diff --git a/package.json b/package.json
index ebd93adc..133169c0 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"description": "Hypertrons Chromium Extension",
"license": "Apache",
"engines": {
- "node": ">=16.14"
+ "node": ">=18"
},
"scripts": {
"build": "cross-env NODE_ENV='production' BABEL_ENV='production' node utils/build.js",
@@ -28,6 +28,7 @@
"antd": "^5.9.1",
"buffer": "^6.0.3",
"colorthief": "^2.4.0",
+ "constate": "^3.3.2",
"delay": "^5.0.0",
"dom-loaded": "^3.0.0",
"echarts": "^5.3.0",
diff --git a/src/api/repo.ts b/src/api/repo.ts
index 63858546..7fce3104 100644
--- a/src/api/repo.ts
+++ b/src/api/repo.ts
@@ -19,6 +19,7 @@ const metricNameMap = new Map([
['developer_network', 'developer_network'],
['repo_network', 'repo_network'],
['activity_details', 'activity_details'],
+ ['issue_response_time', 'issue_response_time'],
]);
export const getActivity = async (repo: string) => {
@@ -88,3 +89,7 @@ export const getRepoNetwork = async (repo: string) => {
export const getActivityDetails = async (repo: string) => {
return getMetricByName(repo, metricNameMap, 'activity_details');
};
+
+export const getIssueResponseTime = async (repo: string) => {
+ return getMetricByName(repo, metricNameMap, 'issue_response_time');
+};
diff --git a/src/helpers/get-newest-month.ts b/src/helpers/get-newest-month.ts
new file mode 100644
index 00000000..4f9b2160
--- /dev/null
+++ b/src/helpers/get-newest-month.ts
@@ -0,0 +1,14 @@
+const getNewestMonth = () => {
+ const now = new Date();
+ if (now.getDate() === 1) {
+ // data for last month is not ready in the first day of the month (#595)
+ now.setDate(0); // a way to let month - 1
+ }
+ now.setDate(0); // see issue #632
+
+ return (
+ now.getFullYear() + '-' + (now.getMonth() + 1).toString().padStart(2, '0')
+ );
+};
+
+export default getNewestMonth;
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionButton/AddToCollections.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionButton/AddToCollections.tsx
new file mode 100644
index 00000000..3aa1cf71
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionButton/AddToCollections.tsx
@@ -0,0 +1,189 @@
+import { useRepoCollectionContext } from '../context';
+import { Collection } from '../context/store';
+
+import React, { useEffect, useState } from 'react';
+
+const CheckListItem = (
+ collection: Collection,
+ onChange: (collectionId: Collection['id'], checked: boolean) => void,
+ checked: boolean
+) => {
+ const handleChange = () => {
+ onChange(collection.id, checked);
+ };
+
+ return (
+
+
+
+ );
+};
+
+/**
+ * The modal for quickly adding the current repository to existing collections (also for removing)
+ */
+export const AddToCollections = () => {
+ const {
+ currentRepositoryId,
+ currentRepositoryCollections,
+ allCollections,
+ updaters,
+ hideAddToCollections,
+ setHideAddToCollections,
+ setHideCollectionList,
+ setShowCollectionModal,
+ } = useRepoCollectionContext();
+
+ const [checkedCollectionIds, setCheckedCollectionIds] = useState<
+ Collection['id'][]
+ >([]);
+
+ const resetCheckboxes = () => {
+ setCheckedCollectionIds(currentRepositoryCollections.map((c) => c.id));
+ };
+
+ // reset checkboxes when currentRepositoryCollections changes
+ useEffect(() => {
+ resetCheckboxes();
+ }, [currentRepositoryCollections]);
+
+ const handleCheckChange = (
+ collectionId: Collection['id'],
+ checked: boolean
+ ) => {
+ if (checked) {
+ setCheckedCollectionIds(
+ checkedCollectionIds.filter((id) => id !== collectionId)
+ );
+ } else {
+ setCheckedCollectionIds([...checkedCollectionIds, collectionId]);
+ }
+ };
+
+ const goToCollectionList = () => {
+ setHideAddToCollections(true);
+ setHideCollectionList(false);
+ };
+
+ const apply = () => {
+ // add/remove relations
+ const toAdd = checkedCollectionIds.filter(
+ (id) => !currentRepositoryCollections.some((c) => c.id === id)
+ );
+ const toRemove = currentRepositoryCollections.filter(
+ (c) => !checkedCollectionIds.includes(c.id)
+ );
+ toAdd &&
+ updaters.addRelations(
+ toAdd.map((id) => ({
+ collectionId: id,
+ repositoryId: currentRepositoryId,
+ }))
+ );
+ toRemove &&
+ updaters.removeRelations(
+ toRemove.map((c) => ({
+ collectionId: c.id,
+ repositoryId: currentRepositoryId,
+ }))
+ );
+
+ goToCollectionList();
+ };
+
+ const cancel = () => {
+ resetCheckboxes();
+
+ goToCollectionList();
+ };
+
+ const manage = () => {
+ // open modal to manage collections
+ setShowCollectionModal(true);
+ };
+
+ // if the ids of currentRepositoryCollections are the same as the ids of selectedCollectionIds, then the "Apply" button should be disabled
+ let isApplyDisabled: boolean;
+ if (currentRepositoryCollections.length !== checkedCollectionIds.length) {
+ isApplyDisabled = false;
+ } else {
+ isApplyDisabled = currentRepositoryCollections.every((c) =>
+ checkedCollectionIds.includes(c.id)
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Add to collections
+
+
+
+ {/* Checklist */}
+
+ {allCollections.map((collection) => {
+ const checked = checkedCollectionIds.includes(collection.id);
+ return CheckListItem(collection, handleCheckChange, checked);
+ })}
+
+ {/* 3 buttons */}
+
+
+
+ );
+};
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionButton/CollectionList.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionButton/CollectionList.tsx
new file mode 100644
index 00000000..ca11cb77
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionButton/CollectionList.tsx
@@ -0,0 +1,127 @@
+import { useRepoCollectionContext } from '../context';
+import { Collection } from '../context/store';
+
+import React from 'react';
+
+const ListItem = (
+ collection: Collection,
+ onClick: (collectionId: Collection['id']) => void
+) => {
+ const handleClick = () => {
+ onClick(collection.id);
+ };
+
+ return (
+
+
+ {collection.name}
+
+
+ );
+};
+
+/**
+ * The modal that shows the collections that the repo belongs to
+ */
+export const CollectionList = () => {
+ const {
+ currentRepositoryCollections,
+ hideCollectionList,
+ setHideAddToCollections,
+ setHideCollectionList,
+ setSelectedCollection,
+ setShowCollectionModal,
+ } = useRepoCollectionContext();
+
+ const handleCollectionClick = (collectionId: Collection['id']) => {
+ setSelectedCollection(collectionId);
+ setShowCollectionModal(true);
+ };
+
+ const goToAddToCollections = () => {
+ setHideAddToCollections(false);
+ setHideCollectionList(true);
+ };
+
+ return (
+
+
+
+
+
+ {/* header */}
+
+
+ Contained in these collections
+
+
+
+ {/* list */}
+
+ {currentRepositoryCollections.map((collection) =>
+ ListItem(collection, handleCollectionClick)
+ )}
+
+ {/* footer */}
+
+
+
+
+
+ );
+};
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionButton/index.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionButton/index.tsx
new file mode 100644
index 00000000..74b65cb2
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionButton/index.tsx
@@ -0,0 +1,41 @@
+import { CollectionList } from './CollectionList';
+import { AddToCollections } from './AddToCollections';
+import { useRepoCollectionContext } from '../context';
+
+import React from 'react';
+import { FundProjectionScreenOutlined } from '@ant-design/icons';
+
+/**
+ * The "Collections" button, which is in the left of the "Edit Pins" button
+ */
+export const CollectionButton = () => {
+ const { currentRepositoryCollections } = useRepoCollectionContext();
+
+ return (
+
+
+
+
+
+ {' Collections '}
+
+
+ {currentRepositoryCollections.length}
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.css b/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.css
new file mode 100644
index 00000000..3ed0d540
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.css
@@ -0,0 +1,6 @@
+/* ChartCard.css */
+
+.custom-card {
+ background-color: #fafafa;
+ /* 其他样式属性 */
+}
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.tsx
new file mode 100644
index 00000000..13856c10
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.tsx
@@ -0,0 +1,23 @@
+import React, { ReactNode } from 'react';
+import { Card } from 'antd';
+import './ChartCard.css'; // 导入自定义样式
+
+interface ChartCardProps {
+ title: ReactNode;
+ children: React.ReactNode;
+}
+
+function ChartCard({ title, children }: ChartCardProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default ChartCard;
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionContent/CollectionDashboard.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionContent/CollectionDashboard.tsx
new file mode 100644
index 00000000..353e590a
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/CollectionDashboard.tsx
@@ -0,0 +1,179 @@
+import LineChart from '../charts/LineChart';
+import BarChart from '../charts/BarChart';
+import SankeyChart from '../charts/SankeyChart';
+import PieChart from '../charts/PieChart';
+import StackedBarChart from '../charts/StackedBarChart';
+import CodeStackedBarChart from '../charts/CodeStackedBarChart';
+import BoxplotChart from '../charts/BoxplotChart';
+import ChartCard from './ChartCard';
+import NumericPanel from '../charts/NumericPanel';
+
+import React from 'react';
+import { Row, Col } from 'antd';
+
+import './index.scss';
+
+interface CollectionDashboardProps {
+ repoNames: string[];
+ currentRepo?: string;
+}
+
+const CollectionDashboard: React.FC = ({
+ repoNames,
+ currentRepo,
+}) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default CollectionDashboard;
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.scss b/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.scss
new file mode 100644
index 00000000..7d083f0a
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.scss
@@ -0,0 +1,12 @@
+.ant-row {
+ margin-right: 0 !important;
+ margin-left: 0 !important;
+}
+
+.ant-col:first-child {
+ padding-left: 0 !important;
+}
+
+.ant-col:last-child {
+ padding-right: 0 !important;
+}
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.tsx
new file mode 100644
index 00000000..81582afe
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.tsx
@@ -0,0 +1,62 @@
+// Index.tsx
+import React, { useState, useEffect } from 'react';
+import { Layout, Menu, theme } from 'antd';
+
+import CollectionDashboard from './CollectionDashboard';
+
+const { Content, Sider } = Layout;
+interface Index {
+ repoNames: string[];
+
+ currentRepo?: string;
+}
+
+const LIGHT_THEME = {
+ BG_COLOR: '#ffffff',
+};
+
+const CollectionContent: React.FC = ({ repoNames, currentRepo }) => {
+ const menuItems = repoNames.map((repo, index) => ({
+ key: index,
+ label: repo,
+ }));
+ const {
+ token: { colorBgContainer },
+ } = theme.useToken();
+
+ // 添加一个状态来跟踪选中的仓库名
+ const [selectedRepo, setSelectedRepo] = useState(
+ undefined
+ );
+ useEffect(() => {
+ setSelectedRepo(currentRepo);
+ }, [currentRepo]);
+ const handleMenuClick = (key: string) => {
+ setSelectedRepo(key);
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default CollectionContent;
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionModal/CollectionEditor.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionModal/CollectionEditor.tsx
new file mode 100644
index 00000000..a387ac58
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionModal/CollectionEditor.tsx
@@ -0,0 +1,318 @@
+import { useRepoCollectionContext } from '../context';
+
+import React, { useEffect, useState } from 'react';
+import {
+ Button,
+ Col,
+ Divider,
+ Form,
+ Input,
+ Modal,
+ Radio,
+ Row,
+ Table,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+interface Values {
+ name: string;
+ quickImport: string;
+}
+
+interface DataType {
+ key: React.Key;
+ name: string;
+ description: string;
+}
+
+interface RepositoryInfo {
+ name: string;
+ description: string;
+}
+
+interface CollectionEditorProps {
+ open: boolean;
+ onCreate: (values: Values, newRepoData: string[]) => void;
+ onCancel: () => void;
+ isEdit: boolean | undefined;
+ collectionName: string;
+ collectionData: string[];
+}
+
+interface DataSourceType {
+ key: string;
+ name: string;
+ description: string;
+}
+
+const accessTokens = ['token'];
+
+let currentTokenIndex = 0;
+
+async function getUserOrOrgRepos(
+ username: string,
+ addType: string
+): Promise {
+ try {
+ const currentAccessToken = accessTokens[currentTokenIndex];
+ let apiUrl = '';
+ if (addType === 'User') {
+ apiUrl = `https://api.github.com/users/${username}/repos`;
+ } else if (addType === 'Organization') {
+ apiUrl = `https://api.github.com/orgs/${username}/repos`;
+ }
+
+ const response = await fetch(apiUrl, {
+ headers: {
+ Authorization: `Bearer ${currentAccessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ currentTokenIndex = (currentTokenIndex + 1) % accessTokens.length; // switch to next token
+ return getUserOrOrgRepos(username, addType);
+ } else {
+ throw new Error(
+ `GitHub API request failed with status: ${response.status}`
+ );
+ }
+ }
+
+ const reposData = await response.json();
+
+ return reposData.map((repo: any) => ({
+ name: repo.name,
+ description: repo.description || '',
+ }));
+ } catch (error) {
+ console.error('Error fetching repositories:', error);
+ throw error;
+ }
+}
+
+// TODO 需要找到一个合适的方法解决Token的问题...
+
+const columns: ColumnsType = [
+ {
+ title: 'name',
+ dataIndex: 'name',
+ },
+ {
+ title: 'description',
+ dataIndex: 'description',
+ },
+];
+
+const CollectionEditor: React.FC = ({
+ open,
+ onCreate,
+ onCancel,
+ isEdit,
+ collectionName,
+ collectionData,
+}) => {
+ const { allCollections } = useRepoCollectionContext();
+ const [selectedRowKeys, setSelectedRowKeys] = useState([]);
+ const [form] = Form.useForm();
+ const [dataSource, setDataSource] = useState();
+ const [newRepoData, setNewRepoData] = useState(collectionData);
+ const [addType, setAddType] = useState('FullName');
+
+ async function fetchRepositoryDescription(repositoryName: string) {
+ const apiUrl = `https://api.github.com/repos/${repositoryName}`;
+
+ const response = await fetch(apiUrl);
+ if (!response.ok) {
+ throw new Error(
+ `GitHub API request failed for ${repositoryName} with status: ${response.status}`
+ );
+ }
+ const repoData = await response.json();
+ return {
+ key: collectionData.indexOf(repositoryName).toString(),
+ name: repositoryName,
+ description: repoData.description || '',
+ };
+ }
+
+ useEffect(() => {
+ if (isEdit) {
+ Promise.all(
+ collectionData.map((repositoryName) =>
+ fetchRepositoryDescription(repositoryName)
+ )
+ )
+ .then((repositoryDescriptions) => {
+ setDataSource(repositoryDescriptions);
+ })
+ .catch((error) => {
+ console.error('Error fetching repository descriptions:', error);
+ });
+ }
+ }, []);
+
+ const initialValues = {
+ collectionName: isEdit ? collectionName : '',
+ };
+ const modalTitle = isEdit ? 'Collection Editor' : 'Create a new collection';
+
+ const onSelectChange = (
+ newSelectedRowKeys: React.Key[],
+ selectedRows: DataType[]
+ ) => {
+ setNewRepoData(selectedRows.map((item) => item.name));
+ setSelectedRowKeys(newSelectedRowKeys);
+ };
+
+ const defaultSelectedRowKeys: React.Key[] = Array.from(
+ { length: collectionData.length },
+ (_, index) => index.toString()
+ );
+ const rowSelection = {
+ defaultSelectedRowKeys,
+ onChange: onSelectChange,
+ };
+
+ const handleSearchClick = () => {
+ const inputValue = form.getFieldValue('Quick import');
+ if (addType === 'FullName') {
+ fetchRepositoryDescription(inputValue)
+ .then((repoDescription) => {
+ const key = dataSource ? dataSource.length + 1 : 1;
+ repoDescription.key = key.toString();
+ console.log('repoDescription', repoDescription);
+ if (dataSource) {
+ setDataSource([...dataSource, repoDescription]);
+ } else {
+ console.log('repoDescription', repoDescription);
+ setDataSource([repoDescription]);
+ }
+ })
+ .catch((error) => {
+ console.error('Error fetching repository description:', error);
+ });
+ } else {
+ fetchRepositories();
+ }
+
+ async function fetchRepositories() {
+ try {
+ const result = await getUserOrOrgRepos(inputValue, addType);
+ let nextKey: number;
+ if (dataSource) {
+ nextKey = dataSource.length + 1;
+ } else {
+ nextKey = 1;
+ }
+ const addKeyValue = [
+ ...result.map((repo) => ({
+ key: (nextKey++).toString(),
+ name: repo.name,
+ description: repo.description,
+ })),
+ ];
+ if (dataSource) {
+ setDataSource([...dataSource, ...addKeyValue]);
+ } else {
+ setDataSource(addKeyValue);
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ }
+ }
+ };
+
+ function handleImportClick() {
+ console.log('newRepoData', newRepoData);
+ }
+
+ return (
+ {
+ form
+ .validateFields()
+ .then((values) => {
+ form.resetFields();
+ onCreate(values, newRepoData);
+ })
+ .catch((info) => {
+ console.log('Validate Failed:', info);
+ });
+ }}
+ >
+ {
+ const editedCollectionName = isEdit ? collectionName : null;
+ if (value === editedCollectionName) {
+ return Promise.resolve();
+ }
+ if (allCollections.some((item) => item.name === value)) {
+ return Promise.reject('Collection name already exists.');
+ }
+ return Promise.resolve();
+ },
+ },
+ ]}
+ >
+
+
+
+
+
+
+
+ {
+ const selectedValue = e.target.value;
+ setAddType(selectedValue);
+ }}
+ >
+ FullName
+ User
+ Organization
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CollectionEditor;
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.scss b/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.scss
new file mode 100644
index 00000000..17d9dca8
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.scss
@@ -0,0 +1,3 @@
+.ant-tabs-content-holder {
+ overflow: auto;
+}
diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.tsx
new file mode 100644
index 00000000..a1ca36f2
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.tsx
@@ -0,0 +1,216 @@
+import { useRepoCollectionContext } from '../context';
+import CollectionEditor from './CollectionEditor';
+import CollectionContent from '../CollectionContent';
+
+import React, { useState } from 'react';
+import { Modal, Tabs, Button } from 'antd';
+
+import './index.scss';
+
+type TargetKey = React.MouseEvent | React.KeyboardEvent | string;
+
+type CollectionTabType = {
+ label: string;
+ children: React.ReactNode;
+ key: string;
+};
+
+export const CollectionModal = () => {
+ const {
+ showCollectionModal,
+ setShowCollectionModal,
+ selectedCollection,
+ setSelectedCollection,
+ updaters,
+ allCollections,
+ allRelations,
+ } = useRepoCollectionContext();
+
+ const [activeKey, setActiveKey] = useState();
+ const [items, setItems] = useState([]);
+ const [isInEditMode, setIsInEditMode] = useState(false);
+
+ const editTab = (
+
+
+
+
+ );
+
+ const onCreate = async (values: any, newRepoData: string[]) => {
+ if (isInEditMode) {
+ const updatedItems = items.map((item) => {
+ if (item.key === activeKey?.toString()) {
+ return {
+ label: values.collectionName,
+ children: ,
+ key: values.collectionName,
+ };
+ }
+ return item;
+ });
+ setItems(updatedItems);
+ } else {
+ const newPanes = [...items];
+ newPanes.push({
+ label: values.collectionName,
+ children: ,
+ key: values.collectionName,
+ });
+ setItems(newPanes);
+ setActiveKey(values.collectionName);
+ }
+
+ try {
+ /*
+ * remove collection and its relations
+ */
+ if (selectedCollection) {
+ await updaters.removeCollection(selectedCollection);
+ const relationsToRemove = allRelations.filter(
+ (relation) => relation.collectionId === selectedCollection
+ );
+ await updaters.removeRelations(relationsToRemove);
+ }
+
+ /*
+ * add newCollection and its relations
+ */
+
+ await updaters.addCollection({
+ id: values.collectionName,
+ name: values.collectionName,
+ });
+ if (newRepoData) {
+ const relationsToAdd = newRepoData.map((repo) => ({
+ collectionId: values.collectionName,
+ repositoryId: repo,
+ }));
+ await updaters.addRelations(relationsToAdd);
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ }
+ console.log('Received values of form: ', values);
+
+ setSelectedCollection(values.collectionName);
+ setIsInEditMode(false);
+ };
+
+ const onChange = (newActiveKey: string) => {
+ setActiveKey(newActiveKey);
+ setSelectedCollection(newActiveKey);
+ };
+
+ const remove = (targetKey: TargetKey) => {
+ Modal.confirm({
+ title: 'Confirm Deletion',
+ content: 'Are you sure you want to delete this collection?',
+ okText: 'Confirm',
+ onOk() {
+ let newActiveKey = activeKey;
+ let lastIndex = -1;
+ items.forEach((item, i) => {
+ if (item.key === targetKey) {
+ lastIndex = i - 1;
+ }
+ });
+ const newPanes = items.filter((item) => item.key !== targetKey);
+ if (newPanes.length && newActiveKey === targetKey) {
+ if (lastIndex >= 0) {
+ newActiveKey = newPanes[lastIndex].key;
+ } else {
+ newActiveKey = newPanes[0].key;
+ }
+ }
+ setItems(newPanes);
+ setActiveKey(newActiveKey);
+ updaters.removeCollection(targetKey.toString());
+ setSelectedCollection(newActiveKey);
+ },
+ onCancel() {},
+ });
+ };
+
+ const onEdit = (
+ targetKey: React.MouseEvent | React.KeyboardEvent | string,
+ action: 'add' | 'remove'
+ ) => {
+ if (action === 'remove') remove(targetKey);
+ };
+
+ const tabItems = allCollections.map((collection) => {
+ const repoList = allRelations
+ .filter((relation) => relation.collectionId === collection.name)
+ .map((relation) => relation.repositoryId);
+ return {
+ label: collection.name,
+ children: ,
+ key: collection.id,
+ };
+ });
+
+ return (
+
+ {
+ setShowCollectionModal(false);
+ setSelectedCollection(undefined);
+ }}
+ footer={null}
+ width={'95%'}
+ style={{
+ top: '10px',
+ bottom: '10px',
+ height: '95vh',
+ }}
+ bodyStyle={{ height: 'calc(95vh - 30px)' }} // 40px is the sum of top and bottom padding
+ >
+
+
+ {
+ setIsInEditMode(false);
+ }}
+ isEdit={isInEditMode}
+ collectionName={selectedCollection ? selectedCollection : ''}
+ collectionData={
+ isInEditMode
+ ? allRelations
+ .filter(
+ (relation) => relation.collectionId === selectedCollection
+ )
+ .map((relation) => relation.repositoryId)
+ : ['']
+ }
+ />
+
+ );
+};
diff --git a/src/pages/ContentScripts/features/repo-collection/charts/BarChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/BarChart.tsx
new file mode 100644
index 00000000..696d124d
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/charts/BarChart.tsx
@@ -0,0 +1,137 @@
+import React, { useEffect, useRef, useState } from 'react';
+import * as echarts from 'echarts';
+import generateDataByMonth from '../../../../../helpers/generate-data-by-month';
+import { getStars } from '../../../../../api/repo';
+
+interface RawRepoData {
+ [date: string]: number;
+}
+
+const LIGHT_THEME = {
+ FG_COLOR: '#24292f',
+ BG_COLOR: '#ffffff',
+ PALLET: ['#5470c6', '#91cc75'],
+};
+
+const DARK_THEME = {
+ FG_COLOR: '#c9d1d9',
+ BG_COLOR: '#0d1118',
+ PALLET: ['#58a6ff', '#3fb950'],
+};
+
+interface BarChartProps {
+ theme: 'light' | 'dark';
+ height: number;
+ repoNames: string[];
+ currentRepo?: string;
+}
+
+const BarChart = (props: BarChartProps): JSX.Element => {
+ const { theme, height, repoNames, currentRepo } = props;
+ const divEL = useRef(null);
+ const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME;
+ const [data, setData] = useState<{ [repo: string]: RawRepoData }>({});
+
+ const option: echarts.EChartsOption = {
+ tooltip: {
+ trigger: 'axis',
+ },
+ // legend: {
+ // type: 'scroll',
+ // },
+ grid: {
+ left: '5%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true,
+ },
+ xAxis: {
+ type: 'time',
+ splitLine: {
+ show: false,
+ },
+ axisLabel: {
+ color: TH.FG_COLOR,
+ formatter: {
+ year: '{yearStyle|{yy}}',
+ month: '{MMM}',
+ },
+ rich: {
+ yearStyle: {
+ fontWeight: 'bold',
+ },
+ },
+ },
+ },
+ yAxis: {
+ type: 'value',
+ },
+ dataZoom: [
+ {
+ type: 'inside',
+ start: 0,
+ end: 100,
+ minValueSpan: 3600 * 24 * 1000 * 180,
+ },
+ ],
+ series: BarChartSeries(data), // / Utilize the transformed series data
+ };
+ console.log('bar', BarChartSeries(data));
+
+ useEffect(() => {
+ const fetchData = async () => {
+ for (const repo of repoNames) {
+ try {
+ //getStars() to fetch repository data
+ const starsData = await getStars(repo);
+ // Update Data/
+ setData((prevData) => ({ ...prevData, [repo]: starsData }));
+ } catch (error) {
+ console.error(`Error fetching stars data for ${repo}:`, error);
+ // If the retrieval fails, set the data to an empty object
+ setData((prevData) => ({ ...prevData, [repo]: {} }));
+ }
+ }
+ };
+ fetchData();
+ }, []);
+
+ useEffect(() => {
+ let chartDOM = divEL.current;
+ const TH = 'light' ? LIGHT_THEME : DARK_THEME;
+
+ const instance = echarts.init(chartDOM as any);
+ instance.setOption(option);
+ instance.dispatchAction({
+ type: 'highlight',
+ // seriesIndex: Number(currentRepo),
+ // dataIndex: Number(currentRepo),
+ name: repoNames[Number(currentRepo)],
+ seriesName: repoNames[Number(currentRepo)],
+ });
+ return () => {
+ instance.dispose();
+ };
+ }, [data, currentRepo]);
+
+ return ;
+};
+const BarChartSeries = (data: {
+ [repo: string]: RawRepoData;
+}): echarts.SeriesOption[] =>
+ Object.entries(data).map(([repoName, repoData]) => ({
+ name: repoName,
+ type: 'bar',
+ symbol: 'none',
+ data: getLastSixMonth(generateDataByMonth(repoData)),
+ emphasis: {
+ focus: 'series',
+ },
+ yAxisIndex: 0,
+ triggerLineEvent: true,
+ }));
+
+const getLastSixMonth = (data: any[]) =>
+ data.length > 6 ? data.slice(-6) : data;
+
+export default BarChart;
diff --git a/src/pages/ContentScripts/features/repo-collection/charts/BoxplotChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/BoxplotChart.tsx
new file mode 100644
index 00000000..65d9d45c
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/charts/BoxplotChart.tsx
@@ -0,0 +1,159 @@
+import React, { useEffect, useRef, useState } from 'react';
+import * as echarts from 'echarts';
+import { getIssueResponseTime } from '../../../../../api/repo';
+import getNewestMonth from '../../../../../helpers/get-newest-month';
+
+const LIGHT_THEME = {
+ FG_COLOR: '#24292f',
+ BG_COLOR: '#ffffff',
+ PALLET: ['#5470c6', '#91cc75'],
+};
+
+const DARK_THEME = {
+ FG_COLOR: '#c9d1d9',
+ BG_COLOR: '#0d1118',
+ PALLET: ['#58a6ff', '#3fb950'],
+};
+
+interface BarChartProps {
+ theme: 'light' | 'dark';
+ height: number;
+ repoNames: string[];
+
+ currentRepo?: string;
+}
+
+const BoxplotChart = (props: BarChartProps): JSX.Element => {
+ const { theme, height, repoNames, currentRepo } = props;
+ const divEL = useRef(null);
+ const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME;
+ const [data, setData] = useState<{}>({});
+
+ console.log('Boxplot_data,', lastMonthRepoData(data));
+ const option: echarts.EChartsOption = {
+ tooltip: {
+ trigger: 'item',
+ axisPointer: {
+ type: 'shadow',
+ },
+ },
+ legend: {
+ type: 'scroll',
+ },
+ grid: {
+ left: '5%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true,
+ },
+ xAxis: {
+ show: false,
+ type: 'category',
+ boundaryGap: true,
+ nameGap: 30,
+ splitArea: {
+ show: false,
+ },
+ // data: Object.keys(data),
+ splitLine: {
+ show: false,
+ },
+ // data: lastMonthRepoData(data).map((repo) => repo.name),
+ },
+ yAxis: {
+ type: 'value',
+ name: 'value',
+ splitArea: {
+ show: true,
+ },
+ },
+ // dataZoom: [
+ // {
+ // type: 'inside',
+ // start: 0,
+ // end: 100,
+ // minValueSpan: 3600 * 24 * 1000 * 180,
+ // },
+ // ],
+ series: lastMonthRepoData(data).map((repoData) => {
+ return {
+ type: 'boxplot',
+ name: repoData.name,
+ data: [repoData],
+ };
+ }),
+ // {
+ // type: 'boxplot',
+ // data: lastMonthRepoData(data),
+ // },
+ };
+ useEffect(() => {
+ const fetchData = async () => {
+ for (const repo of repoNames) {
+ try {
+ //getStars() to fetch repository data
+ const starsData = await getIssueResponseTime(repo);
+ // console.log('starsDatastarsData', starsData);
+ // Update Data/
+ setData((prevData) => ({ ...prevData, [repo]: starsData }));
+ } catch (error) {
+ console.error(`Error fetching stars data for ${repo}:`, error);
+ // If the retrieval fails, set the data to an empty object
+ setData((prevData) => ({ ...prevData, [repo]: {} }));
+ }
+ }
+ };
+ fetchData();
+ // console.log('data', data);
+ }, []);
+
+ useEffect(() => {
+ let chartDOM = divEL.current;
+ const TH = 'light' ? LIGHT_THEME : DARK_THEME;
+
+ const instance = echarts.init(chartDOM as any);
+ // console.log('data', data);
+ // console.log('lastMonthRepoData', lastMonthRepoData(data));
+ instance.setOption(option);
+ instance.dispatchAction({
+ type: 'highlight',
+ seriesIndex: Number(currentRepo),
+ dataIndex: Number(currentRepo),
+ name: repoNames[Number(currentRepo)],
+ seriesName: repoNames[Number(currentRepo)],
+ });
+ return () => {
+ instance.dispose();
+ };
+ }, [data, currentRepo]);
+
+ return ;
+};
+
+//原始数据 =>各仓库最近一个月的数据[]
+function lastMonthRepoData(repo_data: any) {
+ const resultArray = [];
+ const lastMonth = getNewestMonth();
+ for (const repoName in repo_data) {
+ if (repo_data.hasOwnProperty(repoName)) {
+ const lastMonthData = {
+ name: repoName,
+ value:
+ repo_data[repoName][`avg`][lastMonth] !== undefined
+ ? Array.from(
+ { length: 5 },
+ (_, q) => repo_data[repoName][`quantile_${q}`][lastMonth]
+ )
+ : [null, null, null, null, null],
+ };
+
+ resultArray.push(lastMonthData);
+ // 将转换后的数据存储为对象,并添加到结果数组中
+ // console.log('repoName', repoName);
+ // console.log('lastM', repo_data[repoName][`avg`][lastMonth]);
+ }
+ }
+ return resultArray;
+}
+
+export default BoxplotChart;
diff --git a/src/pages/ContentScripts/features/repo-collection/charts/CodeStackedBarChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/CodeStackedBarChart.tsx
new file mode 100644
index 00000000..430a369c
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/charts/CodeStackedBarChart.tsx
@@ -0,0 +1,179 @@
+import React, { useEffect, useRef, useState } from 'react';
+import * as echarts from 'echarts';
+import generateDataByMonth from '../../../../../helpers/generate-data-by-month';
+import {
+ getMergedCodeAddition,
+ getMergedCodeDeletion,
+} from '../../../../../api/repo';
+
+interface RawRepoData {
+ [date: string]: number;
+}
+
+const LIGHT_THEME = {
+ FG_COLOR: '#24292f',
+ BG_COLOR: '#ffffff',
+ PALLET: ['#5470c6', '#91cc75'],
+};
+
+const DARK_THEME = {
+ FG_COLOR: '#c9d1d9',
+ BG_COLOR: '#0d1118',
+ PALLET: ['#58a6ff', '#3fb950'],
+};
+
+interface StackedBarChartProps {
+ theme: 'light' | 'dark';
+ height: number;
+ repoNames: string[];
+
+ currentRepo?: string;
+}
+
+const CodeStackedBarChart = (props: StackedBarChartProps): JSX.Element => {
+ const { theme, height, repoNames, currentRepo } = props;
+ const divEL = useRef(null);
+ const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME;
+ const [data, setData] = useState<{ [repo: string]: RawRepoData }>({});
+
+ const option: echarts.EChartsOption = {
+ tooltip: {
+ trigger: 'axis',
+ axisPointer: {
+ type: 'cross', // 设置 axisPointer 的类型为 cross,即十字准星
+ },
+ // formatter: (params: any) => {
+ // console.log('params',params);
+
+ // return result;
+ // },
+ },
+ legend: {
+ type: 'scroll',
+ },
+ grid: {
+ left: '5%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true,
+ },
+ xAxis: {
+ type: 'time',
+ splitLine: {
+ show: false,
+ },
+ axisLabel: {
+ color: TH.FG_COLOR,
+ formatter: {
+ year: '{yearStyle|{yy}}',
+ month: '{MMM}',
+ },
+ rich: {
+ yearStyle: {
+ fontWeight: 'bold',
+ },
+ },
+ },
+ },
+ yAxis: {
+ type: 'value',
+ },
+ dataZoom: [
+ {
+ type: 'inside',
+ start: 0,
+ end: 100,
+ minValueSpan: 3600 * 24 * 1000 * 180,
+ },
+ ],
+ series: MCDeletionSeries(data).concat(MCAdditionSeries(data)), // Series Data: Code Addition + Code CodeDeletion
+ };
+ console.log(
+ 'BarChartSeries',
+ MCDeletionSeries(data).concat(MCAdditionSeries(data))
+ );
+ useEffect(() => {
+ const fetchData = async () => {
+ for (const repo of repoNames) {
+ try {
+ const MCAdditionData = await getMergedCodeAddition(repo);
+ const MCDeletionData = await getMergedCodeDeletion(repo);
+ const MergedCodeData = {
+ MCAdditionData: MCAdditionData,
+ MCDeletionData: MCDeletionData,
+ };
+ setData((prevData) => ({ ...prevData, [repo]: MergedCodeData }));
+ } catch (error) {
+ console.error(`Error fetching stars data for ${repo}:`, error);
+
+ setData((prevData) => ({ ...prevData, [repo]: {} }));
+ }
+ }
+ };
+ fetchData();
+ }, []);
+ console.log('data', data);
+ useEffect(() => {
+ let chartDOM = divEL.current;
+ const TH = 'light' ? LIGHT_THEME : DARK_THEME;
+
+ const instance = echarts.init(chartDOM as any);
+ instance.setOption(option);
+ instance.dispatchAction({
+ type: 'highlight',
+ // seriesIndex: Number(currentRepo),
+ // dataIndex: Number(currentRepo),
+ name: repoNames[Number(currentRepo)],
+ seriesName: repoNames[Number(currentRepo)],
+ });
+ return () => {
+ instance.dispose();
+ };
+ }, [data, currentRepo]);
+
+ return ;
+};
+
+//Series:各仓库代码增加行数
+const MCAdditionSeries = (data: {
+ [repo: string]: RawRepoData;
+}): echarts.SeriesOption[] =>
+ Object.entries(data).map(([repoName, repoData]) => ({
+ name: repoName,
+ type: 'line',
+ areaStyle: {},
+ smooth: true,
+ symbol: 'none',
+ stack: repoName,
+ data: generateDataByMonth(repoData.MCAdditionData),
+ emphasis: {
+ focus: 'series',
+ },
+ yAxisIndex: 0,
+ triggerLineEvent: true,
+ }));
+
+//Series:各仓库代码删减行数
+const MCDeletionSeries = (data: {
+ [repo: string]: RawRepoData;
+}): echarts.SeriesOption[] =>
+ Object.entries(data).map(([repoName, repoData]) => ({
+ name: repoName,
+ type: 'line',
+ areaStyle: {},
+ symbol: 'none',
+ smooth: true,
+ stack: repoName,
+ data: generateDataByMonth(repoData.MCDeletionData).map((item) => [
+ item[0],
+ -item[1],
+ ]),
+ emphasis: {
+ focus: 'series',
+ },
+ yAxisIndex: 0,
+ triggerLineEvent: true,
+ }));
+
+//const getLastSixMonth = (data: any[]) => (data.length > 6 ? data.slice(-6) : data);
+export default CodeStackedBarChart;
diff --git a/src/pages/ContentScripts/features/repo-collection/charts/LineChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/LineChart.tsx
new file mode 100644
index 00000000..508da19e
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/charts/LineChart.tsx
@@ -0,0 +1,138 @@
+import React, { useEffect, useRef, useState } from 'react';
+import * as echarts from 'echarts';
+import generateDataByMonth from '../../../../../helpers/generate-data-by-month';
+import { getStars } from '../../../../../api/repo';
+
+interface RawRepoData {
+ [date: string]: number;
+}
+
+const LIGHT_THEME = {
+ FG_COLOR: '#24292f',
+ BG_COLOR: '#ffffff',
+ PALLET: ['#5470c6', '#91cc75'],
+};
+
+const DARK_THEME = {
+ FG_COLOR: '#c9d1d9',
+ BG_COLOR: '#0d1118',
+ PALLET: ['#58a6ff', '#3fb950'],
+};
+
+interface LineChartProps {
+ theme: 'light' | 'dark';
+ height: number;
+ repoNames: string[];
+
+ currentRepo?: string;
+}
+
+const LineChart = (props: LineChartProps): JSX.Element => {
+ const { theme, height, repoNames, currentRepo } = props;
+
+ const divEL = useRef(null);
+ const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME;
+ const [data, setData] = useState<{ [repo: string]: RawRepoData }>({});
+
+ const option: echarts.EChartsOption = {
+ tooltip: {
+ trigger: 'axis',
+ },
+ legend: {
+ type: 'scroll',
+ },
+ grid: {
+ left: '5%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true,
+ },
+ xAxis: {
+ type: 'time',
+ splitLine: {
+ show: false,
+ },
+ axisLabel: {
+ color: TH.FG_COLOR,
+ formatter: {
+ year: '{yearStyle|{yy}}',
+ month: '{MMM}',
+ },
+ rich: {
+ yearStyle: {
+ fontWeight: 'bold',
+ },
+ },
+ },
+ },
+ yAxis: {
+ type: 'value',
+ },
+ dataZoom: [
+ {
+ type: 'slider',
+ },
+ {
+ type: 'inside',
+ // start: 0,
+ // end: 100,
+ minValueSpan: 3600 * 24 * 1000 * 180,
+ },
+ ],
+ series: LineChartSeries(data),
+ };
+
+ useEffect(() => {
+ const fetchData = async () => {
+ for (const repo of repoNames) {
+ try {
+ //getStars() to fetch repository data
+ const starsData = await getStars(repo);
+ // Update Data
+ setData((prevData) => ({ ...prevData, [repo]: starsData }));
+ } catch (error) {
+ console.error(`Error fetching stars data for ${repo}:`, error);
+ // If the retrieval fails, set the data to an empty object
+ setData((prevData) => ({ ...prevData, [repo]: {} }));
+ }
+ }
+ };
+ fetchData();
+ }, []);
+
+ useEffect(() => {
+ let chartDOM = divEL.current;
+ const TH = 'light' ? LIGHT_THEME : DARK_THEME;
+
+ const instance = echarts.init(chartDOM as any);
+ instance.setOption(option);
+ instance.dispatchAction({
+ type: 'highlight',
+ // seriesIndex: Number(currentRepo),
+ // dataIndex: Number(currentRepo),
+ name: repoNames[Number(currentRepo)],
+ seriesName: repoNames[Number(currentRepo)],
+ });
+ return () => {
+ instance.dispose();
+ };
+ }, [data, currentRepo]);
+
+ return ;
+};
+const LineChartSeries = (data: {
+ [repo: string]: RawRepoData;
+}): echarts.SeriesOption[] =>
+ Object.entries(data).map(([repoName, repoData]) => ({
+ name: repoName,
+ type: 'line',
+ symbol: 'none',
+ smooth: true,
+ data: generateDataByMonth(repoData),
+ emphasis: {
+ focus: 'series',
+ },
+ yAxisIndex: 0,
+ triggerLineEvent: true,
+ }));
+export default LineChart;
diff --git a/src/pages/ContentScripts/features/repo-collection/charts/NumericPanel.tsx b/src/pages/ContentScripts/features/repo-collection/charts/NumericPanel.tsx
new file mode 100644
index 00000000..e60735bf
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/charts/NumericPanel.tsx
@@ -0,0 +1,101 @@
+import React, { useEffect, useRef, useState } from 'react';
+import * as echarts from 'echarts';
+import generateDataByMonth from '../../../../../helpers/generate-data-by-month';
+import { getStars } from '../../../../../api/repo';
+import getNewestMonth from '../../../../../helpers/get-newest-month';
+
+interface RawRepoData {
+ [date: string]: number;
+}
+
+const LIGHT_THEME = {
+ FG_COLOR: '#24292f',
+ BG_COLOR: '#ffffff',
+ PALLET: ['#5470c6', '#91cc75'],
+};
+
+const DARK_THEME = {
+ FG_COLOR: '#c9d1d9',
+ BG_COLOR: '#0d1118',
+ PALLET: ['#58a6ff', '#3fb950'],
+};
+
+interface BarChartProps {
+ theme: 'light' | 'dark';
+ height: number;
+ repoNames: string[];
+ currentRepo?: string;
+}
+
+const NumericPanel = (props: BarChartProps): JSX.Element => {
+ const { theme, height, repoNames, currentRepo } = props;
+ const divEL = useRef(null);
+ const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME;
+ const [data, setData] = useState<{ [repo: string]: RawRepoData }>({});
+
+ const option: echarts.EChartsOption = {
+ graphic: [
+ {
+ type: 'text',
+ left: 'center',
+ top: 'center',
+ style: {
+ fill: '#333',
+ text: valueSum(data).toString(),
+ font: 'bold 48px Arial',
+ },
+ },
+ ],
+ };
+
+ useEffect(() => {
+ const fetchData = async () => {
+ for (const repo of repoNames) {
+ try {
+ //getStars() to fetch repository data
+ const starsData = await getStars(repo);
+ // Update Data/
+ setData((prevData) => ({ ...prevData, [repo]: starsData }));
+ } catch (error) {
+ console.error(`Error fetching stars data for ${repo}:`, error);
+ // If the retrieval fails, set the data to an empty object
+ setData((prevData) => ({ ...prevData, [repo]: {} }));
+ }
+ }
+ };
+ fetchData();
+ }, []);
+
+ useEffect(() => {
+ let chartDOM = divEL.current;
+ const TH = 'light' ? LIGHT_THEME : DARK_THEME;
+
+ const instance = echarts.init(chartDOM as any);
+ instance.setOption(option);
+ instance.dispatchAction({
+ type: 'highlight',
+ // seriesIndex: Number(currentRepo),
+ // dataIndex: Number(currentRepo),
+ name: repoNames[Number(currentRepo)],
+ seriesName: repoNames[Number(currentRepo)],
+ });
+ return () => {
+ instance.dispose();
+ };
+ }, [data, currentRepo]);
+
+ return ;
+};
+
+function valueSum(data: Record): number {
+ return Object.values(data).reduce((sum, repoData) => {
+ const lastData = generateDataByMonth(repoData).at(-1);
+ const value =
+ lastData !== undefined && lastData[0] === getNewestMonth()
+ ? lastData[1]
+ : 0;
+ return sum + value;
+ }, 0);
+}
+
+export default NumericPanel;
diff --git a/src/pages/ContentScripts/features/repo-collection/charts/PieChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/PieChart.tsx
new file mode 100644
index 00000000..f8af46e1
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/charts/PieChart.tsx
@@ -0,0 +1,122 @@
+import React, { useEffect, useRef, useState } from 'react';
+import * as echarts from 'echarts';
+import generateDataByMonth from '../../../../../helpers/generate-data-by-month';
+import { getParticipant } from '../../../../../api/repo';
+import getNewestMonth from '../../../../../helpers/get-newest-month';
+
+interface RawRepoData {
+ [date: string]: number;
+}
+
+const LIGHT_THEME = {
+ FG_COLOR: '#24292f',
+ BG_COLOR: '#ffffff',
+ PALLET: ['#5470c6', '#91cc75'],
+};
+
+const DARK_THEME = {
+ FG_COLOR: '#c9d1d9',
+ BG_COLOR: '#0d1118',
+ PALLET: ['#58a6ff', '#3fb950'],
+};
+
+interface PieChartProps {
+ theme: 'light' | 'dark';
+ height: number;
+ repoNames: string[];
+
+ currentRepo?: string;
+}
+
+const PieChart = (props: PieChartProps): JSX.Element => {
+ const { theme, height, repoNames, currentRepo } = props;
+ const divEL = useRef(null);
+ const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME;
+ const [data, setData] = useState<{ [repo: string]: RawRepoData }>({});
+
+ const option: echarts.EChartsOption = {
+ tooltip: {
+ trigger: 'item',
+ },
+ legend: {
+ type: 'scroll',
+ },
+
+ series: [
+ {
+ type: 'pie',
+ radius: ['40%', '70%'],
+ avoidLabelOverlap: false,
+ itemStyle: {
+ borderRadius: 10,
+ borderColor: '#fff',
+ borderWidth: 2,
+ },
+ label: {
+ show: false,
+ position: 'center',
+ },
+ emphasis: {
+ label: {
+ show: true,
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+ },
+ data: PieChartData(data),
+ },
+ ],
+ };
+
+ useEffect(() => {
+ const fetchData = async () => {
+ for (const repo of repoNames) {
+ try {
+ //getStars() to fetch repository data
+ const starsData = await getParticipant(repo);
+ // Update Data
+ setData((prevData) => ({ ...prevData, [repo]: starsData }));
+ } catch (error) {
+ console.error(`Error fetching stars data for ${repo}:`, error);
+ // If the retrieval fails, set the data to an empty object
+ setData((prevData) => ({ ...prevData, [repo]: {} }));
+ }
+ }
+ };
+ fetchData();
+ }, []);
+
+ useEffect(() => {
+ let chartDOM = divEL.current;
+ const TH = 'light' ? LIGHT_THEME : DARK_THEME;
+
+ const instance = echarts.init(chartDOM as any);
+ instance.setOption(option);
+ console.log('pieChart,currentRepo', currentRepo);
+ instance.dispatchAction({
+ type: 'highlight',
+ // seriesIndex: 0,
+ dataIndex: Number(currentRepo),
+ });
+ return () => {
+ instance.dispose();
+ };
+ }, [data, currentRepo]);
+
+ return ;
+};
+
+// Retrieve data for the current month
+const PieChartData = (data: { [repo: string]: RawRepoData }) =>
+ Object.entries(data).map(([repoName, repoData]) => {
+ const lastData = generateDataByMonth(repoData).at(-1);
+ return {
+ name: repoName,
+ value:
+ lastData !== undefined && lastData[0] === getNewestMonth()
+ ? lastData[1]
+ : 0,
+ };
+ });
+
+export default PieChart;
diff --git a/src/pages/ContentScripts/features/repo-collection/charts/SankeyChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/SankeyChart.tsx
new file mode 100644
index 00000000..02c15c1f
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/charts/SankeyChart.tsx
@@ -0,0 +1,159 @@
+import React, { useEffect, useRef, useState } from 'react';
+import * as echarts from 'echarts';
+import getNewestMonth from '../../../../../helpers/get-newest-month';
+import { getActivityDetails } from '../../../../../api/repo';
+
+const LIGHT_THEME = {
+ FG_COLOR: '#24292f',
+ BG_COLOR: '#ffffff',
+ PALLET: ['#5470c6', '#91cc75'],
+};
+
+const DARK_THEME = {
+ FG_COLOR: '#c9d1d9',
+ BG_COLOR: '#0d1118',
+ PALLET: ['#58a6ff', '#3fb950'],
+};
+
+interface SankeyChartProps {
+ theme: 'light' | 'dark';
+ height: number;
+ repoNames: string[];
+
+ currentRepo?: string;
+}
+
+const SankeyChart = (props: SankeyChartProps): JSX.Element => {
+ const { theme, height, repoNames, currentRepo } = props;
+ const divEL = useRef(null);
+ const [data, setData] = useState<{ [repoName: string]: RepoData }>({});
+
+ const option: echarts.EChartsOption = {
+ tooltip: {
+ trigger: 'item',
+ triggerOn: 'mousemove',
+ },
+ // legend: {
+ // type: 'scroll',
+ // },
+ animation: false,
+ grid: {
+ left: '2%',
+ right: '10%',
+ bottom: '3%',
+ // containLabel: true,
+ },
+ series: [
+ {
+ type: 'sankey',
+ // bottom: '10%',
+ emphasis: {
+ focus: 'adjacency',
+ },
+ data: lastMonthData(data).nodes,
+ links: lastMonthData(data).links,
+ // orient: "vertical",
+ // label: {
+ // position: "top"
+ // },
+ lineStyle: {
+ color: 'source',
+ curveness: 0.5,
+ },
+ },
+ ],
+ };
+
+ useEffect(() => {
+ const fetchData = async () => {
+ for (const repo of repoNames) {
+ try {
+ //getStars() to fetch repository data
+ const starsData = await getActivityDetails(repo);
+ // Update Data/
+ setData((prevData) => ({ ...prevData, [repo]: starsData }));
+ } catch (error) {
+ console.error(`Error fetching stars data for ${repo}:`, error);
+ // If the retrieval fails, set the data to an empty object
+ setData((prevData) => ({ ...prevData, [repo]: {} }));
+ }
+ }
+ };
+ fetchData();
+ }, []);
+
+ useEffect(() => {
+ let chartDOM = divEL.current;
+ const TH = 'light' ? LIGHT_THEME : DARK_THEME;
+
+ const instance = echarts.init(chartDOM as any);
+ // console.log('sankeydata', data);
+ // console.log('lastMonthData', lastMonthData(data));
+ instance.setOption(option);
+ instance.dispatchAction({
+ type: 'highlight',
+ // seriesIndex: Number(currentRepo),
+ // dataIndex: Number(currentRepo),
+ name: repoNames[Number(currentRepo)],
+ seriesName: repoNames[Number(currentRepo)],
+ });
+ return () => {
+ instance.dispose();
+ };
+ }, [data, currentRepo]);
+
+ return ;
+};
+
+interface RepoData {
+ [month: string]: [string, number][];
+}
+
+interface DataNode {
+ name: string;
+}
+
+interface DataLink {
+ source: string;
+ target: string;
+ value: number;
+}
+
+interface LastMonthData {
+ nodes: DataNode[];
+ links: DataLink[];
+}
+
+function lastMonthData(repo_data: {
+ [repoName: string]: RepoData;
+}): LastMonthData {
+ const data: LastMonthData = {
+ nodes: [],
+ links: [],
+ };
+ const userSet = new Set();
+ const newestMonth = getNewestMonth();
+ for (const [repoName, repoData] of Object.entries(repo_data)) {
+ const monthData = repoData[newestMonth];
+ if (monthData) {
+ monthData.forEach(([userName, value]) => {
+ const link: DataLink = {
+ source: repoName,
+ target: userName,
+ value: value,
+ };
+ userSet.add(userName);
+ data.links.push(link);
+ });
+ }
+ }
+ data.nodes = [
+ ...Object.keys(repo_data).map((repoName) => ({ name: repoName })),
+ ...Array.from(userSet).map((userName) => ({ name: userName })),
+ ];
+ // console.log(data.nodes);
+ // console.log(data.links);
+ return data;
+}
+
+export default SankeyChart;
diff --git a/src/pages/ContentScripts/features/repo-collection/charts/StackedBarChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/StackedBarChart.tsx
new file mode 100644
index 00000000..8d4ae6df
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/charts/StackedBarChart.tsx
@@ -0,0 +1,165 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import * as echarts from 'echarts';
+import generateDataByMonth from '../../../../../helpers/generate-data-by-month';
+import { getStars } from '../../../../../api/repo';
+
+interface RawRepoData {
+ [date: string]: number;
+}
+
+const LIGHT_THEME = {
+ FG_COLOR: '#24292f',
+ BG_COLOR: '#ffffff',
+ PALLET: ['#5470c6', '#91cc75'],
+};
+
+const DARK_THEME = {
+ FG_COLOR: '#c9d1d9',
+ BG_COLOR: '#0d1118',
+ PALLET: ['#58a6ff', '#3fb950'],
+};
+
+interface StackedBarChartProps {
+ theme: 'light' | 'dark';
+ height: number;
+ repoNames: string[];
+
+ currentRepo?: string;
+}
+
+const StackedBarChart = (props: StackedBarChartProps): JSX.Element => {
+ const { theme, height, repoNames, currentRepo } = props;
+ const divEL = useRef(null);
+ const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME;
+ const [data, setData] = useState<{ [repo: string]: RawRepoData }>({});
+
+ // Fetch data for the specified repositories
+ useEffect(() => {
+ const fetchData = async () => {
+ const repoData: { [repo: string]: RawRepoData } = {};
+ for (const repo of repoNames) {
+ try {
+ repoData[repo] = await getStars(repo);
+ } catch (error) {
+ console.error(`Error fetching stars data for ${repo}:`, error);
+ repoData[repo] = {};
+ }
+ }
+ setData(repoData);
+ };
+ fetchData();
+ }, [repoNames]);
+
+ // Preprocess the data
+ const preprocessedData = useMemo(() => addPreviousMonth(data), [data]);
+
+ const option: echarts.EChartsOption = {
+ tooltip: {
+ trigger: 'axis',
+ },
+ legend: {
+ type: 'scroll',
+ },
+ grid: {
+ left: '5%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true,
+ },
+ xAxis: {
+ type: 'time',
+ splitLine: {
+ show: false,
+ },
+ axisLabel: {
+ color: TH.FG_COLOR,
+ formatter: {
+ year: '{yearStyle|{yy}}',
+ month: '{MMM}',
+ },
+ rich: {
+ yearStyle: {
+ fontWeight: 'bold',
+ },
+ },
+ },
+ },
+ yAxis: {
+ type: 'value',
+ },
+ dataZoom: [
+ {
+ type: 'inside',
+ start: 0,
+ end: 100,
+ minValueSpan: 3600 * 24 * 1000 * 180,
+ },
+ ],
+ series: StarSeries(preprocessedData),
+ };
+ //console.log('StackedBarChartseries', StarSeries(preprocessedData));
+
+ useEffect(() => {
+ let chartDOM = divEL.current;
+
+ const instance = echarts.init(chartDOM as any);
+ instance.setOption(option);
+ instance.dispatchAction({
+ type: 'highlight',
+ seriesIndex: Number(currentRepo),
+ dataIndex: Number(currentRepo),
+ name: repoNames[Number(currentRepo)],
+ seriesName: repoNames[Number(currentRepo)],
+ });
+ return () => {
+ instance.dispose();
+ };
+ }, [option, currentRepo]);
+
+ return ;
+};
+
+// Preprocess data by adding previous month data
+const addPreviousMonth = (data: { [repo: string]: RawRepoData }) => {
+ const preprocessedData: { [repo: string]: [string, number][] } = {};
+ let maxLength = 0;
+
+ // Iterate through the data of each repository
+ for (const [repoName, repoData] of Object.entries(data)) {
+ const generatedData = generateDataByMonth(repoData);
+ preprocessedData[repoName] = generatedData;
+
+ // Update the maximum length
+ maxLength = Math.max(maxLength, generatedData.length);
+ }
+ // // Fill in arrays with months
+ for (const repoData of Object.values(preprocessedData)) {
+ while (repoData.length < maxLength) {
+ const [year, month] = repoData[0][0].split('-');
+ const previousMonth = new Date(parseInt(year), parseInt(month) - 1, 1)
+ .toISOString()
+ .slice(0, 7);
+ repoData.unshift([previousMonth, 0]);
+ }
+ }
+
+ return preprocessedData;
+};
+
+// Generate chart series for each repository
+const StarSeries = (data: {
+ [repo: string]: [string, number][];
+}): echarts.SeriesOption[] =>
+ Object.entries(data).map(([repoName, repoData]) => ({
+ name: repoName,
+ type: 'bar',
+ stack: 'total',
+ // emphasis: emphasisStyle,
+ data: repoData,
+ emphasis: {
+ focus: 'series',
+ },
+ triggerLineEvent: true,
+ }));
+
+export default StackedBarChart;
diff --git a/src/pages/ContentScripts/features/repo-collection/context/index.ts b/src/pages/ContentScripts/features/repo-collection/context/index.ts
new file mode 100644
index 00000000..3a3d0138
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/context/index.ts
@@ -0,0 +1,48 @@
+import { Repository } from './store';
+import { useStore } from './useStore';
+
+import { useState } from 'react';
+import constate from 'constate';
+
+const useRepoCollection = ({
+ currentRepositoryId,
+}: {
+ currentRepositoryId: Repository['id'];
+}) => {
+ const { allCollections, allRelations, updaters } = useStore();
+ // get all related collections for the current repository
+ const currentRepositoryRelations = allRelations.filter(
+ (r) => r.repositoryId === currentRepositoryId
+ );
+ const currentRepositoryCollections = allCollections.filter((c) =>
+ currentRepositoryRelations.some((r) => r.collectionId === c.id)
+ );
+
+ const [hideCollectionList, setHideCollectionList] = useState(false);
+ const [hideAddToCollections, setHideAddToCollections] = useState(true);
+
+ const [showCollectionModal, setShowCollectionModal] = useState(false);
+ const [selectedCollection, setSelectedCollection] = useState();
+
+ return {
+ currentRepositoryId,
+ currentRepositoryCollections,
+ allCollections,
+ allRelations,
+ updaters,
+
+ hideCollectionList,
+ setHideCollectionList,
+ hideAddToCollections,
+ setHideAddToCollections,
+
+ showCollectionModal,
+ setShowCollectionModal,
+
+ selectedCollection,
+ setSelectedCollection,
+ };
+};
+
+export const [RepoCollectionProvider, useRepoCollectionContext] =
+ constate(useRepoCollection);
diff --git a/src/pages/ContentScripts/features/repo-collection/context/store.ts b/src/pages/ContentScripts/features/repo-collection/context/store.ts
new file mode 100644
index 00000000..d7b25530
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/context/store.ts
@@ -0,0 +1,202 @@
+export interface Repository {
+ id: string;
+ /** e.g. microsoft/vscode */
+ fullName: string;
+}
+
+export interface Collection {
+ id: string;
+ name: string;
+}
+
+export interface Relation {
+ repositoryId: Repository['id'];
+ collectionId: Collection['id'];
+}
+
+const RELATIONS_STORE_KEY =
+ 'hypercrx:repo-collection:repository-collection-relations';
+const COLLECTIONS_STORE_KEY = 'hypercrx:repo-collection:collections';
+
+// TODO: delete other collections except for X-lab before the PR is merged
+const defaultCollections: Collection[] = [
+ {
+ id: 'X-lab',
+ name: 'X-lab',
+ },
+ {
+ id: 'Hypertrons',
+ name: 'Hypertrons',
+ },
+ {
+ id: 'Mulan',
+ name: 'Mulan',
+ },
+];
+const defaultRelations: Relation[] = [
+ {
+ repositoryId: 'X-lab2017/open-digger',
+ collectionId: 'X-lab',
+ },
+ {
+ repositoryId: 'hypertrons/hypertrons-crx',
+ collectionId: 'X-lab',
+ },
+ {
+ repositoryId: 'hypertrons/hypertrons-crx',
+ collectionId: 'Hypertrons',
+ },
+];
+
+/**
+ * Store for repo collection
+ */
+class RepoCollectionStore {
+ private static instance: RepoCollectionStore;
+ // a simple lock mechanism to prevent concurrent updates
+ private isUpdatingRelations: boolean = false;
+ private isUpdatingCollection: boolean = false;
+
+ public static getInstance(): RepoCollectionStore {
+ if (!RepoCollectionStore.instance) {
+ RepoCollectionStore.instance = new RepoCollectionStore();
+ }
+ return RepoCollectionStore.instance;
+ }
+
+ public async addCollection(collection: Collection): Promise {
+ if (this.isUpdatingCollection) {
+ // Another update is in progress, wait for it to finish
+ await this.waitForUpdateToFinish();
+ }
+
+ this.isUpdatingCollection = true;
+
+ try {
+ const collections = await this.getAllCollections();
+ collections.push(collection);
+ await chrome.storage.sync.set({
+ [COLLECTIONS_STORE_KEY]: collections,
+ });
+ } finally {
+ this.isUpdatingCollection = false;
+ }
+ }
+
+ public async removeCollection(collectionId: Collection['id']): Promise {
+ if (this.isUpdatingCollection) {
+ // Another update is in progress, wait for it to finish
+ await this.waitForUpdateToFinish();
+ }
+
+ this.isUpdatingCollection = true;
+
+ try {
+ const collections = await this.getAllCollections();
+ const index = collections.findIndex((c) => c.id === collectionId);
+ if (index === -1) {
+ return;
+ }
+ // Remove its relations first
+ const relations = await this.getAllRelations();
+ relations.forEach((r) => {
+ if (r.collectionId === collectionId) {
+ this.removeRelations([r]);
+ }
+ });
+ // Then remove the collection
+ collections.splice(index, 1);
+ await chrome.storage.sync.set({
+ [COLLECTIONS_STORE_KEY]: collections,
+ });
+ } finally {
+ this.isUpdatingCollection = false;
+ }
+ }
+
+ public async getAllCollections(): Promise {
+ const collections = await chrome.storage.sync.get({
+ [COLLECTIONS_STORE_KEY]: defaultCollections,
+ });
+ return collections[COLLECTIONS_STORE_KEY];
+ }
+
+ public async addRelations(relations: Relation[]): Promise {
+ if (this.isUpdatingRelations) {
+ // Another update is in progress, wait for it to finish
+ await this.waitForUpdateToFinish();
+ }
+
+ this.isUpdatingRelations = true;
+
+ try {
+ const allRelations = await this.getAllRelations();
+ // Remove duplicate relations
+ relations = relations.filter((r) => {
+ return (
+ allRelations.findIndex(
+ (rr) =>
+ rr.repositoryId === r.repositoryId &&
+ rr.collectionId === r.collectionId
+ ) === -1
+ );
+ });
+ allRelations.push(...relations);
+ await chrome.storage.sync.set({
+ [RELATIONS_STORE_KEY]: allRelations,
+ });
+ } finally {
+ this.isUpdatingRelations = false;
+ }
+ }
+
+ public async removeRelations(relations: Relation[]): Promise {
+ if (this.isUpdatingRelations) {
+ // Another update is in progress, wait for it to finish
+ await this.waitForUpdateToFinish();
+ }
+
+ this.isUpdatingRelations = true;
+
+ try {
+ const allRelations = await this.getAllRelations();
+ relations.forEach((r) => {
+ const index = allRelations.findIndex(
+ (rr) =>
+ rr.repositoryId === r.repositoryId &&
+ rr.collectionId === r.collectionId
+ );
+ if (index !== -1) {
+ allRelations.splice(index, 1);
+ }
+ });
+ await chrome.storage.sync.set({
+ [RELATIONS_STORE_KEY]: allRelations,
+ });
+ } finally {
+ this.isUpdatingRelations = false;
+ }
+ }
+
+ public async getAllRelations(): Promise {
+ const relations = await chrome.storage.sync.get({
+ [RELATIONS_STORE_KEY]: defaultRelations,
+ });
+ return relations[RELATIONS_STORE_KEY];
+ }
+
+ private async waitForUpdateToFinish(): Promise {
+ return new Promise((resolve) => {
+ const check = () => {
+ if (!this.isUpdatingRelations) {
+ resolve();
+ } else {
+ setTimeout(check, 10); // Check again after a short delay
+ }
+ };
+ check();
+ });
+ }
+}
+
+export const repoCollectionStore = RepoCollectionStore.getInstance();
diff --git a/src/pages/ContentScripts/features/repo-collection/context/useStore.ts b/src/pages/ContentScripts/features/repo-collection/context/useStore.ts
new file mode 100644
index 00000000..ac278acc
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/context/useStore.ts
@@ -0,0 +1,56 @@
+import { repoCollectionStore, Collection, Relation } from './store';
+
+import { useState, useEffect } from 'react';
+
+export const useStore = () => {
+ const [allCollections, setAllCollections] = useState([]);
+ const [allRelations, setAllRelations] = useState([]);
+
+ const fetchAllCollections = async () => {
+ const collections = await repoCollectionStore.getAllCollections();
+ setAllCollections(collections);
+ };
+
+ const fetchAllRelations = async () => {
+ const relations = await repoCollectionStore.getAllRelations();
+ setAllRelations(relations);
+ };
+
+ const addRelations = async (relations: Relation[]) => {
+ await repoCollectionStore.addRelations(relations);
+ fetchAllRelations();
+ };
+
+ const removeRelations = async (relations: Relation[]) => {
+ await repoCollectionStore.removeRelations(relations);
+ fetchAllRelations();
+ };
+
+ const addCollection = async (collection: Collection) => {
+ await repoCollectionStore.addCollection(collection);
+ fetchAllCollections();
+ };
+
+ const removeCollection = async (collectionId: Collection['id']) => {
+ await repoCollectionStore.removeCollection(collectionId);
+ fetchAllCollections();
+ };
+
+ const updaters = {
+ addRelations,
+ removeRelations,
+ addCollection,
+ removeCollection,
+ };
+
+ useEffect(() => {
+ fetchAllCollections();
+ fetchAllRelations();
+ }, []);
+
+ return {
+ allCollections,
+ allRelations,
+ updaters,
+ };
+};
diff --git a/src/pages/ContentScripts/features/repo-collection/index.tsx b/src/pages/ContentScripts/features/repo-collection/index.tsx
new file mode 100644
index 00000000..b072699a
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/index.tsx
@@ -0,0 +1,39 @@
+import features from '../../../../feature-manager';
+import { isPublicRepo, getRepoName } from '../../../../helpers/get-repo-info';
+import View from './view';
+
+import React from 'react';
+import { render, Container } from 'react-dom';
+import $ from 'jquery';
+import elementReady from 'element-ready';
+
+const featureId = features.getFeatureID(import.meta.url);
+let repoName: string;
+
+const renderTo = (container: Container) => {
+ render(, container);
+};
+
+const init = async (): Promise => {
+ repoName = getRepoName();
+
+ const container = document.createElement('li');
+ container.id = featureId;
+ renderTo(container);
+ await elementReady('#repository-details-container');
+ $('#repository-details-container>ul').prepend(container);
+};
+
+const restore = async () => {
+ if (repoName !== getRepoName()) {
+ repoName = getRepoName();
+ }
+ renderTo($(`#${featureId}`)[0]);
+};
+
+features.add(featureId, {
+ include: [isPublicRepo],
+ awaitDomReady: true,
+ init,
+ restore,
+});
diff --git a/src/pages/ContentScripts/features/repo-collection/view.tsx b/src/pages/ContentScripts/features/repo-collection/view.tsx
new file mode 100644
index 00000000..a57e1281
--- /dev/null
+++ b/src/pages/ContentScripts/features/repo-collection/view.tsx
@@ -0,0 +1,20 @@
+import { CollectionButton } from './CollectionButton';
+import { CollectionModal } from './CollectionModal';
+import { RepoCollectionProvider } from './context';
+
+import React from 'react';
+
+interface Props {
+ repoName: string;
+}
+
+const View = ({ repoName }: Props) => {
+ return (
+
+
+
+
+ );
+};
+
+export default View;
diff --git a/src/pages/ContentScripts/index.ts b/src/pages/ContentScripts/index.ts
index bab274a3..9288402a 100644
--- a/src/pages/ContentScripts/index.ts
+++ b/src/pages/ContentScripts/index.ts
@@ -13,3 +13,4 @@ import './features/repo-networks';
import './features/developer-networks';
import './features/oss-gpt';
import './features/repo-activity-racing-bar';
+import './features/repo-collection';
diff --git a/src/pages/Popup/Popup.tsx b/src/pages/Popup/Popup.tsx
index 8f39b0d6..d30998ff 100644
--- a/src/pages/Popup/Popup.tsx
+++ b/src/pages/Popup/Popup.tsx
@@ -1,15 +1,34 @@
-import React from 'react';
+import { number } from 'echarts';
+import React, { useState } from 'react';
+import { Space, Button } from 'antd';
export default function Popup() {
+ // refer: https://developer.chrome.com/docs/extensions/mv3/messaging/#simple
+ const openFeatureInContentScript = async () => {
+ const [tab] = await chrome.tabs.query({
+ active: true,
+ lastFocusedWindow: true,
+ });
+ const response = await chrome.tabs.sendMessage(tab.id!, {
+ greeting: 'demo',
+ });
+ };
return (
-
-
+
+
+
+
+
);
}
diff --git a/yarn.lock b/yarn.lock
index 2443a626..7eb9c959 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -330,7 +330,7 @@
"@babel/plugin-proposal-class-properties@^7.16.7":
version "7.18.6"
- resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3"
integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==
dependencies:
"@babel/helper-create-class-features-plugin" "^7.18.6"
@@ -1411,7 +1411,7 @@
"@nodelib/fs.walk@^1.2.3":
version "1.2.8"
- resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+ resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
dependencies:
"@nodelib/fs.scandir" "2.1.5"
@@ -2730,7 +2730,7 @@ commander@^2.20.0:
commander@^7.0.0:
version "7.2.0"
- resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
commander@^8.3.0:
@@ -2788,6 +2788,11 @@ connect-history-api-fallback@^2.0.0:
resolved "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8"
integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==
+constate@^3.3.2:
+ version "3.3.2"
+ resolved "https://registry.npmjs.org/constate/-/constate-3.3.2.tgz#a6cd2f3c203da2cb863f47d22a330b833936c449"
+ integrity sha512-ZnEWiwU6QUTil41D5EGpA7pbqAPGvnR9kBjko8DzVIxpC60mdNKrP568tT5WLJPAxAOtJqJw60+h79ot/Uz1+Q==
+
content-disposition@0.5.4:
version "0.5.4"
resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
@@ -2896,7 +2901,7 @@ crx@^5.0.1:
css-loader@^6.6.0:
version "6.8.1"
- resolved "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz#0f8f52699f60f5e679eab4ec0fcd68b8e8a50a88"
+ resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.8.1.tgz#0f8f52699f60f5e679eab4ec0fcd68b8e8a50a88"
integrity sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==
dependencies:
icss-utils "^5.1.0"
@@ -3529,7 +3534,7 @@ fs.realpath@^1.0.0:
fsevents@~2.3.2:
version "2.3.2"
- resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
function-bind@^1.1.1:
@@ -3632,7 +3637,7 @@ globals@^11.1.0:
globby@^11.0.1:
version "11.1.0"
- resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
+ resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
dependencies:
array-union "^2.1.0"
@@ -4108,7 +4113,7 @@ jest-get-type@^27.5.1:
jest-matcher-utils@^27.0.0:
version "27.5.1"
- resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab"
+ resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab"
integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==
dependencies:
chalk "^4.0.0"
@@ -4488,7 +4493,7 @@ min-indent@^1.0.1:
mini-css-extract-plugin@^2.7.2:
version "2.7.6"
- resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz#282a3d38863fddcd2e0c220aaed5b90bc156564d"
+ resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz#282a3d38863fddcd2e0c220aaed5b90bc156564d"
integrity sha512-Qk7HcgaPkGG6eD77mLvZS1nmxlao3j+9PkrT9Uc7HAE1id3F41+DdBRYRYkbyfNRGzm8/YWtzhw7nVPmwhqTQw==
dependencies:
schema-utils "^4.0.0"
@@ -4791,7 +4796,7 @@ parse-data-uri@^0.2.0:
parse5@^6.0.1:
version "6.0.1"
- resolved "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
+ resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
parse5@^7.0.0:
@@ -4874,7 +4879,7 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
pify@^2.0.0:
version "2.3.0"
- resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
pify@^4.0.1:
@@ -5002,7 +5007,7 @@ process@^0.11.10:
prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
- resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
+ resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
dependencies:
loose-envify "^1.4.0"
@@ -5788,7 +5793,7 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0,
sass-loader@^12.4.0:
version "12.6.0"
- resolved "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz#5148362c8e2cdd4b950f3c63ac5d16dbfed37bcb"
+ resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-12.6.0.tgz#5148362c8e2cdd4b950f3c63ac5d16dbfed37bcb"
integrity sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==
dependencies:
klona "^2.0.4"
@@ -6000,7 +6005,7 @@ sockjs@^0.3.24:
source-map-loader@^3.0.1:
version "3.0.2"
- resolved "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.2.tgz#af23192f9b344daa729f6772933194cc5fa54fee"
+ resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-3.0.2.tgz#af23192f9b344daa729f6772933194cc5fa54fee"
integrity sha512-BokxPoLjyl3iOrgkWaakaxqnelAJSS+0V+De0kKIq6lyWrXuiPgYTGp6z3iHmqljKAaLXwZa+ctD8GccRJeVvg==
dependencies:
abab "^2.0.5"
@@ -6170,7 +6175,7 @@ tar-stream@^2.1.0:
terser-webpack-plugin@^5.3.1, terser-webpack-plugin@^5.3.7:
version "5.3.9"
- resolved "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz#832536999c51b46d468067f9e37662a3b96adfe1"
+ resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz#832536999c51b46d468067f9e37662a3b96adfe1"
integrity sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==
dependencies:
"@jridgewell/trace-mapping" "^0.3.17"