Skip to content
Open
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
7 changes: 7 additions & 0 deletions webui/src/locales/en-US/workspace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions webui/src/locales/zh-CN/workspace.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@
"close": "关闭",
"uploading": "上传中...",
"binaryPreview": "二进制文件无法预览",
"unsupportedPreview": "当前文件类型暂不支持预览,请下载查看",
"downloadFile": "下载文件",
"readFailed": "文件内容加载失败",
"retry": "重试",
"previewTabs": {
"review": "预览",
"raw": "源码"
},
"toast": {
"loadDirFailed": "加载目录失败",
"readFileFailed": "读取文件失败",
Expand Down
48 changes: 48 additions & 0 deletions webui/src/pages/Workspace/filePreview.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions webui/src/pages/Workspace/filePreview.ts
Original file line number Diff line number Diff line change
@@ -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;
}
237 changes: 237 additions & 0 deletions webui/src/pages/Workspace/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/api/workspace')>('@/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 }) => <h1>{title}</h1>,
}));

vi.mock('@/components/common/LoadingSpinner', () => ({
default: () => <div role="status">loading</div>,
}));

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(<WorkspacePage />);

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(<WorkspacePage />);

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(<WorkspacePage />);

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(<WorkspacePage />);

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(<WorkspacePage />);

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(<WorkspacePage />);

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(<WorkspacePage />);

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();
});
});
Loading
Loading