Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"react-dom": "^18.2.0",
"react-icons": "^4.3.1",
"semver": "^7.5.4",
"swr": "^2.2.0"
"swr": "^2.2.0",
"zustand": "^5.0.9"
},
"repository": "https://github.com/cnpm/cnpmweb.git",
"devDependencies": {
Expand Down
22 changes: 18 additions & 4 deletions src/components/CodeViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import loader from '@monaco-editor/loader';
import { File, useFileContent } from '@/hooks/useFile';
import useHighlightHash, { parseHash } from '@/hooks/useHighlightHash';
import { useThemeMode } from 'antd-style';
import { useRef } from 'react';
import { useEffect, useRef } from 'react';
import { REGISTRY } from '@/config';
import { FileActions } from './FileActions';

const LinkField = ['devDependencies', 'dependencies', 'peerDependencies', 'optionalDependencies', 'bundleDependencies'];

Expand All @@ -25,11 +26,11 @@ function highlightEditor(editor: any) {
function registerLinkProvider(monaco: any) {
monaco.languages.registerLinkProvider('json', {
provideLinks: (model: any) => {
const links:any= [];
const links: any = [];
const lines = model.getLinesContent();
let startCatch = false;
lines.forEach((line: string, lineIndex: number) => {
if (LinkField.some( _ => line === ` "${_}": {`)) {
if (LinkField.some(_ => line === ` "${_}": {`)) {
startCatch = true;
return;
}
Expand Down Expand Up @@ -69,7 +70,7 @@ export const CodeViewer = ({
const editorRef = useRef<any>(null);
const { themeMode: theme } = useThemeMode();

const { data: code } = useFileContent({ fullname: pkgName, spec }, selectedFile?.path || '');
const [{ data: code }, { fileUri }] = useFileContent({ fullname: pkgName, spec }, selectedFile?.path || '');

let language = selectedFile?.path.split('.').pop();
if (language === 'js' || language === 'jsx' || language === 'map') language = 'javascript';
Expand All @@ -86,6 +87,19 @@ export const CodeViewer = ({
}
};

// inject file info to FileActions store
useEffect(() => {
FileActions.setStore({
rawUrl: fileUri,
fileContent: code
});

return function cleanup() {
FileActions.restoreStore();
}
}, [fileUri, code])
Comment on lines +91 to +100
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

这个 useEffect 在组件挂载和更新时向全局 store 注入了文件信息,但缺少了在组件卸载时进行清理的逻辑。这可能会导致在用户离开文件预览页面后,全局 store 中仍然残留着旧的文件数据,造成状态污染。建议添加一个 effect 的清理函数,在组件卸载时调用 FileActions.restoreStore() 来重置状态。

Suggested change
useEffect(() => {
FileActions.setStore({
rawUrl: fileUri,
fileContent: code
})
}, [fileUri, code])
useEffect(() => {
FileActions.setStore({
rawUrl: fileUri,
fileContent: code
})
return () => {
FileActions.restoreStore();
}
}, [fileUri, code])



Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra blank line should be removed for consistency with the rest of the codebase formatting.

Suggested change

Copilot uses AI. Check for mistakes.
if (!selectedFile) return <></>;

return (
Expand Down
34 changes: 19 additions & 15 deletions src/components/CustomTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';
import { Tabs } from 'antd';
import { Flex, Space, Tabs } from 'antd';
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import Space.

Suggested change
import { Flex, Space, Tabs } from 'antd';
import { Flex, Tabs } from 'antd';

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if Space from antd is used in CustomTabs.tsx or NPMVersionSelect.tsx

echo "=== Checking Space usage in CustomTabs.tsx ==="
rg -n "Space" src/components/CustomTabs.tsx

echo -e "\n=== Checking Space usage in NPMVersionSelect.tsx ==="
rg -n "Space" src/components/NPMVersionSelect.tsx

Repository: cnpm/cnpmweb

Length of output: 290


🏁 Script executed:

#!/bin/bash
# View CustomTabs.tsx to see if Space or NPMVersionSelect is used in the component

echo "=== CustomTabs.tsx full content ==="
cat -n src/components/CustomTabs.tsx

echo -e "\n=== Check for JSX usage of Space or NPMVersionSelect ==="
rg -n "<Space|<NPMVersionSelect" src/components/CustomTabs.tsx

Repository: cnpm/cnpmweb

Length of output: 2852


Remove unused Space import from line 2.

The Space component from 'antd' is imported in CustomTabs.tsx but never used in the component. While NPMVersionSelect.tsx does use Space internally, each component should manage its own imports—parent components don't need to import what child components use.

import { Flex, Space, Tabs } from 'antd';

Change to:

import { Flex, Tabs } from 'antd';
🤖 Prompt for AI Agents
In src/components/CustomTabs.tsx around line 2, the import statement includes an
unused Space import from 'antd'; remove Space from the import list so it only
imports the symbols actually used (e.g., Flex and Tabs) to eliminate the unused
import and satisfy linting.

import Link from 'next/link';
import NPMVersionSelect from './NPMVersionSelect';
import { PackageManifest } from '@/hooks/useManifest';
import { useRouter } from 'next/router';
import { FileActions } from './FileActions';

const presetTabs = [
{
Expand Down Expand Up @@ -44,20 +45,23 @@ export default function CustomTabs({
activeKey={activateKey}
type={'line'}
tabBarExtraContent={
<NPMVersionSelect
versions={Object.keys(pkg?.versions || {})}
tags={pkg?.['dist-tags']}
targetVersion={targetVersion}
setVersionStr={(v) => {
if (v === pkg?.['dist-tags']?.latest) {
push(`/package/${pkg.name}/${activateKey}`, undefined, { shallow: true });
} else {
push(`/package/${pkg.name}/${activateKey}?version=${v}`, undefined, {
shallow: true,
});
}
}}
/>
<Flex gap="small" style={{ marginInlineEnd: 16 }}>
{activateKey === 'files' && <FileActions />}
<NPMVersionSelect
versions={Object.keys(pkg?.versions || {})}
tags={pkg?.['dist-tags']}
targetVersion={targetVersion}
setVersionStr={(v) => {
if (v === pkg?.['dist-tags']?.latest) {
push(`/package/${pkg.name}/${activateKey}`, undefined, { shallow: true });
} else {
push(`/package/${pkg.name}/${activateKey}?version=${v}`, undefined, {
shallow: true,
});
}
}}
/>
</Flex>
}
items={presetTabs.map((tab) => {
return {
Expand Down
80 changes: 80 additions & 0 deletions src/components/FileActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { CopyOutlined, DownloadOutlined } from "@ant-design/icons";
import { Button, Space, Tooltip, message } from "antd";
import { isEmpty } from "lodash";
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { useShallow } from 'zustand/react/shallow'

// #region Store
interface IState {
rawUrl: string;
fileContent: string;
}

const initialState: IState = {
rawUrl: '',
fileContent: '',
};

const setStore = (state: Partial<IState>) =>
useFileActionsStore.setState(state, false, 'FileActions/setStore')

const restoreStore = () =>
useFileActionsStore.setState(initialState, false, 'FileActions/restoreStore')

const useFileActionsStore = create<IState>()(
devtools(() => initialState, { name: 'FileActions' })
);
// #endregion Store

// ===========================================================================

// #region Component
function InnerFileActions() {
const state = useFileActionsStore(useShallow(s => s));

function handleCopyRaw() {
if (!state.fileContent) return;
navigator.clipboard.writeText(state.fileContent);
message.success('Copied to clipboard');
Comment on lines +36 to +39
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clipboard write operation using navigator.clipboard.writeText() may fail if the user hasn't granted clipboard permissions or if the page is not in a secure context. Add error handling to catch potential failures and inform the user appropriately.

Suggested change
function handleCopyRaw() {
if (!state.fileContent) return;
navigator.clipboard.writeText(state.fileContent);
message.success('Copied to clipboard');
async function handleCopyRaw() {
if (!state.fileContent) return;
if (!navigator || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
message.error('Clipboard is not available in this browser or context.');
return;
}
try {
await navigator.clipboard.writeText(state.fileContent);
message.success('Copied to clipboard');
} catch (error) {
// Clipboard write can fail if permissions are denied or in insecure contexts
// eslint-disable-next-line no-console
console.error('Failed to write to clipboard:', error);
message.error('Failed to copy to clipboard. Please check your browser permissions or try again.');
}

Copilot uses AI. Check for mistakes.
}

function handleDownloadRaw() {
if (!state.fileContent) return;
const blob = new Blob([state.fileContent], { type: 'text/plain' });
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MIME type is hardcoded as 'text/plain' for all files. This may cause issues for binary files or files with specific content types (e.g., JSON, CSS, JavaScript). Consider detecting the appropriate MIME type based on the file extension or using a more generic type like 'application/octet-stream' for downloads.

Copilot uses AI. Check for mistakes.
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = state.rawUrl.split('/').pop() || 'file';
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filename extraction logic using split('/').pop() will fail for URLs that don't end with a filename (e.g., if the URL ends with a slash). Additionally, if the path contains query parameters or fragments, they will be included in the filename. Consider extracting the filename from the full path including handling edge cases like trailing slashes and URL parameters.

Suggested change
link.download = state.rawUrl.split('/').pop() || 'file';
let filename = 'file';
try {
const parsedUrl = new URL(state.rawUrl, window.location.href);
const segments = parsedUrl.pathname.split('/').filter(Boolean);
const lastSegment = segments[segments.length - 1];
if (lastSegment) {
filename = decodeURIComponent(lastSegment);
}
} catch {
// If URL parsing fails, keep the default filename
}
link.download = filename;

Copilot uses AI. Check for mistakes.
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}

function handleViewRaw() {
if (!state.rawUrl) return;
window.open(state.rawUrl, '_blank', 'noopener,noreferrer');
}

// ========== render =========
if (Object.values(state).some(isEmpty)) return null
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

当前的渲染条件 Object.values(state).some(isEmpty) 存在一个问题:lodashisEmpty('') 会返回 true。这意味着当一个文件内容为空字符串时,fileContent'',此条件会判断为真,导致操作按钮组不被渲染。用户应该能够对空文件进行“查看原文”或“下载”等操作。建议修改此条件,仅在数据真正加载中(例如 fileContentundefined)时才隐藏按钮。

Suggested change
if (Object.values(state).some(isEmpty)) return null
if (!state.rawUrl || state.fileContent === undefined) return null

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我知道,故意的,别管

Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The visibility check using Object.values(state).some(isEmpty) is fragile and could lead to unexpected behavior. If any state value is legitimately an empty string (like an empty file), the component would hide. Consider explicitly checking for null/undefined states or using a more specific condition like !state.rawUrl || state.fileContent === undefined.

Copilot uses AI. Check for mistakes.

return (
<Space.Compact block>
<Button size="small" onClick={handleViewRaw}>Raw</Button>
<Tooltip title="Copy raw file">
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyRaw} />
</Tooltip>
<Tooltip title="Download raw file">
<Button size="small" icon={<DownloadOutlined />} onClick={handleDownloadRaw} />
</Tooltip>
</Space.Compact>
)
}

export const FileActions = Object.assign(InnerFileActions, {
setStore,
restoreStore,
});
// #endregion Component
12 changes: 7 additions & 5 deletions src/components/NPMVersionSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,11 @@ export default function NPMVersionSelect({
return null;
}

// 版本选择器
return !isEmpty(targetOptions) ? (
<Space style={{ paddingRight: 32 }}>
// ========== render ==========
if (isEmpty(targetOptions)) return null
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing semicolon at the end of the return statement. While JavaScript has automatic semicolon insertion, it's best practice to maintain consistency with the rest of the codebase which uses semicolons.

Suggested change
if (isEmpty(targetOptions)) return null
if (isEmpty(targetOptions)) return null;

Copilot uses AI. Check for mistakes.

return (
<>
<Cascader
size="small"
options={targetOptions}
Expand All @@ -164,6 +166,6 @@ export default function NPMVersionSelect({
}}
showSearch
/>
</Space>
) : null;
</>
)
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing semicolon at the end of the return statement. For consistency with the rest of the codebase, add a semicolon.

Copilot uses AI. Check for mistakes.
}
12 changes: 7 additions & 5 deletions src/hooks/useFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ export const useDirs = (info: PkgInfo, path = '', ignore = false) => {
};

export const useFileContent = (info: PkgInfo, path: string) => {
return useSwr(`file: ${info.fullname}_${info.spec || 'latest'}_${path}`, async () => {
return fetch(`${REGISTRY}/${info.fullname}/${info.spec}/files${path}`).then((res) =>
res.text(),
);
});
const swrKey = `file: ${info.fullname}_${info.spec || 'latest'}_${path}`;
const fileUri = `${REGISTRY}/${info.fullname}/${info.spec || 'latest'}/files${path}`;

return [
useSwr(swrKey, () => fetch(fileUri).then((res) => res.text())),
{ fileUri }
] as const;
};