From 339a06b281edd7cfeddd99f280c3d59e13485300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 30 Apr 2026 00:35:26 +0800 Subject: [PATCH] fix: keep local workspace previews available Workspace file preview is a local desktop surface, so image files should not be blocked by the text preview byte cap. Large text files now return a bounded preview instead of an unusable too-large state, while binary files remain explicitly unsupported. Constraint: Avoid unbounded text payloads that can freeze the desktop renderer Rejected: Remove all read limits | large generated files can still overload JSON transport and syntax rendering Confidence: high Scope-risk: moderate Directive: Keep image preview detection before text-size limiting so local images remain renderable Tested: bun test src/server/__tests__/workspace-service.test.ts Tested: bun test src/server/__tests__/sessions.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx Tested: cd desktop && bun run build Not-tested: Full packaged macOS smoke after this commit --- desktop/src/api/sessions.ts | 2 + .../src/components/layout/Sidebar.test.tsx | 21 -------- desktop/src/components/layout/Sidebar.tsx | 10 ---- desktop/src/components/layout/TabBar.tsx | 21 +++++--- .../workspace/WorkspacePanel.test.tsx | 48 +++++++++++++++++++ .../components/workspace/WorkspacePanel.tsx | 24 ++++++++++ .../__tests__/workspace-service.test.ts | 21 +++++++- src/server/services/workspaceService.ts | 27 +++++++---- 8 files changed, 125 insertions(+), 49 deletions(-) diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index e1211e48..137bdbd0 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -176,6 +176,8 @@ export type WorkspaceReadFileResult = { mimeType?: string language: string size: number + truncated?: boolean + readBytes?: number error?: string } diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index dd665f4d..f380c64a 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -11,7 +11,6 @@ vi.mock('../../i18n', () => ({ const translations: Record = { 'sidebar.newSession': 'New Session', 'sidebar.scheduled': 'Scheduled', - 'sidebar.terminal': 'Terminal', 'sidebar.settings': 'Settings', 'sidebar.searchPlaceholder': 'Search sessions', 'sidebar.noSessions': 'No sessions', @@ -105,26 +104,6 @@ describe('Sidebar', () => { expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region') }) - it('opens each terminal click as a first-class app tab', () => { - render() - - fireEvent.click(screen.getByRole('button', { name: 'Terminal' })) - fireEvent.click(screen.getByRole('button', { name: 'Terminal' })) - - const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal') - expect(terminalTabs).toHaveLength(2) - expect(terminalTabs.map((tab) => tab.title)).toEqual(['Terminal 1', 'Terminal 2']) - expect(useTabStore.getState().activeTabId).toBe(terminalTabs[1]!.sessionId) - - useTabStore.getState().closeTab(terminalTabs[0]!.sessionId) - useTabStore.getState().openTerminalTab() - - expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal').map((tab) => tab.title)).toEqual([ - 'Terminal 2', - 'Terminal 3', - ]) - }) - it('shows a toast when session creation fails', async () => { createSession.mockRejectedValue(new Error('boom')) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 9f967e3f..c0add799 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -26,7 +26,6 @@ export function Sidebar() { const sidebarOpen = useUIStore((s) => s.sidebarOpen) const toggleSidebar = useUIStore((s) => s.toggleSidebar) const activeTabId = useTabStore((s) => s.activeTabId) - const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) const [searchQuery, setSearchQuery] = useState('') @@ -203,15 +202,6 @@ export function Sidebar() { > {t('sidebar.scheduled')} - useTabStore.getState().openTerminalTab()} - icon={terminal} - > - {t('sidebar.terminal')} - {sidebarOpen ? ( diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index a44de357..134b6570 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -4,6 +4,7 @@ import { useChatStore } from '../../stores/chatStore' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useTranslation } from '../../i18n' import { WindowControls, showWindowControls } from './WindowControls' +import { Folder, FolderOpen, SquareTerminal } from 'lucide-react' const TAB_WIDTH = 180 const DRAG_START_THRESHOLD = 4 @@ -278,17 +279,18 @@ export function TabBar() { ))} -
+
} label={t('tabs.openTerminal')} onClick={() => useTabStore.getState().openTerminalTab()} /> {isActiveSessionTab && activeTabId && ( : } label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')} onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)} + active={isWorkspacePanelOpen} /> )}
@@ -461,10 +463,12 @@ function ToolbarIconButton({ icon, label, onClick, + active = false, }: { - icon: string + icon: React.ReactNode label: string onClick: () => void + active?: boolean }) { return ( ) } diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 4d969ba4..5ace9434 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -539,6 +539,54 @@ describe('WorkspacePanel', () => { expect(image.getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgo=') }) + it('renders markdown file previews as formatted documents', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-markdown-preview': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-markdown-preview': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-markdown-preview': [{ + id: 'file:README.md', + path: 'README.md', + kind: 'file', + title: 'README.md', + language: 'markdown', + content: '# Project Notes\n\n- **Done** item\n\n```ts\nexport const ok = true\n```', + state: 'ok', + size: 70, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-markdown-preview': 'file:README.md', + }, + })) + + const view = await renderPanel('session-markdown-preview') + + expect(view.getByRole('heading', { name: 'Project Notes', level: 1 })).toBeTruthy() + expect(view.getByText('Done')).toBeTruthy() + expect(view.container.textContent).toContain('export const ok = true') + expect(view.queryByTestId('workspace-code')).toBeNull() + }) + it('uses the localized view menu label', async () => { await setSettingsState({ ...settingsInitialState, locale: 'zh' }) await setWorkspaceState((state) => ({ diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index 50431de4..7f6d3b37 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -13,6 +13,7 @@ import { type WorkspacePreviewKind, type WorkspacePreviewTab, } from '../../stores/workspacePanelStore' +import { MarkdownRenderer } from '../markdown/MarkdownRenderer' type WorkspacePanelProps = { sessionId: string @@ -176,6 +177,13 @@ function getLanguageFromPath(path: string) { return normalizePrismLanguage(getFileExtension(path) || 'text') } +function isMarkdownPreview(tab: WorkspacePreviewTab) { + if (tab.kind !== 'file') return false + const language = (tab.language ?? '').toLowerCase() + const extension = getFileExtension(tab.path) + return language === 'markdown' || language === 'md' || extension === 'md' || extension === 'markdown' +} + function FileTypeBadge({ name, subtle = false }: { name: string; subtle?: boolean }) { const meta = getFileBadgeMeta(name) return ( @@ -417,6 +425,20 @@ function CodeSurface({ value, language }: { value: string; language: string }) { ) } +function MarkdownSurface({ value }: { value: string }) { + return ( +
+
+ +
+
+ ) +} + function DiffSurface({ value, path }: { value: string; path: string }) { const t = useTranslation() const lines = value.split('\n') @@ -902,6 +924,8 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { value={activePreviewTab.diff ?? ''} path={activePreviewTab.path} /> + ) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? ( + ) : state === 'ok' ? ( { expect(diffOutcome.error).toMatch(/outside workspace/) }) - it('returns explicit readFile states for text, binary, too-large, and missing targets', async () => { + it('returns explicit readFile states for text, binary, large, and missing targets', async () => { const workDir = await makeTempDir('workspace-service-files-') const service = new WorkspaceService(async () => workDir) await fs.writeFile(path.join(workDir, 'note.ts'), 'export const answer = 42\n') await fs.writeFile(path.join(workDir, 'binary.bin'), Buffer.from([0, 1, 2, 3])) await fs.writeFile(path.join(workDir, 'image.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])) + await fs.writeFile( + path.join(workDir, 'large-image.png'), + Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(ONE_MIB + 1, 0xff)]), + ) await fs.writeFile(path.join(workDir, 'large.txt'), Buffer.alloc(ONE_MIB + 1, 'a')) await fs.mkdir(path.join(workDir, 'folder')) @@ -286,10 +290,23 @@ describe('WorkspaceService', () => { dataUrl: 'data:image/png;base64,iVBORwA=', size: 5, }) + const largeImage = await service.readFile('session-1', 'large-image.png') + expect(largeImage).toMatchObject({ + state: 'ok', + previewType: 'image', + language: 'image', + mimeType: 'image/png', + size: ONE_MIB + 5, + }) + expect(largeImage.dataUrl).toStartWith('data:image/png;base64,') await expect(service.readFile('session-1', 'large.txt')).resolves.toMatchObject({ - state: 'too_large', + state: 'ok', + previewType: 'text', language: 'text', size: ONE_MIB + 1, + readBytes: ONE_MIB, + truncated: true, + content: 'a'.repeat(ONE_MIB), }) await expect(service.readFile('session-1', 'missing.txt')).resolves.toMatchObject({ state: 'missing', diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts index 7d954d6f..e2883b07 100644 --- a/src/server/services/workspaceService.ts +++ b/src/server/services/workspaceService.ts @@ -81,6 +81,8 @@ export type WorkspaceReadFileResult = { mimeType?: string language: string size: number + truncated?: boolean + readBytes?: number error?: string } @@ -398,18 +400,22 @@ export class WorkspaceService { } const language = this.detectLanguage(resolvedPath.absolutePath) - if (stat.stat.size > MAX_PREVIEW_BYTES) { - return { - state: 'too_large', - path: resolvedPath.relativePath, - language, - size: stat.stat.size, - } - } + const imageMimeType = this.detectImageMimeType(resolvedPath.absolutePath) let content: Buffer try { - content = await fs.readFile(resolvedPath.absolutePath) + if (!imageMimeType && stat.stat.size > MAX_PREVIEW_BYTES) { + const fileHandle = await fs.open(resolvedPath.absolutePath, 'r') + try { + const previewBuffer = Buffer.alloc(MAX_PREVIEW_BYTES) + const { bytesRead } = await fileHandle.read(previewBuffer, 0, MAX_PREVIEW_BYTES, 0) + content = previewBuffer.subarray(0, bytesRead) + } finally { + await fileHandle.close() + } + } else { + content = await fs.readFile(resolvedPath.absolutePath) + } } catch (error) { return { state: 'error', @@ -423,7 +429,6 @@ export class WorkspaceService { ), } } - const imageMimeType = this.detectImageMimeType(resolvedPath.absolutePath) if (imageMimeType) { return { state: 'ok', @@ -452,6 +457,8 @@ export class WorkspaceService { content: content.toString('utf8'), language, size: stat.stat.size, + truncated: content.length < stat.stat.size, + readBytes: content.length, } }