mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
3a6f45d173
commit
339a06b281
@ -176,6 +176,8 @@ export type WorkspaceReadFileResult = {
|
||||
mimeType?: string
|
||||
language: string
|
||||
size: number
|
||||
truncated?: boolean
|
||||
readBytes?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
||||
@ -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'))
|
||||
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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 ?? ''}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user