diff --git a/webui/src/locales/en-US/workspace.json b/webui/src/locales/en-US/workspace.json index 599d18161..8fb2754b2 100644 --- a/webui/src/locales/en-US/workspace.json +++ b/webui/src/locales/en-US/workspace.json @@ -26,7 +26,14 @@ "close": "Close", "uploading": "Uploading...", "binaryPreview": "Binary files cannot be previewed", + "unsupportedPreview": "This file type cannot be previewed. Please download to view.", "downloadFile": "Download File", + "readFailed": "Failed to load file content", + "retry": "Retry", + "previewTabs": { + "review": "Review", + "raw": "Raw" + }, "toast": { "loadDirFailed": "Failed to load directory", "readFileFailed": "Failed to read file", diff --git a/webui/src/locales/zh-CN/workspace.json b/webui/src/locales/zh-CN/workspace.json index 8066f22d7..9c3ca3cee 100644 --- a/webui/src/locales/zh-CN/workspace.json +++ b/webui/src/locales/zh-CN/workspace.json @@ -26,7 +26,14 @@ "close": "关闭", "uploading": "上传中...", "binaryPreview": "二进制文件无法预览", + "unsupportedPreview": "当前文件类型暂不支持预览,请下载查看", "downloadFile": "下载文件", + "readFailed": "文件内容加载失败", + "retry": "重试", + "previewTabs": { + "review": "预览", + "raw": "源码" + }, "toast": { "loadDirFailed": "加载目录失败", "readFileFailed": "读取文件失败", diff --git a/webui/src/pages/Workspace/filePreview.test.ts b/webui/src/pages/Workspace/filePreview.test.ts new file mode 100644 index 000000000..b4831313c --- /dev/null +++ b/webui/src/pages/Workspace/filePreview.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + formatReviewContent, isJsonNode, isMarkdownNode, isRichPreviewNode, isTextPreviewNode, +} from './filePreview'; +import type { WorkspaceNode } from '@/api/workspace'; + +function fileNode(name: string): WorkspaceNode { + return { name, path: name, type: 'file', is_text_file: true }; +} + +describe('isMarkdownNode', () => { + it('returns true for .md and .markdown', () => { + expect(isMarkdownNode(fileNode('report.md'))).toBe(true); + expect(isMarkdownNode(fileNode('notes.MD'))).toBe(true); + expect(isMarkdownNode(fileNode('readme.markdown'))).toBe(true); + }); + + it('returns false for other file types and directories', () => { + expect(isMarkdownNode(fileNode('data.json'))).toBe(false); + expect(isMarkdownNode(fileNode('doc.pdf'))).toBe(false); + expect(isMarkdownNode(fileNode('sheet.xlsx'))).toBe(false); + expect(isMarkdownNode({ name: 'outputs', path: 'outputs', type: 'directory' })).toBe(false); + }); +}); + +describe('json and rich preview helpers', () => { + it('recognizes json files as rich previewable', () => { + const json = fileNode('result.json'); + expect(isJsonNode(json)).toBe(true); + expect(isRichPreviewNode(json)).toBe(true); + expect(isRichPreviewNode(fileNode('report.md'))).toBe(true); + expect(isRichPreviewNode(fileNode('doc.pdf'))).toBe(false); + }); + + it('returns content unchanged for review rendering', () => { + const node = fileNode('result.json'); + const content = '{\n "a": 1\n}'; + expect(formatReviewContent(node, content)).toBe(content); + }); +}); + +describe('isTextPreviewNode', () => { + it('uses backend text-file metadata to decide whether content can be read', () => { + expect(isTextPreviewNode(fileNode('script.py'))).toBe(true); + expect(isTextPreviewNode({ ...fileNode('image.png'), is_text_file: false })).toBe(false); + expect(isTextPreviewNode({ name: 'outputs', path: 'outputs', type: 'directory' })).toBe(false); + }); +}); diff --git a/webui/src/pages/Workspace/filePreview.ts b/webui/src/pages/Workspace/filePreview.ts new file mode 100644 index 000000000..cad50ca63 --- /dev/null +++ b/webui/src/pages/Workspace/filePreview.ts @@ -0,0 +1,30 @@ +import type { WorkspaceNode } from '@/api/workspace'; + +/** True when the node is a Markdown file eligible for rendered preview. */ +export function isMarkdownNode(node: WorkspaceNode): boolean { + if (node.type !== 'file') return false; + const ext = node.name.split('.').pop()?.toLowerCase() ?? ''; + return ext === 'md' || ext === 'markdown'; +} + +/** True when the node is a JSON file eligible for code-block preview. */ +export function isJsonNode(node: WorkspaceNode): boolean { + if (node.type !== 'file') return false; + const ext = node.name.split('.').pop()?.toLowerCase() ?? ''; + return ext === 'json'; +} + +/** Rich preview currently supports Markdown and JSON. */ +export function isRichPreviewNode(node: WorkspaceNode): boolean { + return isMarkdownNode(node) || isJsonNode(node); +} + +/** True when the node can be read and edited as text. */ +export function isTextPreviewNode(node: WorkspaceNode): boolean { + return node.type === 'file' && Boolean(node.is_text_file); +} + +/** Build content for Review tab rendering. */ +export function formatReviewContent(node: WorkspaceNode, content: string): string { + return content; +} diff --git a/webui/src/pages/Workspace/index.test.tsx b/webui/src/pages/Workspace/index.test.tsx new file mode 100644 index 000000000..0e6fd0470 --- /dev/null +++ b/webui/src/pages/Workspace/index.test.tsx @@ -0,0 +1,237 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import WorkspacePage from './index'; + +const { listMock, readFileMock, tMock, toastMock } = vi.hoisted(() => ({ + listMock: vi.fn(), + readFileMock: vi.fn(), + tMock: vi.fn((key: string) => key), + toastMock: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: tMock }), +})); + +vi.mock('@/components/common/Toast', () => ({ + useToast: () => toastMock, +})); + +vi.mock('@/components/common/ConfirmDialog', () => ({ + useConfirm: () => vi.fn().mockResolvedValue(false), +})); + +vi.mock('@/api/workspace', async () => { + const actual = await vi.importActual('@/api/workspace'); + return { + ...actual, + workspaceAPI: { + ...actual.workspaceAPI, + list: listMock, + readFile: readFileMock, + createDir: vi.fn(), + deleteFile: vi.fn(), + deleteDir: vi.fn(), + upload: vi.fn(), + writeFile: vi.fn(), + }, + }; +}); + +vi.mock('@/components/common/PageHeader', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})); + +vi.mock('@/components/common/LoadingSpinner', () => ({ + default: () =>
loading
, +})); + +const mdNode = { + name: 'report.md', + path: 'outputs/report.md', + type: 'file' as const, + size: 1024, + modified_at: 1_700_000_000, + is_text_file: true, +}; + +const jsonNode = { + name: 'result.json', + path: 'outputs/result.json', + type: 'file' as const, + size: 512, + modified_at: 1_700_000_000, + is_text_file: true, +}; + +const textNode = { + name: 'notes.txt', + path: 'outputs/notes.txt', + type: 'file' as const, + size: 128, + modified_at: 1_700_000_000, + is_text_file: true, +}; + +const pdfNode = { + name: 'report.pdf', + path: 'outputs/report.pdf', + type: 'file' as const, + size: 2048, + modified_at: 1_700_000_000, + is_text_file: false, +}; + +describe('WorkspacePage files preview', () => { + beforeEach(() => { + vi.clearAllMocks(); + listMock.mockResolvedValue({ data: [mdNode, jsonNode, textNode, pdfNode] }); + readFileMock.mockResolvedValue({ data: { path: mdNode.path, content: '# Hello Report\n\nBody text.' } }); + }); + + function clickFileRow(filename: string) { + const cell = screen.getByText(filename, { selector: 'span' }); + const row = cell.closest('tr'); + if (!row) throw new Error(`row not found for ${filename}`); + return userEvent.setup().click(row); + } + + it('opens markdown drawer with Review tab and renders heading by default', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('report.md', { selector: 'span' })).toBeInTheDocument(); + }); + + await clickFileRow('report.md'); + + await waitFor(() => { + expect(readFileMock).toHaveBeenCalledWith(mdNode.path); + }); + + await waitFor(() => { + expect(screen.getByTestId('workspace-preview-drawer')).toBeInTheDocument(); + }); + + expect(screen.getByRole('heading', { level: 1, name: 'Hello Report' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'files.previewTabs.review' })).toHaveClass('border-slate-700'); + }); + + it('switches to Raw tab and shows source markdown', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getByText('report.md', { selector: 'span' })).toBeInTheDocument()); + await clickFileRow('report.md'); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Hello Report' })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'files.previewTabs.raw' })); + + expect(screen.getByText((content) => content.includes('# Hello Report'))).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Hello Report' })).not.toBeInTheDocument(); + }); + + it('opens plain text files in the drawer as raw source', async () => { + render(); + + await waitFor(() => expect(screen.getByText('notes.txt', { selector: 'span' })).toBeInTheDocument()); + readFileMock.mockResolvedValueOnce({ data: { path: textNode.path, content: 'line 1\nline 2' } }); + + await clickFileRow('notes.txt'); + + await waitFor(() => { + expect(readFileMock).toHaveBeenCalledWith(textNode.path); + }); + expect(screen.getByTestId('workspace-preview-drawer')).toBeInTheDocument(); + expect(screen.getByText((content) => content.includes('line 1'))).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'files.previewTabs.review' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'files.previewTabs.raw' })).not.toBeInTheDocument(); + }); + + it('does not read file content for non-text files', async () => { + render(); + + await waitFor(() => expect(screen.getByText('report.pdf', { selector: 'span' })).toBeInTheDocument()); + await clickFileRow('report.pdf'); + + await waitFor(() => { + expect(screen.getByTestId('workspace-download-panel')).toBeInTheDocument(); + }); + + expect(readFileMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('workspace-preview-drawer')).not.toBeInTheDocument(); + }); + + it('opens drawer for json and renders json code block in review tab', async () => { + render(); + + await waitFor(() => expect(screen.getByText('result.json', { selector: 'span' })).toBeInTheDocument()); + readFileMock.mockResolvedValueOnce({ data: { path: jsonNode.path, content: '{\n \"status\": \"ok\"\n}' } }); + + await clickFileRow('result.json'); + + await waitFor(() => { + expect(readFileMock).toHaveBeenCalledWith(jsonNode.path); + }); + + await waitFor(() => { + expect(screen.getByTestId('workspace-preview-drawer')).toBeInTheDocument(); + }); + + const codeEl = document.querySelector('pre code'); + expect(codeEl).toBeTruthy(); + expect(codeEl?.textContent).toContain('"status": "ok"'); + expect(screen.queryByRole('button', { name: 'files.previewTabs.review' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'files.previewTabs.raw' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('workspace-download-panel')).not.toBeInTheDocument(); + }); + + it('shows an error state when text file content fails to load', async () => { + render(); + + await waitFor(() => expect(screen.getByText('notes.txt', { selector: 'span' })).toBeInTheDocument()); + readFileMock.mockRejectedValueOnce(new Error('network down')); + + await clickFileRow('notes.txt'); + + await waitFor(() => { + expect(screen.getByText('files.readFailed')).toBeInTheDocument(); + }); + expect(screen.getByText('network down')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'files.retry' })).toBeInTheDocument(); + }); + + it('ignores stale file content responses after switching files', async () => { + let resolveMarkdown!: (value: { data: { path: string; content: string } }) => void; + let resolveText!: (value: { data: { path: string; content: string } }) => void; + + readFileMock.mockImplementation((path: string) => new Promise<{ data: { path: string; content: string } }>((resolve) => { + if (path === mdNode.path) { + resolveMarkdown = resolve; + return; + } + resolveText = resolve; + })); + + render(); + + await waitFor(() => expect(screen.getByText('report.md', { selector: 'span' })).toBeInTheDocument()); + await clickFileRow('report.md'); + await waitFor(() => expect(readFileMock).toHaveBeenCalledWith(mdNode.path)); + + await clickFileRow('notes.txt'); + await waitFor(() => expect(readFileMock).toHaveBeenCalledWith(textNode.path)); + + resolveText({ data: { path: textNode.path, content: 'current file content' } }); + await waitFor(() => expect(screen.getByText('current file content')).toBeInTheDocument()); + + resolveMarkdown({ data: { path: mdNode.path, content: '# stale markdown content' } }); + await waitFor(() => expect(screen.getByText('current file content')).toBeInTheDocument()); + expect(screen.queryByText((content) => content.includes('stale markdown content'))).not.toBeInTheDocument(); + }); +}); diff --git a/webui/src/pages/Workspace/index.tsx b/webui/src/pages/Workspace/index.tsx index 35af60cbd..374b29078 100644 --- a/webui/src/pages/Workspace/index.tsx +++ b/webui/src/pages/Workspace/index.tsx @@ -12,27 +12,34 @@ import { useConfirm } from '@/components/common/ConfirmDialog'; import { workspaceAPI, WorkspaceNode, formatBytes, formatDate, fileIcon, } from '@/api/workspace'; +import { StreamingMarkdown } from '@/components/common/StreamingMarkdown'; +import { + formatReviewContent, isJsonNode, isMarkdownNode, isTextPreviewNode, +} from './filePreview'; // ─── Types ──────────────────────────────────────────────────────────────── type Tab = 'files' | 'memory'; +type PreviewTab = 'review' | 'raw'; // Preview/edit panel state consolidated into a single object interface PanelState { node: WorkspaceNode | null; content: string | null; + contentError: string | null; editContent: string | null; editing: boolean; saving: boolean; } const PANEL_INIT: PanelState = { - node: null, content: null, editContent: null, editing: false, saving: false, + node: null, content: null, contentError: null, editContent: null, editing: false, saving: false, }; type PanelAction = | { type: 'select'; node: WorkspaceNode } - | { type: 'content_loaded'; content: string } + | { type: 'content_loaded'; path: string; content: string } + | { type: 'content_failed'; path: string; error: string } | { type: 'start_edit' } | { type: 'edit_change'; text: string } | { type: 'save_start' } @@ -45,7 +52,11 @@ function panelReducer(state: PanelState, action: PanelAction): PanelState { case 'select': return { ...PANEL_INIT, node: action.node }; case 'content_loaded': - return { ...state, content: action.content }; + if (state.node?.path !== action.path) return state; + return { ...state, content: action.content, contentError: null }; + case 'content_failed': + if (state.node?.path !== action.path) return state; + return { ...state, contentError: action.error }; case 'start_edit': return { ...state, editing: true, editContent: state.content ?? '' }; case 'edit_change': @@ -89,6 +100,24 @@ export default function WorkspacePage() { ); } +function PreviewTabButton({ active, label, onClick }: { + active: boolean; label: string; onClick: () => void; +}) { + return ( + + ); +} + function TabButton({ active, onClick, icon, label }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; }) { @@ -119,6 +148,7 @@ function FilesTab() { // Preview/edit panel — consolidated into a reducer const [panel, dispatchPanel] = useReducer(panelReducer, PANEL_INIT); + const [previewTab, setPreviewTab] = useState('review'); // Upload / new-dir state const [uploading, setUploading] = useState(false); @@ -127,15 +157,20 @@ function FilesTab() { const fileInputRef = useRef(null); + const closePanel = useCallback(() => { + dispatchPanel({ type: 'close' }); + setPreviewTab('review'); + }, []); + const loadFileContent = useCallback(async (path: string) => { const res = await workspaceAPI.readFile(path); - dispatchPanel({ type: 'content_loaded', content: res.data.content }); + dispatchPanel({ type: 'content_loaded', path, content: res.data.content }); }, []); const loadDir = useCallback(async (path: string, options?: { preservePanel?: boolean }) => { setLoading(true); if (!options?.preservePanel) { - dispatchPanel({ type: 'close' }); + closePanel(); } try { const res = await workspaceAPI.list(path); @@ -146,23 +181,39 @@ function FilesTab() { } finally { setLoading(false); } - }, [toast, t]); + }, [toast, t, closePanel]); + + useEffect(() => { + void loadDir(''); + // Mount-only: avoid re-fetching (and closing the preview panel) when callback identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const textPreviewDrawerOpen = Boolean(panel.node && isTextPreviewNode(panel.node)); useEffect(() => { - loadDir(''); - }, [loadDir]); + if (!textPreviewDrawerOpen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [textPreviewDrawerOpen]); const handleSelectNode = useCallback(async (node: WorkspaceNode) => { if (node.type === 'directory') { loadDir(node.path); return; } + setPreviewTab('review'); dispatchPanel({ type: 'select', node }); - if (node.is_text_file) { + if (isTextPreviewNode(node)) { try { await loadFileContent(node.path); } catch (e: any) { - toast.error(t('files.toast.readFileFailed'), e?.response?.data?.detail ?? e.message); + const message = e?.response?.data?.detail ?? e.message; + dispatchPanel({ type: 'content_failed', path: node.path, error: message }); + toast.error(t('files.toast.readFileFailed'), message); } } }, [loadDir, loadFileContent, toast, t]); @@ -170,28 +221,42 @@ function FilesTab() { const handleRefresh = useCallback(async () => { await loadDir(currentPath, { preservePanel: true }); - if (panel.node?.is_text_file) { + if (panel.node && isTextPreviewNode(panel.node)) { try { await loadFileContent(panel.node.path); } catch (e: any) { - toast.error(t('files.toast.readFileFailed'), e?.response?.data?.detail ?? e.message); + const message = e?.response?.data?.detail ?? e.message; + dispatchPanel({ type: 'content_failed', path: panel.node.path, error: message }); + toast.error(t('files.toast.readFileFailed'), message); } } }, [currentPath, loadDir, loadFileContent, panel.node, toast, t]); + const handleRetryContent = useCallback(async () => { + if (!panel.node || !isTextPreviewNode(panel.node)) return; + try { + await loadFileContent(panel.node.path); + } catch (e: any) { + const message = e?.response?.data?.detail ?? e.message; + dispatchPanel({ type: 'content_failed', path: panel.node.path, error: message }); + toast.error(t('files.toast.readFileFailed'), message); + } + }, [loadFileContent, panel.node, toast, t]); + const handleSave = useCallback(async () => { if (!panel.node || panel.editContent === null) return; dispatchPanel({ type: 'save_start' }); try { await workspaceAPI.writeFile(panel.node.path, panel.editContent); dispatchPanel({ type: 'save_done', content: panel.editContent }); + setPreviewTab('review'); toast.success(t('files.toast.saveSuccess')); - loadDir(currentPath); + loadDir(currentPath, { preservePanel: true }); } catch (e: any) { dispatchPanel({ type: 'cancel_edit' }); toast.error(t('files.toast.saveFailed'), e?.response?.data?.detail ?? e.message); } - }, [panel.node, panel.editContent, currentPath, loadDir, toast]); + }, [panel.node, panel.editContent, currentPath, loadDir, toast, t]); const handleDelete = useCallback(async (node: WorkspaceNode) => { const ok = await confirm({ @@ -208,12 +273,12 @@ function FilesTab() { await workspaceAPI.deleteDir(node.path); } toast.success(t('files.toast.deleteSuccess')); - if (panel.node?.path === node.path) dispatchPanel({ type: 'close' }); + if (panel.node?.path === node.path) closePanel(); loadDir(currentPath); } catch (e: any) { toast.error(t('files.toast.deleteFailed'), e?.response?.data?.detail ?? e.message); } - }, [confirm, panel.node, currentPath, loadDir, toast]); + }, [confirm, panel.node, currentPath, loadDir, toast, closePanel, t]); const handleUpload = useCallback(async (files: FileList | null) => { if (!files || files.length === 0) return; @@ -400,32 +465,121 @@ function FilesTab() { )} - {/* Right: preview / edit panel */} - {panel.node && ( -
+ {/* Text preview: 2/3 overlay drawer */} + {panel.node && isTextPreviewNode(panel.node) && ( + <> +
+
e.stopPropagation()} + > +
+ {fileIcon(panel.node)} + {panel.node.name} +
+ {!panel.editing && ( + + )} + {panel.editing && ( + <> + + + + )} + + + + +
+
+ +
+ {formatBytes(panel.node.size ?? 0)} + {formatDate(panel.node.modified_at)} +
+ + {!panel.editing && isMarkdownNode(panel.node) && ( +
+ setPreviewTab('review')} + /> + setPreviewTab('raw')} + /> +
+ )} + +
+ {panel.editing ? ( +