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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-30 00:35:26 +08:00
parent 3a6f45d173
commit 339a06b281
8 changed files with 125 additions and 49 deletions

View File

@ -176,6 +176,8 @@ export type WorkspaceReadFileResult = {
mimeType?: string
language: string
size: number
truncated?: boolean
readBytes?: number
error?: string
}

View File

@ -11,7 +11,6 @@ vi.mock('../../i18n', () => ({
const translations: Record<string, string> = {
'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(<Sidebar />)
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'))

View File

@ -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')}
</NavItem>
<NavItem
active={activeTabType === 'terminal'}
collapsed={!sidebarOpen}
label={t('sidebar.terminal')}
onClick={() => useTabStore.getState().openTerminalTab()}
icon={<span className="material-symbols-outlined text-[18px]">terminal</span>}
>
{t('sidebar.terminal')}
</NavItem>
</div>
{sidebarOpen ? (

View File

@ -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() {
))}
</div>
<div className="flex shrink-0 items-center gap-0.5 px-1.5">
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
<ToolbarIconButton
icon="terminal"
icon={<SquareTerminal size={17} strokeWidth={1.9} />}
label={t('tabs.openTerminal')}
onClick={() => useTabStore.getState().openTerminalTab()}
/>
{isActiveSessionTab && activeTabId && (
<ToolbarIconButton
icon={isWorkspacePanelOpen ? 'folder_open' : 'folder'}
icon={isWorkspacePanelOpen ? <FolderOpen size={18} strokeWidth={1.9} /> : <Folder size={18} strokeWidth={1.9} />}
label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')}
onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)}
active={isWorkspacePanelOpen}
/>
)}
</div>
@ -461,10 +463,12 @@ function ToolbarIconButton({
icon,
label,
onClick,
active = false,
}: {
icon: string
icon: React.ReactNode
label: string
onClick: () => void
active?: boolean
}) {
return (
<button
@ -472,9 +476,14 @@ function ToolbarIconButton({
aria-label={label}
title={label}
onClick={onClick}
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
data-active={active ? 'true' : 'false'}
className={`inline-flex h-8 w-8 items-center justify-center rounded-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
active
? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<span className="material-symbols-outlined text-[16px]">{icon}</span>
{icon}
</button>
)
}

View File

@ -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) => ({

View File

@ -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 (
<div className="min-h-0 flex-1 overflow-auto bg-[#fdfdfc]">
<div className="mx-auto w-full max-w-[860px] px-6 py-5">
<MarkdownRenderer
content={value}
variant="document"
className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4"
/>
</div>
</div>
)
}
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) ? (
<MarkdownSurface value={activePreviewTab.content ?? ''} />
) : state === 'ok' ? (
<CodeSurface
value={activePreviewTab.content ?? ''}

View File

@ -257,13 +257,17 @@ describe('WorkspaceService', () => {
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',

View File

@ -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,
}
}