diff --git a/.gitignore b/.gitignore index fadf0b09..d6b8e451 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ desktop/brand-assets/ # Superpowers plans & specs (local only) docs/superpowers/ +.superpowers/ # Temp server/web logs .tmp-*.log @@ -64,4 +65,4 @@ docs/.vitepress/cache runtime/__pycache__ # IDE -.idea \ No newline at end of file +.idea diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index b82a913b..e1211e48 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -139,6 +139,80 @@ export type SessionInspectionResponse = { errors?: Record } +export type WorkspaceFileStatus = + | 'modified' + | 'added' + | 'deleted' + | 'renamed' + | 'untracked' + | 'copied' + | 'type_changed' + | 'unknown' + +export type WorkspaceChangedFile = { + path: string + oldPath?: string + status: WorkspaceFileStatus + additions: number + deletions: number +} + +export type WorkspaceStatusResult = { + state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error' + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: WorkspaceChangedFile[] + error?: string +} + +export type WorkspaceReadFileResult = { + state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error' + path: string + previewType?: 'text' | 'image' + content?: string + dataUrl?: string + mimeType?: string + language: string + size: number + error?: string +} + +export type WorkspaceTreeEntry = { + name: string + path: string + isDirectory: boolean +} + +export type WorkspaceTreeResult = { + state: 'ok' | 'missing' | 'error' + path: string + entries: WorkspaceTreeEntry[] + error?: string +} + +export type WorkspaceDiffResult = { + state: 'ok' | 'missing' | 'not_git_repo' | 'error' + path: string + diff?: string + error?: string +} + +function buildWorkspacePath( + sessionId: string, + resource: 'status' | 'tree' | 'file' | 'diff', + workspacePath?: string, +) { + const query = new URLSearchParams() + if (typeof workspacePath === 'string' && workspacePath.length > 0) { + query.set('path', workspacePath) + } + + const qs = query.toString() + return `/api/sessions/${sessionId}/workspace/${resource}${qs ? `?${qs}` : ''}` +} + export const sessionsApi = { list(params?: { project?: string; limit?: number; offset?: number }) { const query = new URLSearchParams() @@ -187,6 +261,22 @@ export const sessionsApi = { }) }, + getWorkspaceStatus(sessionId: string) { + return api.get(buildWorkspacePath(sessionId, 'status')) + }, + + getWorkspaceTree(sessionId: string, workspacePath = '') { + return api.get(buildWorkspacePath(sessionId, 'tree', workspacePath)) + }, + + getWorkspaceFile(sessionId: string, workspacePath: string) { + return api.get(buildWorkspacePath(sessionId, 'file', workspacePath)) + }, + + getWorkspaceDiff(sessionId: string, workspacePath: string) { + return api.get(buildWorkspacePath(sessionId, 'diff', workspacePath)) + }, + rewind(sessionId: string, body: { targetUserMessageId?: string userMessageIndex?: number diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index d41734ed..5b9a3afe 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -36,9 +36,10 @@ type Attachment = { type ChatInputProps = { variant?: 'default' | 'hero' + compact?: boolean } -export function ChatInput({ variant = 'default' }: ChatInputProps) { +export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) { const t = useTranslation() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) @@ -72,7 +73,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const isActive = chatState !== 'idle' const isWorkspaceMissing = activeSession?.workDirExists === false const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0)) - const isHeroComposer = variant === 'hero' && !isMemberSession + const isHeroComposer = variant === 'hero' && !isMemberSession && !compact const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined useEffect(() => { @@ -517,12 +518,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands') return ( -
-
+
+
event.preventDefault()} onDrop={handleDrop} > @@ -637,14 +656,18 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { placeholder={composerPlaceholder} disabled={isWorkspaceMissing} rows={1} - className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50" + className={`w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${ + compact ? 'py-1.5 pb-11' : 'py-2 pb-12' + }`} /> )}
-
+ : `absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] ${ + compact ? 'gap-2 px-2.5 py-2' : 'px-3 py-3' + }`}> +
{!isMemberSession && ( <>
@@ -679,20 +702,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { )}
- + )}
-
+
{!isMemberSession && activeTabId && ( - + )}
@@ -710,12 +743,13 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { {!isMemberSession && ( -
+
{hasMessages ? ( ) : ( s.activeTabId) const resolvedSessionId = sessionId ?? activeTabId const sessionState = useChatStore((s) => @@ -291,9 +292,9 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
-
+
{renderItems.map((item) => { if (item.kind === 'tool_group') { return ( diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 730ba1e7..0605575c 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -21,6 +21,7 @@ type Props = { onChange?: (modelId: string) => void runtimeKey?: string disabled?: boolean + compact?: boolean } function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice { @@ -105,6 +106,7 @@ export function ModelSelector({ onChange, runtimeKey, disabled = false, + compact = false, }: Props = {}) { const t = useTranslation() const { @@ -229,13 +231,15 @@ export function ModelSelector({ {open && ( diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index e472e4f6..edbe4ed8 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -23,6 +23,9 @@ vi.mock('../../i18n', () => ({ 'tabs.closeConfirmMessage': 'Still running', 'tabs.closeConfirmKeep': 'Keep Running', 'tabs.closeConfirmStop': 'Stop & Close', + 'tabs.openTerminal': 'Open Terminal', + 'tabs.showWorkspace': 'Show Workspace', + 'tabs.hideWorkspace': 'Hide Workspace', 'common.cancel': 'Cancel', } @@ -64,11 +67,13 @@ describe('TabBar', () => { afterEach(async () => { const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') useTabStore.setState({ tabs: [], activeTabId: null }) useChatStore.setState({ sessions: {}, } as Partial>) + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__ }) @@ -356,11 +361,122 @@ describe('TabBar', () => { render() }) - expect(screen.getByText('terminal')).toBeInTheDocument() + expect(screen.getByLabelText('Open Terminal')).toBeInTheDocument() fireEvent.click(screen.getByLabelText('Close Terminal 1')) expect(disconnectSession).not.toHaveBeenCalled() expect(useTabStore.getState().tabs).toEqual([]) }) + + it('opens a terminal tab from the toolbar', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' })) + + const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal') + expect(terminalTabs).toHaveLength(1) + expect(useTabStore.getState().activeTabId).toBe(terminalTabs[0]?.sessionId) + }) + + it('toggles the workspace panel for the active session from the toolbar', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Show Workspace' })) + expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Hide Workspace' })) + expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false) + }) + + it('hides the workspace toolbar button for non-session tabs', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + useTabStore.setState({ + tabs: [ + { sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }, + { sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' }, + ], + activeTabId: '__terminal__1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + const { rerender } = render() + + expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument() + + await act(async () => { + useTabStore.getState().setActiveTab('__settings__') + }) + rerender() + + expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument() + }) + + it('clears workspace panel state when closing a session tab', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + useWorkspacePanelStore.getState().openPanel('tab-1') + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByLabelText('Close First Session')) + + expect(useWorkspacePanelStore.getState().panelBySession['tab-1']).toBeUndefined() + }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 7da193ae..a44de357 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -1,6 +1,7 @@ import { forwardRef, useRef, useState, useEffect, useCallback } from 'react' import { useTabStore, type Tab } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useTranslation } from '../../i18n' import { WindowControls, showWindowControls } from './WindowControls' @@ -14,6 +15,11 @@ export function TabBar() { const setActiveTab = useTabStore((s) => s.setActiveTab) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) + const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null + const isActiveSessionTab = activeTab?.type === 'session' + const isWorkspacePanelOpen = useWorkspacePanelStore((state) => + activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, + ) const moveTab = useTabStore((s) => s.moveTab) const scrollRef = useRef(null) @@ -74,12 +80,19 @@ export function TabBar() { el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' }) } + const closeTabWithCleanup = useCallback((tab: Tab) => { + if (tab.type === 'session') { + useWorkspacePanelStore.getState().clearSession(tab.sessionId) + } + closeTab(tab.sessionId) + }, [closeTab]) + const handleClose = (sessionId: string) => { // Special tabs can always be closed directly const tab = tabs.find((t) => t.sessionId === sessionId) if (!tab) return if (tab.type !== 'session') { - closeTab(sessionId) + closeTabWithCleanup(tab) return } @@ -92,7 +105,7 @@ export function TabBar() { } disconnectSession(sessionId) - closeTab(sessionId) + closeTabWithCleanup(tab) } const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { @@ -105,7 +118,7 @@ export function TabBar() { const otherTabs = tabs.filter((t) => t.sessionId !== sessionId) for (const tab of otherTabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) - closeTab(tab.sessionId) + closeTabWithCleanup(tab) } } @@ -115,7 +128,7 @@ export function TabBar() { const leftTabs = tabs.slice(0, idx) for (const tab of leftTabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) - closeTab(tab.sessionId) + closeTabWithCleanup(tab) } } @@ -125,7 +138,7 @@ export function TabBar() { const rightTabs = tabs.slice(idx + 1) for (const tab of rightTabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) - closeTab(tab.sessionId) + closeTabWithCleanup(tab) } } @@ -133,7 +146,7 @@ export function TabBar() { setContextMenu(null) for (const tab of tabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) - closeTab(tab.sessionId) + closeTabWithCleanup(tab) } } @@ -265,6 +278,21 @@ export function TabBar() { ))}
+
+ useTabStore.getState().openTerminalTab()} + /> + {isActiveSessionTab && activeTabId && ( + useWorkspacePanelStore.getState().togglePanel(activeTabId)} + /> + )} +
+ {isTauri && (
+ ) +} diff --git a/desktop/src/components/shared/ProjectContextChip.tsx b/desktop/src/components/shared/ProjectContextChip.tsx index f6a7fb49..2e798d81 100644 --- a/desktop/src/components/shared/ProjectContextChip.tsx +++ b/desktop/src/components/shared/ProjectContextChip.tsx @@ -2,21 +2,26 @@ type Props = { workDir?: string | null repoName?: string | null branch?: string | null + compact?: boolean } -export function ProjectContextChip({ workDir, repoName, branch }: Props) { +export function ProjectContextChip({ workDir, repoName, branch, compact = false }: Props) { const label = branch ? (repoName || workDir?.split('/').pop() || '') : (workDir?.split('/').pop() || repoName || '') if (!label) return null return ( -
+
{branch ? ( - + ) : ( - folder + folder )} {label} {branch ? ( diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx new file mode 100644 index 00000000..500ecb0f --- /dev/null +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -0,0 +1,615 @@ +// @vitest-environment jsdom + +// @ts-expect-error jsdom is installed in this workspace without local type declarations +import { JSDOM } from 'jsdom' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +if (typeof document === 'undefined') { + const dom = new JSDOM('', { + url: 'http://localhost/', + }) + const { window } = dom + + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + localStorage: window.localStorage, + HTMLElement: window.HTMLElement, + HTMLButtonElement: window.HTMLButtonElement, + MutationObserver: window.MutationObserver, + Node: window.Node, + Event: window.Event, + MouseEvent: window.MouseEvent, + KeyboardEvent: window.KeyboardEvent, + getComputedStyle: window.getComputedStyle.bind(window), + IS_REACT_ACT_ENVIRONMENT: true, + }) +} + +type WorkspaceApiMocks = { + getWorkspaceStatusMock: ReturnType + getWorkspaceTreeMock: ReturnType + getWorkspaceFileMock: ReturnType + getWorkspaceDiffMock: ReturnType +} + +var mocks: WorkspaceApiMocks | undefined + +function getMocks() { + if (!mocks) { + throw new Error('Workspace API mocks were not initialized') + } + return mocks +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +async function setWorkspaceState( + updater: + | ReturnType + | Parameters[0], +) { + await act(() => { + useWorkspacePanelStore.setState(updater as Parameters[0], true) + }) +} + +async function setSettingsState( + updater: + | ReturnType + | Parameters[0], +) { + await act(() => { + useSettingsStore.setState(updater as Parameters[0], true) + }) +} + +async function renderPanel(sessionId: string) { + let view!: ReturnType + await act(() => { + view = render() + }) + return view +} + +async function clickElement(element: Element) { + await act(() => { + fireEvent.click(element) + }) +} + +vi.mock('../../api/sessions', () => ({ + sessionsApi: (() => { + if (!mocks) { + mocks = { + getWorkspaceStatusMock: vi.fn(), + getWorkspaceTreeMock: vi.fn(), + getWorkspaceFileMock: vi.fn(), + getWorkspaceDiffMock: vi.fn(), + } + } + + return { + getWorkspaceStatus: mocks.getWorkspaceStatusMock, + getWorkspaceTree: mocks.getWorkspaceTreeMock, + getWorkspaceFile: mocks.getWorkspaceFileMock, + getWorkspaceDiff: mocks.getWorkspaceDiffMock, + } + })(), +})) + +import { useSettingsStore } from '../../stores/settingsStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' +import { WorkspacePanel } from './WorkspacePanel' + +describe('WorkspacePanel', () => { + const workspaceInitialState = useWorkspacePanelStore.getInitialState() + const settingsInitialState = useSettingsStore.getInitialState() + + beforeEach(async () => { + vi.clearAllMocks() + await setWorkspaceState(workspaceInitialState) + await setSettingsState({ ...settingsInitialState, locale: 'en' }) + }) + + afterEach(async () => { + cleanup() + await setWorkspaceState(workspaceInitialState) + await setSettingsState(settingsInitialState) + vi.restoreAllMocks() + }) + + it('stays hidden when the panel is closed', async () => { + const view = await renderPanel('session-hidden') + + expect(view.queryByTestId('workspace-panel')).toBeNull() + }) + + it('loads changed status on open and opens a diff preview from the changed view', async () => { + const statusRequest = deferred<{ + state: 'ok' + workDir: string + repoName: string + branch: string + isGitRepo: true + changedFiles: Array<{ + path: string + status: 'modified' + additions: number + deletions: number + }> + }>() + const diffRequest = deferred<{ + state: 'ok' + path: string + diff: string + }>() + + getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) + getMocks().getWorkspaceDiffMock.mockReturnValue(diffRequest.promise) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-changed') + }) + + const view = await renderPanel('session-changed') + + expect(view.getByTestId('workspace-panel').style.maxWidth).toBe('calc(100% - 348px)') + + await waitFor(() => { + expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed') + }) + + await act(async () => { + statusRequest.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [ + { + path: 'src/app.ts', + status: 'modified', + additions: 4, + deletions: 1, + }, + ], + }) + await statusRequest.promise + }) + + expect(view.getByPlaceholderText('Filter files...')).toBeTruthy() + + await clickElement(await view.findByText('src/app.ts')) + + await waitFor(() => { + expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-changed', 'src/app.ts') + }) + + await act(async () => { + diffRequest.resolve({ + state: 'ok', + path: 'src/app.ts', + diff: '@@ -1 +1 @@\n-console.log("old")\n+console.log("new")', + }) + await diffRequest.promise + }) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")') + }) + expect(view.getAllByText('Diff').length).toBeGreaterThan(0) + }) + + it('renders transcript-derived changed files for non-git sessions', async () => { + getMocks().getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/tmp/non-git-session', + repoName: 'non-git-session', + branch: null, + isGitRepo: false, + changedFiles: [ + { + path: 'src/app.ts', + status: 'modified', + additions: 1, + deletions: 1, + }, + ], + }) + getMocks().getWorkspaceDiffMock.mockResolvedValue({ + state: 'ok', + path: 'src/app.ts', + diff: 'diff --session a/src/app.ts b/src/app.ts\n-export const answer = 1\n+export const answer = 2', + }) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-non-git') + }) + + const view = await renderPanel('session-non-git') + + await waitFor(() => { + expect(view.getByText('src/app.ts')).toBeTruthy() + }) + expect(view.queryByText('No matching files')).toBeNull() + + await clickElement(view.getByText('src/app.ts')) + + await waitFor(() => { + expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-non-git', 'src/app.ts') + }) + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('export const answer = 2') + }) + }) + + it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => { + const statusRequest = deferred<{ + state: 'ok' + workDir: string + repoName: string + branch: string + isGitRepo: true + changedFiles: [] + }>() + const rootTreeRequest = deferred<{ + state: 'ok' + path: '' + entries: Array<{ name: string; path: string; isDirectory: boolean }> + }>() + const childTreeRequest = deferred<{ + state: 'ok' + path: 'src' + entries: Array<{ name: string; path: string; isDirectory: boolean }> + }>() + const fileRequest = deferred<{ + state: 'ok' + path: string + content: string + language: string + size: number + }>() + + getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) + getMocks().getWorkspaceTreeMock + .mockReturnValueOnce(rootTreeRequest.promise) + .mockReturnValueOnce(childTreeRequest.promise) + getMocks().getWorkspaceFileMock.mockReturnValue(fileRequest.promise) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-tree') + }) + + const view = await renderPanel('session-tree') + + expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() + + await clickElement(view.getByRole('button', { name: 'Changed files' })) + await clickElement(view.getByRole('menuitem', { name: 'All files' })) + + await waitFor(() => { + expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '') + }) + + await act(async () => { + statusRequest.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }) + rootTreeRequest.resolve({ + state: 'ok', + path: '', + entries: [ + { name: 'src', path: 'src', isDirectory: true }, + { name: 'README.md', path: 'README.md', isDirectory: false }, + ], + }) + await Promise.all([statusRequest.promise, rootTreeRequest.promise]) + }) + + const folderLabel = await view.findByText('src') + const folderButton = folderLabel.closest('button') + if (!folderButton) { + throw new Error('Expected src label to be rendered inside a folder button') + } + expect(folderButton.getAttribute('aria-expanded')).toBe('false') + + await clickElement(folderButton) + + await waitFor(() => { + expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', 'src') + }) + await act(async () => { + childTreeRequest.resolve({ + state: 'ok', + path: 'src', + entries: [{ name: 'index.ts', path: 'src/index.ts', isDirectory: false }], + }) + await childTreeRequest.promise + }) + await waitFor(() => { + expect(folderButton.getAttribute('aria-expanded')).toBe('true') + }) + + await clickElement(await view.findByText('index.ts')) + + await waitFor(() => { + expect(getMocks().getWorkspaceFileMock).toHaveBeenCalledWith('session-tree', 'src/index.ts') + }) + await act(async () => { + fileRequest.resolve({ + state: 'ok', + path: 'src/index.ts', + content: 'export const ready = true', + language: 'typescript', + size: 25, + }) + await fileRequest.promise + }) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true') + }) + expect(view.getAllByText('File').length).toBeGreaterThan(0) + }) + + it('renders multiple preview tabs and closes only the exact requested tab', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-tabs': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-tabs': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-tabs': [ + { + id: 'file:src/a.ts', + path: 'src/a.ts', + kind: 'file', + title: 'a.ts', + language: 'typescript', + content: 'export const a = 1', + state: 'ok', + size: 18, + }, + { + id: 'diff:src/a.ts', + path: 'src/a.ts', + kind: 'diff', + title: 'a.ts', + diff: '@@ -1 +1 @@', + state: 'ok', + }, + { + id: 'file:src/b.ts', + path: 'src/b.ts', + kind: 'file', + title: 'b.ts', + language: 'typescript', + content: 'export const b = 1', + state: 'ok', + size: 18, + }, + ], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-tabs': 'diff:src/a.ts', + }, + })) + + const view = await renderPanel('session-tabs') + + expect(view.getByRole('tablist', { name: 'Preview tabs' })).toBeTruthy() + expect(view.getAllByRole('tab', { name: /a\.ts/ })).toHaveLength(2) + expect(view.getAllByText('a.ts').length).toBeGreaterThanOrEqual(2) + expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1) + + await clickElement(view.getByLabelText('Close tab a.ts Diff')) + + expect(view.queryByLabelText('Close tab a.ts Diff')).toBeNull() + expect(view.getByLabelText('Close tab a.ts File')).toBeTruthy() + expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1) + }) + + it('caps rendered preview lines to keep large diffs responsive', async () => { + const longDiff = Array.from({ length: 650 }, (_, index) => `+line ${index + 1}`).join('\n') + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-large-preview': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-large-preview': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-large-preview': [{ + id: 'diff:large.ts', + path: 'large.ts', + kind: 'diff', + title: 'large.ts', + diff: longDiff, + state: 'ok', + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-large-preview': 'diff:large.ts', + }, + })) + + const view = await renderPanel('session-large-preview') + const highlightedCode = view.getByTestId('workspace-code').textContent ?? '' + + expect(highlightedCode).toContain('+line 1') + expect(highlightedCode).toContain('+line 420') + expect(highlightedCode).not.toContain('+line 421') + expect(view.getByText('Showing first 420 lines. Open in your editor for the full file.')).toBeTruthy() + }) + + it('renders image previews from workspace files', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-image-preview': { + isOpen: true, + activeView: 'all', + }, + }, + treeBySessionPath: { + ...state.treeBySessionPath, + 'session-image-preview': { + '': { + state: 'ok', + path: '', + entries: [{ name: 'logo.png', path: 'logo.png', isDirectory: false }], + }, + }, + }, + })) + + getMocks().getWorkspaceFileMock.mockResolvedValue({ + state: 'ok', + path: 'logo.png', + previewType: 'image', + dataUrl: 'data:image/png;base64,iVBORw0KGgo=', + mimeType: 'image/png', + language: 'image', + size: 8, + }) + + const view = await renderPanel('session-image-preview') + + await clickElement(await view.findByText('logo.png')) + + const image = await view.findByRole('img', { name: 'logo.png' }) + expect(image.getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgo=') + }) + + it('uses the localized view menu label', async () => { + await setSettingsState({ ...settingsInitialState, locale: 'zh' }) + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-zh': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-zh': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-zh') + + expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy() + }) + + it('shows explicit empty and error states in the changed view', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-empty': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-empty': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-empty') + + expect(view.getByText('No changes')).toBeTruthy() + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-error': { + isOpen: true, + activeView: 'changed', + }, + }, + errors: { + ...state.errors, + statusBySession: { + ...state.errors.statusBySession, + 'session-error': 'status failed', + }, + }, + })) + + await act(() => { + view.rerender() + }) + + expect(view.getByText('status failed')).toBeTruthy() + }) +}) diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx new file mode 100644 index 00000000..a7007105 --- /dev/null +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -0,0 +1,1057 @@ +import { useEffect, useMemo, useState } from 'react' +import { Highlight, type PrismTheme } from 'prism-react-renderer' +import type { + WorkspaceChangedFile, + WorkspaceFileStatus, + WorkspaceTreeEntry, + WorkspaceTreeResult, +} from '../../api/sessions' +import { useTranslation } from '../../i18n' +import { useShallow } from 'zustand/react/shallow' +import { + useWorkspacePanelStore, + type WorkspacePreviewKind, + type WorkspacePreviewTab, +} from '../../stores/workspacePanelStore' + +type WorkspacePanelProps = { + sessionId: string +} + +type TreeNodeProps = { + sessionId: string + entry: WorkspaceTreeEntry + depth: number + expandedPaths: Set + treeByPath: Record + treeLoadingByPath: Record + treeErrorsByPath: Record + filterQuery: string + onToggle: (path: string) => void + onOpenFile: (path: string) => void + activePath: string | null +} + +const FILE_STATUS_META: Record = { + modified: { + label: 'M', + className: 'border-[var(--color-warning)]/35 bg-[var(--color-warning)]/12 text-[var(--color-warning)]', + }, + added: { + label: 'A', + className: 'border-[var(--color-success)]/35 bg-[var(--color-success)]/12 text-[var(--color-success)]', + }, + deleted: { + label: 'D', + className: 'border-[var(--color-error)]/35 bg-[var(--color-error)]/12 text-[var(--color-error)]', + }, + renamed: { + label: 'R', + className: 'border-[var(--color-brand)]/35 bg-[var(--color-brand)]/12 text-[var(--color-brand)]', + }, + untracked: { + label: 'U', + className: 'border-[var(--color-tertiary)]/35 bg-[var(--color-tertiary)]/12 text-[var(--color-tertiary)]', + }, + copied: { + label: 'C', + className: 'border-[var(--color-secondary)]/35 bg-[var(--color-secondary)]/12 text-[var(--color-secondary)]', + }, + type_changed: { + label: 'T', + className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]', + }, + unknown: { + label: '?', + className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]', + }, +} + +const EMPTY_TREE_BY_PATH: Record = {} +const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = [] +const EMPTY_EXPANDED_PATHS: string[] = [] +const PREVIEW_LINE_LIMIT = 420 + +const workspacePrismTheme: PrismTheme = { + plain: { + color: 'var(--color-code-fg)', + backgroundColor: 'transparent', + }, + styles: [ + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } }, + { types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } }, + { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['function'], style: { color: 'var(--color-code-function)' } }, + { types: ['tag'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } }, + { types: ['operator'], style: { color: 'var(--color-code-fg)' } }, + { types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } }, + { types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } }, + { types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } }, + { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } }, + { types: ['inserted'], style: { color: 'var(--color-code-inserted)' } }, + { types: ['deleted'], style: { color: 'var(--color-code-deleted)' } }, + ], +} + +const FILE_BADGE_META: Record = { + ts: { label: 'TS', className: 'bg-[#dff0ff] text-[#2b86c5]' }, + tsx: { label: 'TSX', className: 'bg-[#dff0ff] text-[#2b86c5]' }, + js: { label: 'JS', className: 'bg-[#fff3bf] text-[#8a6500]' }, + jsx: { label: 'JSX', className: 'bg-[#fff3bf] text-[#8a6500]' }, + json: { label: '{}', className: 'bg-[#eee9ff] text-[#6f4fb8]' }, + md: { label: 'MD', className: 'bg-[#e9eef3] text-[#5e6872]' }, + css: { label: 'CSS', className: 'bg-[#e4f2ff] text-[#246da6]' }, + html: { label: 'H', className: 'bg-[#ffe7dc] text-[#b9552d]' }, + png: { label: 'IMG', className: 'bg-[#e4f7ed] text-[#287747]' }, + jpg: { label: 'IMG', className: 'bg-[#e4f7ed] text-[#287747]' }, + jpeg: { label: 'IMG', className: 'bg-[#e4f7ed] text-[#287747]' }, + gif: { label: 'IMG', className: 'bg-[#e4f7ed] text-[#287747]' }, + svg: { label: 'SVG', className: 'bg-[#e4f7ed] text-[#287747]' }, +} + +function makeTreeStateKey(sessionId: string, path: string) { + return `${sessionId}::${path}` +} + +function makePreviewStateKey(sessionId: string, tabId: string) { + return `${sessionId}::${tabId}` +} + +function getSessionScopedRecord( + record: Record, + sessionId: string, +) { + const prefix = `${sessionId}::` + return Object.fromEntries( + Object.entries(record).filter(([key]) => key.startsWith(prefix)), + ) as Record +} + +function getPreviewKindLabel( + t: ReturnType, + kind: WorkspacePreviewKind, +) { + return kind === 'diff' ? t('workspace.previewKind.diff') : t('workspace.previewKind.file') +} + +function getFileExtension(name: string) { + const cleanName = name.split('/').pop() ?? name + const lastDot = cleanName.lastIndexOf('.') + if (lastDot <= 0 || lastDot === cleanName.length - 1) return '' + return cleanName.slice(lastDot + 1).toLowerCase() +} + +function getFileBadgeMeta(name: string) { + const extension = getFileExtension(name) + return FILE_BADGE_META[extension] ?? { + label: extension ? extension.slice(0, 3).toUpperCase() : 'TXT', + className: 'bg-[#eef0f2] text-[#747b83]', + } +} + +function normalizePrismLanguage(language: string) { + const lower = language.toLowerCase() + const map: Record = { + text: 'text', + typescript: 'typescript', + ts: 'typescript', + tsx: 'tsx', + javascript: 'javascript', + js: 'javascript', + jsx: 'jsx', + markdown: 'markdown', + md: 'markdown', + html: 'markup', + xml: 'markup', + shell: 'bash', + sh: 'bash', + zsh: 'bash', + diff: 'diff', + } + return map[lower] ?? lower +} + +function getLanguageFromPath(path: string) { + return normalizePrismLanguage(getFileExtension(path) || 'text') +} + +function FileTypeBadge({ name, subtle = false }: { name: string; subtle?: boolean }) { + const meta = getFileBadgeMeta(name) + return ( + + ) +} + +function InlineHighlightedCode({ + value, + language, +}: { + value: string + language: string +}) { + return ( + + {({ tokens, getTokenProps }) => ( + <> + {(tokens[0] ?? []).map((token, tokenIndex) => { + const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) + return + })} + + )} + + ) +} + +function getInlineStateMessage( + t: ReturnType, + state: WorkspacePreviewTab['state'] | WorkspaceTreeResult['state'] | 'not_git_repo' | undefined, + fallbackError?: string | null, +) { + switch (state) { + case 'loading': + return t('workspace.previewState.loading') + case 'binary': + return t('workspace.previewState.binary') + case 'too_large': + return t('workspace.previewState.tooLarge') + case 'missing': + return t('workspace.previewState.missing') + case 'not_git_repo': + return t('workspace.notGitRepo') + case 'error': + return fallbackError || t('workspace.loadError') + default: + return fallbackError || t('workspace.loadError') + } +} + +function normalizeFilterQuery(query: string) { + return query.trim().toLowerCase() +} + +function changedFileMatchesFilter(file: WorkspaceChangedFile, query: string) { + if (!query) return true + return ( + file.path.toLowerCase().includes(query) + || file.oldPath?.toLowerCase().includes(query) + || file.status.toLowerCase().includes(query) + ) +} + +function treeEntryMatchesFilter( + entry: WorkspaceTreeEntry, + query: string, + treeByPath: Record, +): boolean { + if (!query) return true + if (entry.name.toLowerCase().includes(query) || entry.path.toLowerCase().includes(query)) { + return true + } + + if (!entry.isDirectory) return false + const childTree = treeByPath[entry.path] + if (childTree?.state !== 'ok') return false + return childTree.entries.some((child) => treeEntryMatchesFilter(child, query, treeByPath)) +} + +function PanelMessage({ + icon, + message, + tone = 'muted', + compact = false, +}: { + icon: string + message: string + tone?: 'muted' | 'error' + compact?: boolean +}) { + const toneClass = + tone === 'error' + ? 'text-[var(--color-error)]' + : 'text-[var(--color-text-tertiary)]' + + return ( +
+ + {icon} + + {message} +
+ ) +} + +function ToolbarIconButton({ + icon, + label, + onClick, +}: { + icon: string + label: string + onClick: () => void +}) { + return ( + + ) +} + +function WorkspaceFilterInput({ + value, + onChange, +}: { + value: string + onChange: (value: string) => void +}) { + const t = useTranslation() + + return ( +
+ +
+ ) +} + +function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) { + const meta = FILE_STATUS_META[status] + return ( + + {meta.label} + + ) +} + +function CodeSurface({ value, language }: { value: string; language: string }) { + const t = useTranslation() + const lines = value.split('\n') + const visibleLines = lines.slice(0, PREVIEW_LINE_LIMIT) + const visibleCode = visibleLines.join('\n') + const hiddenLineCount = Math.max(0, lines.length - visibleLines.length) + + return ( +
+
+ + {({ tokens, getLineProps, getTokenProps }) => ( +
+              {tokens.map((line, index) => {
+                const { key: lineKey, ...lineProps } = getLineProps({ line, key: index })
+                return (
+                  
+ + {index + 1} + + + {line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => { + const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) + return + })} + +
+ ) + })} +
+ )} +
+ {hiddenLineCount > 0 && ( +
+ {t('workspace.previewLineLimit', { count: PREVIEW_LINE_LIMIT })} +
+ )} +
+
+ ) +} + +function DiffSurface({ value, path }: { value: string; path: string }) { + const t = useTranslation() + const lines = value.split('\n') + const visibleLines = lines.slice(0, PREVIEW_LINE_LIMIT) + const hiddenLineCount = Math.max(0, lines.length - visibleLines.length) + const language = getLanguageFromPath(path) + + return ( +
+
+
+          {visibleLines.map((line, index) => {
+            const isFileHeader = line.startsWith('diff --') || line.startsWith('--- ') || line.startsWith('+++ ')
+            const isHunk = line.startsWith('@@')
+            const isAdded = line.startsWith('+') && !line.startsWith('+++')
+            const isRemoved = line.startsWith('-') && !line.startsWith('---')
+            const isCodeLine = isAdded || isRemoved || line.startsWith(' ')
+            const code = isCodeLine ? line.slice(1) : line
+            const prefix = isCodeLine ? line[0] : ' '
+
+            return (
+              
+ + {index + 1} + + + {prefix} + + + {isCodeLine ? ( + code ? : ' ' + ) : ( + code || ' ' + )} + +
+ ) + })} +
+ {hiddenLineCount > 0 && ( +
+ {t('workspace.previewLineLimit', { count: PREVIEW_LINE_LIMIT })} +
+ )} +
+
+ ) +} + +function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) { + const t = useTranslation() + + if (!tab.dataUrl) { + return ( + + ) + } + + return ( +
+
+ {tab.path} +
+
+ ) +} + +function ChangedFileRow({ + file, + onClick, +}: { + file: WorkspaceChangedFile + onClick: () => void +}) { + return ( + + ) +} + +function TreeNode({ + sessionId, + entry, + depth, + expandedPaths, + treeByPath, + treeLoadingByPath, + treeErrorsByPath, + filterQuery, + onToggle, + onOpenFile, + activePath, +}: TreeNodeProps) { + const t = useTranslation() + const childTree = treeByPath[entry.path] + const childLoading = treeLoadingByPath[makeTreeStateKey(sessionId, entry.path)] ?? false + const childError = treeErrorsByPath[makeTreeStateKey(sessionId, entry.path)] ?? null + const isExpanded = expandedPaths.has(entry.path) + const isVisuallyExpanded = isExpanded || filterQuery.length > 0 + const indent = 14 + depth * 20 + + if (!entry.isDirectory) { + const isActive = entry.path === activePath + return ( + + ) + } + + return ( +
+ + + {isVisuallyExpanded && ( +
+ {depth < 4 && ( +
+ )} +
+ ) +} + +export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { + const t = useTranslation() + const [filterQuery, setFilterQuery] = useState('') + const [isViewMenuOpen, setIsViewMenuOpen] = useState(false) + const width = useWorkspacePanelStore((state) => state.width) + const isOpen = useWorkspacePanelStore((state) => state.isPanelOpen(sessionId)) + const activeView = useWorkspacePanelStore((state) => state.getActiveView(sessionId)) + const status = useWorkspacePanelStore((state) => state.statusBySession[sessionId]) + const treeByPath = useWorkspacePanelStore((state) => state.treeBySessionPath[sessionId] ?? EMPTY_TREE_BY_PATH) + const previewTabs = useWorkspacePanelStore((state) => state.previewTabsBySession[sessionId] ?? EMPTY_PREVIEW_TABS) + const activePreviewTabId = useWorkspacePanelStore((state) => state.activePreviewTabIdBySession[sessionId] ?? null) + const expandedPaths = useWorkspacePanelStore((state) => state.expandedPathsBySession[sessionId] ?? EMPTY_EXPANDED_PATHS) + const statusLoading = useWorkspacePanelStore((state) => state.loading.statusBySession[sessionId] ?? false) + const treeLoadingByPath = useWorkspacePanelStore( + useShallow((state) => getSessionScopedRecord(state.loading.treeBySessionPath, sessionId)), + ) + const statusError = useWorkspacePanelStore((state) => state.errors.statusBySession[sessionId] ?? null) + const treeErrorsByPath = useWorkspacePanelStore( + useShallow((state) => getSessionScopedRecord(state.errors.treeBySessionPath, sessionId)), + ) + const setActiveView = useWorkspacePanelStore((state) => state.setActiveView) + const loadStatus = useWorkspacePanelStore((state) => state.loadStatus) + const loadTree = useWorkspacePanelStore((state) => state.loadTree) + const toggleTreeNode = useWorkspacePanelStore((state) => state.toggleTreeNode) + const openPreview = useWorkspacePanelStore((state) => state.openPreview) + const closePreview = useWorkspacePanelStore((state) => state.closePreview) + const closePanel = useWorkspacePanelStore((state) => state.closePanel) + + const rootTree = treeByPath[''] + const rootTreeKey = makeTreeStateKey(sessionId, '') + const rootTreeLoading = treeLoadingByPath[rootTreeKey] ?? false + const rootTreeError = treeErrorsByPath[rootTreeKey] ?? null + const normalizedFilterQuery = normalizeFilterQuery(filterQuery) + const expandedPathSet = new Set(expandedPaths) + const activePreviewTab = + previewTabs.find((tab) => tab.id === activePreviewTabId) ?? previewTabs[previewTabs.length - 1] ?? null + const activeTreePath = activePreviewTab?.kind === 'file' ? activePreviewTab.path : null + const filteredChangedFiles = useMemo( + () => (status?.changedFiles ?? []).filter((file) => changedFileMatchesFilter(file, normalizedFilterQuery)), + [normalizedFilterQuery, status?.changedFiles], + ) + const filteredRootEntries = useMemo( + () => rootTree?.state === 'ok' + ? rootTree.entries.filter((entry) => treeEntryMatchesFilter(entry, normalizedFilterQuery, treeByPath)) + : [], + [normalizedFilterQuery, rootTree, treeByPath], + ) + const activePreviewRequestKey = activePreviewTab + ? makePreviewStateKey(sessionId, activePreviewTab.id) + : null + const activePreviewLoading = useWorkspacePanelStore((state) => + activePreviewRequestKey ? state.loading.previewByTabId[activePreviewRequestKey] ?? false : false, + ) + const activePreviewError = useWorkspacePanelStore((state) => + activePreviewRequestKey ? state.errors.previewByTabId[activePreviewRequestKey] ?? null : null, + ) + + useEffect(() => { + if (!isOpen || activeView !== 'changed' || status || statusLoading || statusError) return + void loadStatus(sessionId) + }, [activeView, isOpen, loadStatus, sessionId, status, statusError, statusLoading]) + + useEffect(() => { + if (!isOpen || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return + void loadTree(sessionId, '') + }, [activeView, isOpen, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId]) + + if (!isOpen) return null + + const handleRefresh = () => { + void loadStatus(sessionId) + if (activeView === 'all') { + void loadTree(sessionId, '') + } + } + + const handleOpenDiff = (path: string) => { + void openPreview(sessionId, path, 'diff') + } + + const handleOpenFile = (path: string) => { + void openPreview(sessionId, path, 'file') + } + + const handleSetActiveView = (view: 'changed' | 'all') => { + setActiveView(sessionId, view) + setIsViewMenuOpen(false) + } + + const renderChangedView = () => { + if (statusLoading && !status) { + return + } + + if (status?.state === 'missing_workdir') { + return + } + + if (status?.state === 'not_git_repo') { + return + } + + if (statusError || status?.state === 'error') { + return ( + + ) + } + + if (!status) { + return + } + + if (status.changedFiles.length === 0) { + return + } + + if (filteredChangedFiles.length === 0) { + return + } + + return ( +
+ {filteredChangedFiles.map((file) => ( + handleOpenDiff(file.path)} + /> + ))} +
+ ) + } + + const renderAllFilesView = () => { + if (rootTreeLoading && !rootTree) { + return + } + + if (rootTreeError) { + return + } + + if (rootTree?.state === 'missing') { + return + } + + if (rootTree?.state === 'error') { + return + } + + if (!rootTree) { + return + } + + if (rootTree.entries.length === 0) { + return + } + + if (filteredRootEntries.length === 0) { + return + } + + return ( +
+ {filteredRootEntries.map((entry) => ( + { + void toggleTreeNode(sessionId, path) + }} + onOpenFile={handleOpenFile} + activePath={activeTreePath} + /> + ))} +
+ ) + } + + const renderPreviewContent = () => { + if (!activePreviewTab) { + return ( +
+ {t('workspace.previewEmpty')} +
+ ) + } + + const kindLabel = getPreviewKindLabel(t, activePreviewTab.kind) + const state = activePreviewTab.state ?? 'loading' + + return ( + <> +
+ {status?.repoName || 'workspace'} + {activePreviewTab.path.split('/').map((segment, index, segments) => ( + + + + {segment} + + + ))} + + {kindLabel} + +
+ + {activePreviewLoading || state === 'loading' ? ( + + ) : state === 'ok' && activePreviewTab.previewType === 'image' ? ( + + ) : state === 'ok' && activePreviewTab.kind === 'diff' ? ( + + ) : state === 'ok' ? ( + + ) : ( + + )} + + ) + } + + const renderPreviewTabs = () => ( +
+ {previewTabs.length === 0 ? ( +
+ docs + {t('workspace.preview')} +
+ ) : ( + previewTabs.map((tab) => { + const kindLabel = getPreviewKindLabel(t, tab.kind) + const isActive = tab.id === activePreviewTab?.id + + return ( +
+ + +
+ ) + }) + )} +
+ ) + + return ( + + ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ca59f1e5..f1814d22 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -41,6 +41,34 @@ export const en = { 'titlebar.terminal': 'Terminal', 'titlebar.history': 'History', + // ─── Workspace Panel ─────────────────────────────── + 'workspace.changedFiles': 'Changed files', + 'workspace.allFiles': 'All files', + 'workspace.viewTabs': 'Workspace views', + 'workspace.previewTabs': 'Preview tabs', + 'workspace.filterPlaceholder': 'Filter files...', + 'workspace.clearFilter': 'Clear file filter', + 'workspace.refresh': 'Refresh workspace', + 'workspace.closePanel': 'Close workspace panel', + 'workspace.resizePanel': 'Resize workspace panel', + 'workspace.closeTab': 'Close tab', + 'workspace.preview': 'Preview', + 'workspace.previewEmpty': 'Select a file to preview.', + 'workspace.notGitRepo': 'Not a git repository.', + 'workspace.missingWorkdir': 'Working directory is missing.', + 'workspace.loadError': 'Failed to load workspace data.', + 'workspace.noChanges': 'No changes', + 'workspace.noFiles': 'No files', + 'workspace.noMatchingFiles': 'No matching files', + 'workspace.previewKind.diff': 'Diff', + 'workspace.previewKind.file': 'File', + 'workspace.previewState.loading': 'Loading preview...', + 'workspace.previewState.binary': 'Binary file preview is unavailable.', + 'workspace.previewState.tooLarge': 'File is too large to preview.', + 'workspace.previewState.missing': 'File not found.', + 'workspace.imagePreviewUnavailable': 'Image preview is unavailable.', + 'workspace.previewLineLimit': 'Showing first {count} lines. Open in your editor for the full file.', + // ─── Status Bar ────────────────────────────────────── 'status.connected': 'Connected', 'status.connecting': 'Connecting...', @@ -962,6 +990,9 @@ export const en = { 'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?', 'tabs.closeConfirmKeep': 'Keep Running', 'tabs.closeConfirmStop': 'Stop & Close', + 'tabs.openTerminal': 'Open Terminal', + 'tabs.showWorkspace': 'Show Workspace', + 'tabs.hideWorkspace': 'Hide Workspace', } as const export type TranslationKey = keyof typeof en diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 8350dfbb..9e4a1ce1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -43,6 +43,34 @@ export const zh: Record = { 'titlebar.terminal': '终端', 'titlebar.history': '历史', + // ─── Workspace Panel ─────────────────────────────── + 'workspace.changedFiles': '已更改文件', + 'workspace.allFiles': '所有文件', + 'workspace.viewTabs': '工作区视图', + 'workspace.previewTabs': '预览标签', + 'workspace.filterPlaceholder': '筛选文件...', + 'workspace.clearFilter': '清除文件筛选', + 'workspace.refresh': '刷新工作区', + 'workspace.closePanel': '关闭工作区面板', + 'workspace.resizePanel': '调整工作区宽度', + 'workspace.closeTab': '关闭标签', + 'workspace.preview': '预览', + 'workspace.previewEmpty': '选择一个文件进行预览。', + 'workspace.notGitRepo': '当前目录不是 Git 仓库。', + 'workspace.missingWorkdir': '工作目录不存在。', + 'workspace.loadError': '工作区数据加载失败。', + 'workspace.noChanges': '没有变更', + 'workspace.noFiles': '没有文件', + 'workspace.noMatchingFiles': '没有匹配的文件', + 'workspace.previewKind.diff': 'Diff', + 'workspace.previewKind.file': '文件', + 'workspace.previewState.loading': '正在加载预览...', + 'workspace.previewState.binary': '二进制文件暂不支持预览。', + 'workspace.previewState.tooLarge': '文件过大,无法预览。', + 'workspace.previewState.missing': '文件不存在。', + 'workspace.imagePreviewUnavailable': '图片无法预览。', + 'workspace.previewLineLimit': '仅显示前 {count} 行。完整内容请在编辑器中打开。', + // ─── Status Bar ────────────────────────────────────── 'status.connected': '已连接', 'status.connecting': '连接中...', @@ -964,4 +992,7 @@ export const zh: Record = { 'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?', 'tabs.closeConfirmKeep': '保持运行', 'tabs.closeConfirmStop': '停止并关闭', + 'tabs.openTerminal': '打开终端', + 'tabs.showWorkspace': '显示工作区', + 'tabs.hideWorkspace': '隐藏工作区', } diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index e5b99d4d..18878324 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -1,14 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { render } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import '@testing-library/jest-dom' import { act } from 'react' vi.mock('../components/chat/MessageList', () => ({ - MessageList: () =>
, + MessageList: ({ compact }: { compact?: boolean }) => ( +
+ ), })) vi.mock('../components/chat/ChatInput', () => ({ - ChatInput: () =>
, + ChatInput: ({ compact, variant }: { compact?: boolean; variant?: string }) => ( +
+ ), })) vi.mock('../components/teams/TeamStatusBar', () => ({ @@ -19,12 +23,20 @@ vi.mock('../components/chat/SessionTaskBar', () => ({ SessionTaskBar: () =>
, })) +vi.mock('../components/workspace/WorkspacePanel', () => ({ + WorkspacePanel: ({ sessionId }: { sessionId: string }) => ( +
workspace:{sessionId}
+ ), +})) + import { ActiveSession } from './ActiveSession' import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' import { useSessionStore } from '../stores/sessionStore' import { useTabStore } from '../stores/tabStore' import { useTeamStore } from '../stores/teamStore' +import { useWorkspacePanelStore } from '../stores/workspacePanelStore' +import { WORKSPACE_PANEL_DEFAULT_WIDTH } from '../stores/workspacePanelStore' afterEach(() => { vi.useRealTimers() @@ -32,6 +44,7 @@ afterEach(() => { useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) useChatStore.setState({ sessions: {} }) useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null }) + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) }) describe('ActiveSession task polling', () => { @@ -178,4 +191,179 @@ describe('ActiveSession task polling', () => { unmount() useCLITaskStore.setState(originalCliTaskState) }) + + it('renders the workspace panel to the right of chat and supports resizing', () => { + const sessionId = 'workspace-session' + + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Workspace Session', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 1, + projectPath: '', + workDir: '/tmp/project', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Workspace Session', type: 'session', status: 'idle' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + useWorkspacePanelStore.getState().openPanel(sessionId) + + render() + + const contentRow = screen.getByTestId('active-session-content-row') + const chatColumn = screen.getByTestId('active-session-chat-column') + const resizeHandle = screen.getByTestId('workspace-resize-handle') + + expect(within(contentRow).getByTestId('message-list')).toBeInTheDocument() + expect(within(contentRow).getByTestId('message-list')).toHaveAttribute('data-compact', 'true') + expect(within(contentRow).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`) + expect(within(chatColumn).getByTestId('chat-input')).toBeInTheDocument() + expect(within(chatColumn).getByTestId('chat-input')).toHaveAttribute('data-compact', 'true') + expect(chatColumn).toHaveClass('shrink-0') + expect(contentRow.children[0]).toBe(chatColumn) + expect(contentRow.children[1]).toBe(resizeHandle) + expect(contentRow.children[2]).toBe(screen.getByTestId('workspace-panel')) + + act(() => { + fireEvent.keyDown(resizeHandle, { key: 'ArrowLeft' }) + }) + + expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_DEFAULT_WIDTH + 32) + }) + + it('does not render the workspace panel when closed or for member sessions', () => { + const regularSessionId = 'regular-session' + + useSessionStore.setState({ + sessions: [{ + id: regularSessionId, + title: 'Regular Session', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '', + workDir: '/tmp/project', + workDirExists: true, + }], + activeSessionId: regularSessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId: regularSessionId, title: 'Regular Session', type: 'session', status: 'idle' }], + activeTabId: regularSessionId, + }) + useChatStore.setState({ + sessions: { + [regularSessionId]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + const { rerender } = render() + expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument() + + const memberSessionId = 'team-member:security-reviewer@test-team' + useTeamStore.setState({ + teams: [], + activeTeam: { + name: 'test-team', + leadAgentId: 'team-lead@test-team', + leadSessionId: 'leader-session', + members: [ + { + agentId: 'team-lead@test-team', + role: 'team-lead', + status: 'running', + sessionId: 'leader-session', + }, + { + agentId: 'security-reviewer@test-team', + role: 'security-reviewer', + status: 'running', + }, + ], + }, + memberColors: new Map(), + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }], + activeTabId: memberSessionId, + }) + useChatStore.setState({ + sessions: { + [memberSessionId]: { + messages: [{ id: 'msg-2', type: 'assistant_text', content: 'hello', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + useWorkspacePanelStore.getState().openPanel(memberSessionId) + + rerender() + + expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('message-list')).toBeInTheDocument() + }) }) diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 0cc18ec4..5e44b114 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -1,20 +1,95 @@ -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTabStore } from '../stores/tabStore' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' import { useTeamStore } from '../stores/teamStore' +import { useWorkspacePanelStore } from '../stores/workspacePanelStore' import { useTranslation } from '../i18n' import { MessageList } from '../components/chat/MessageList' import { ChatInput } from '../components/chat/ChatInput' import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal' -import { TeamStatusBar } from '../components/teams/TeamStatusBar' import { SessionTaskBar } from '../components/chat/SessionTaskBar' +import { WorkspacePanel } from '../components/workspace/WorkspacePanel' +import { TeamStatusBar } from '../components/teams/TeamStatusBar' const TASK_POLL_INTERVAL_MS = 1000 +const WORKSPACE_RESIZE_STEP = 32 +const CHAT_COLUMN_COMPACT_CLASS = + 'w-[clamp(340px,23vw,420px)] min-w-[340px] max-w-[420px] shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)]' + +function WorkspaceResizeHandle() { + const t = useTranslation() + const width = useWorkspacePanelStore((state) => state.width) + const setWidth = useWorkspacePanelStore((state) => state.setWidth) + const [dragState, setDragState] = useState<{ startX: number; startWidth: number } | null>(null) + const dragStateRef = useRef(dragState) + + useEffect(() => { + dragStateRef.current = dragState + }, [dragState]) + + useEffect(() => { + if (!dragState) return + + const handlePointerMove = (event: PointerEvent) => { + const current = dragStateRef.current + if (!current) return + setWidth(current.startWidth + current.startX - event.clientX) + } + + const handlePointerUp = () => { + setDragState(null) + } + + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerUp) + + return () => { + document.body.style.cursor = '' + document.body.style.userSelect = '' + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + window.removeEventListener('pointercancel', handlePointerUp) + } + }, [dragState, setWidth]) + + return ( +
{ + if (event.button !== 0) return + event.preventDefault() + setDragState({ startX: event.clientX, startWidth: width }) + }} + onKeyDown={(event) => { + if (event.key === 'ArrowLeft') { + event.preventDefault() + setWidth(width + WORKSPACE_RESIZE_STEP) + } + if (event.key === 'ArrowRight') { + event.preventDefault() + setWidth(width - WORKSPACE_RESIZE_STEP) + } + }} + className="group relative z-10 flex w-2 shrink-0 cursor-col-resize items-stretch justify-center bg-[var(--color-surface)] outline-none focus-visible:bg-[var(--color-surface-container)]" + > +
+
+ ) +} export function ActiveSession() { const activeTabId = useTabStore((s) => s.activeTabId) + const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null) const sessions = useSessionStore((s) => s.sessions) const connectToSession = useChatStore((s) => s.connectToSession) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) @@ -29,6 +104,11 @@ export function ActiveSession() { const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const activeTeam = useTeamStore((s) => s.activeTeam) const isMemberSession = !!memberInfo + const showWorkspacePanel = useWorkspacePanelStore((state) => + activeTabId && activeTabType === 'session' && !isMemberSession + ? state.isPanelOpen(activeTabId) + : false, + ) useEffect(() => { if (activeTabId && !isMemberSession) { @@ -81,132 +161,167 @@ export function ActiveSession() { if (!activeTabId) return null return ( -
- {isMemberSession && ( -
-
-
-
- {memberInfo?.status === 'running' && ( - - )} - {memberInfo?.status === 'completed' && ( - check_circle - )} - smart_toy - - {memberInfo?.role} - - {activeTeam && ( - - @ {activeTeam.name} - - )} -
-

- {t('teams.memberSessionHint')} -

-
- -
-
- )} - - {isEmpty ? ( -
-
- {isMemberSession ? ( - <> - smart_toy -

- {memberInfo?.status === 'running' - ? `${memberInfo.role} ${t('teams.working')}` - : t('teams.noMessages')} -

- - ) : ( - <> - Claude Code Haha -

- {t('empty.title')} -

-

- {t('empty.subtitle')} -

- - )} -
-
- ) : ( - <> - {!isMemberSession && ( -
-
-

- {session?.title || t('session.untitled')} -

-
- {isActive && ( - - - {t('session.active')} - - )} - {totalTokens > 0 && ( - <> - · - {totalTokens.toLocaleString()} t - - )} - {lastUpdated && ( - <> - · - {t('session.lastUpdated', { time: lastUpdated })} - - )} - {session?.messageCount !== undefined && session.messageCount > 0 && ( - <> - · - {t('session.messages', { count: session.messageCount })} - - )} -
- {session?.workDirExists === false && ( -
- warning - - {t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })} +
+
+
+ {isMemberSession && ( +
+
+
+
+ {memberInfo?.status === 'running' && ( + + )} + {memberInfo?.status === 'completed' && ( + check_circle + )} + smart_toy + + {memberInfo?.role} + {activeTeam && ( + + @ {activeTeam.name} + + )}
- )} +

+ {t('teams.memberSessionHint')} +

+
+
)} - - - )} + {isEmpty ? ( +
+
+ {isMemberSession ? ( + <> + smart_toy +

+ {memberInfo?.status === 'running' + ? `${memberInfo.role} ${t('teams.working')}` + : t('teams.noMessages')} +

+ + ) : ( + <> + Claude Code Haha +

+ {t('empty.title')} +

+

+ {t('empty.subtitle')} +

+ + )} +
+
+ ) : ( + <> + {!isMemberSession && ( +
+
+

+ {session?.title || t('session.untitled')} +

+
+ {isActive && ( + + + {t('session.active')} + + )} + {totalTokens > 0 && ( + <> + · + {totalTokens.toLocaleString()} t + + )} + {lastUpdated && ( + <> + · + {t('session.lastUpdated', { time: lastUpdated })} + + )} + {!showWorkspacePanel && session?.messageCount !== undefined && session.messageCount > 0 && ( + <> + · + {t('session.messages', { count: session.messageCount })} + + )} +
+ {session?.workDirExists === false && ( +
+ warning + + {t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })} + +
+ )} +
+
+ )} - {!isMemberSession && } + + + )} - + {!isMemberSession && } - + + + +
+ + {showWorkspacePanel ? ( + <> + + + + ) : null} +
{!isMemberSession && activeTabId ? ( (factory: () => T) => T +} + +if (typeof (vi as HoistedVi).hoisted !== 'function') { + ;(vi as HoistedVi).hoisted = (factory: () => T) => factory() +} + +const mocks = vi.hoisted(() => ({ + getWorkspaceStatusMock: vi.fn(), + getWorkspaceTreeMock: vi.fn(), + getWorkspaceFileMock: vi.fn(), + getWorkspaceDiffMock: vi.fn(), +})) + +vi.mock('../api/sessions', () => ({ + sessionsApi: { + getWorkspaceStatus: mocks.getWorkspaceStatusMock, + getWorkspaceTree: mocks.getWorkspaceTreeMock, + getWorkspaceFile: mocks.getWorkspaceFileMock, + getWorkspaceDiff: mocks.getWorkspaceDiffMock, + }, +})) + +import { sessionsApi } from '../api/sessions' +import { + WORKSPACE_PANEL_DEFAULT_WIDTH, + WORKSPACE_PANEL_MAX_WIDTH, + WORKSPACE_PANEL_MIN_WIDTH, + getWorkspacePreviewTabId, + useWorkspacePanelStore, +} from './workspacePanelStore' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('workspacePanelStore', () => { + const initialState = useWorkspacePanelStore.getInitialState() + + beforeEach(() => { + vi.clearAllMocks() + useWorkspacePanelStore.setState(initialState, true) + }) + + afterEach(() => { + useWorkspacePanelStore.setState(initialState, true) + vi.restoreAllMocks() + }) + + it('uses hoisted session api mocks', () => { + expect(sessionsApi.getWorkspaceStatus).toBe(mocks.getWorkspaceStatusMock) + expect(sessionsApi.getWorkspaceTree).toBe(mocks.getWorkspaceTreeMock) + expect(sessionsApi.getWorkspaceFile).toBe(mocks.getWorkspaceFileMock) + expect(sessionsApi.getWorkspaceDiff).toBe(mocks.getWorkspaceDiffMock) + }) + + it('keeps panel open state and active view isolated per session', () => { + const store = useWorkspacePanelStore.getState() + + expect(store.isPanelOpen('session-a')).toBe(false) + expect(store.getActiveView('session-a')).toBe('changed') + expect(store.width).toBe(WORKSPACE_PANEL_DEFAULT_WIDTH) + + store.openPanel('session-a') + store.setActiveView('session-a', 'all') + + expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(true) + expect(useWorkspacePanelStore.getState().getActiveView('session-a')).toBe('all') + expect(useWorkspacePanelStore.getState().isPanelOpen('session-b')).toBe(false) + expect(useWorkspacePanelStore.getState().getActiveView('session-b')).toBe('changed') + + store.togglePanel('session-b') + expect(useWorkspacePanelStore.getState().isPanelOpen('session-b')).toBe(true) + expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(true) + + store.closePanel('session-a') + expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(false) + expect(useWorkspacePanelStore.getState().getActiveView('session-a')).toBe('all') + + store.setWidth(120) + expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MIN_WIDTH) + store.setWidth(1200) + expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MAX_WIDTH) + }) + + it('loads workspace status successfully', async () => { + mocks.getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [ + { + path: 'src/a.ts', + status: 'modified', + additions: 3, + deletions: 1, + }, + ], + }) + + await useWorkspacePanelStore.getState().loadStatus('session-1') + + expect(mocks.getWorkspaceStatusMock).toHaveBeenCalledWith('session-1') + expect(useWorkspacePanelStore.getState().statusBySession['session-1']).toMatchObject({ + branch: 'main', + changedFiles: [{ path: 'src/a.ts' }], + }) + expect(useWorkspacePanelStore.getState().loading.statusBySession['session-1']).toBe(false) + expect(useWorkspacePanelStore.getState().errors.statusBySession['session-1']).toBeNull() + }) + + it('captures workspace status request errors', async () => { + mocks.getWorkspaceStatusMock.mockRejectedValue(new Error('status failed')) + + await useWorkspacePanelStore.getState().loadStatus('session-err') + + expect(useWorkspacePanelStore.getState().statusBySession['session-err']).toBeUndefined() + expect(useWorkspacePanelStore.getState().loading.statusBySession['session-err']).toBe(false) + expect(useWorkspacePanelStore.getState().errors.statusBySession['session-err']).toBe('status failed') + }) + + it('lazily loads tree nodes when expanding and reuses cached results', async () => { + mocks.getWorkspaceTreeMock.mockResolvedValue({ + state: 'ok', + path: '', + entries: [ + { name: 'src', path: 'src', isDirectory: true }, + { name: 'README.md', path: 'README.md', isDirectory: false }, + ], + }) + + await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '') + + expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1) + expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '') + expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual(['']) + expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree']?.['']).toMatchObject({ + entries: [{ path: 'src' }, { path: 'README.md' }], + }) + + await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '') + expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([]) + + await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '') + expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1) + expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual(['']) + }) + + it('ignores stale tree responses for the same session path', async () => { + const first = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>() + const second = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>() + + mocks.getWorkspaceTreeMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src') + const secondLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src') + + second.resolve({ + state: 'ok', + path: 'src', + entries: [{ name: 'new.ts', path: 'src/new.ts', isDirectory: false }], + }) + await secondLoad + + first.resolve({ + state: 'ok', + path: 'src', + entries: [{ name: 'old.ts', path: 'src/old.ts', isDirectory: false }], + }) + await firstLoad + + expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree-race']?.src?.entries).toEqual([ + { name: 'new.ts', path: 'src/new.ts', isDirectory: false }, + ]) + }) + + it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => { + const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage + const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null + + mocks.getWorkspaceFileMock.mockResolvedValue({ + state: 'ok', + path: 'src/a.ts', + content: 'export const a = 1', + language: 'typescript', + size: 18, + }) + mocks.getWorkspaceDiffMock.mockResolvedValue({ + state: 'ok', + path: 'src/a.ts', + diff: '@@ -1 +1 @@', + }) + + useWorkspacePanelStore.getState().openPanel('session-preview') + await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file') + await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'diff') + await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file') + + expect(mocks.getWorkspaceFileMock).toHaveBeenCalledTimes(1) + expect(mocks.getWorkspaceDiffMock).toHaveBeenCalledTimes(1) + + const tabs = useWorkspacePanelStore.getState().previewTabsBySession['session-preview'] + expect(tabs).toBeDefined() + expect(tabs).toHaveLength(2) + expect(tabs![0]).toMatchObject({ + id: 'file:src/a.ts', + kind: 'file', + path: 'src/a.ts', + content: 'export const a = 1', + language: 'typescript', + }) + expect(tabs![1]).toMatchObject({ + id: 'diff:src/a.ts', + kind: 'diff', + path: 'src/a.ts', + diff: '@@ -1 +1 @@', + }) + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview']).toBe('file:src/a.ts') + if (setItemSpy) { + expect(setItemSpy).not.toHaveBeenCalled() + } else { + expect(storage).toBeNull() + } + }) + + it('closes exact tab id and preserves sibling preview for the same path', async () => { + mocks.getWorkspaceFileMock.mockResolvedValue({ + state: 'ok', + path: 'src/a.ts', + content: 'export const a = 1', + language: 'typescript', + size: 18, + }) + mocks.getWorkspaceDiffMock.mockResolvedValue({ + state: 'ok', + path: 'src/a.ts', + diff: '@@ -1 +1 @@', + }) + + await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'file') + await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'diff') + + useWorkspacePanelStore.getState().closePreview('session-close-path', 'diff:src/a.ts') + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close-path']).toEqual([ + expect.objectContaining({ id: 'file:src/a.ts' }), + ]) + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close-path']).toBe('file:src/a.ts') + }) + + it('chooses the next sensible active preview when closing the current tab', async () => { + mocks.getWorkspaceFileMock + .mockResolvedValueOnce({ + state: 'ok', + path: 'src/a.ts', + content: 'a', + language: 'typescript', + size: 1, + }) + .mockResolvedValueOnce({ + state: 'ok', + path: 'src/b.ts', + content: 'b', + language: 'typescript', + size: 1, + }) + .mockResolvedValueOnce({ + state: 'ok', + path: 'src/c.ts', + content: 'c', + language: 'typescript', + size: 1, + }) + + await useWorkspacePanelStore.getState().openPreview('session-close', 'src/a.ts', 'file') + await useWorkspacePanelStore.getState().openPreview('session-close', 'src/b.ts', 'file') + await useWorkspacePanelStore.getState().openPreview('session-close', 'src/c.ts', 'file') + + useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/b.ts') + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close']).toMatchObject([ + { id: 'file:src/a.ts' }, + { id: 'file:src/c.ts' }, + ]) + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/c.ts') + + useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/c.ts') + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/a.ts') + }) + + it('ignores stale status responses for the same session', async () => { + const first = deferred<{ + state: 'ok' + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: [] + error?: string + }>() + const second = deferred<{ + state: 'ok' + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: [] + error?: string + }>() + + mocks.getWorkspaceStatusMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstLoad = useWorkspacePanelStore.getState().loadStatus('session-race') + const secondLoad = useWorkspacePanelStore.getState().loadStatus('session-race') + + second.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'new', + isGitRepo: true, + changedFiles: [], + }) + await secondLoad + + first.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'old', + isGitRepo: true, + changedFiles: [], + }) + await firstLoad + + expect(useWorkspacePanelStore.getState().statusBySession['session-race']?.branch).toBe('new') + }) + + it('ignores stale preview responses after close and reopen', async () => { + const first = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>() + const second = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>() + + mocks.getWorkspaceFileMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file') + useWorkspacePanelStore.getState().closePreview('session-preview-race', 'file:src/a.ts') + const secondOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file') + + second.resolve({ + state: 'ok', + path: 'src/a.ts', + content: 'new', + language: 'typescript', + size: 3, + }) + await secondOpen + + first.resolve({ + state: 'ok', + path: 'src/a.ts', + content: 'old', + language: 'typescript', + size: 3, + }) + await firstOpen + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-race']).toEqual([ + expect.objectContaining({ id: 'file:src/a.ts', content: 'new' }), + ]) + }) + + it('does not recreate loading or error state when a loading preview is closed before completion', async () => { + const pending = deferred<{ state: 'ok'; path: string; diff: string }>() + const tabId = getWorkspacePreviewTabId('src/a.ts', 'diff') + const previewKey = `session-preview-close::${tabId}` + + mocks.getWorkspaceDiffMock.mockReturnValueOnce(pending.promise) + + const openPromise = useWorkspacePanelStore.getState().openPreview('session-preview-close', 'src/a.ts', 'diff') + useWorkspacePanelStore.getState().closePreview('session-preview-close', tabId) + + pending.resolve({ + state: 'ok', + path: 'src/a.ts', + diff: '@@ -1 +1 @@', + }) + await openPromise + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-close']).toBeUndefined() + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-close']).toBeNull() + expect(useWorkspacePanelStore.getState().loading.previewByTabId[previewKey]).toBeUndefined() + expect(useWorkspacePanelStore.getState().errors.previewByTabId[previewKey]).toBeUndefined() + }) + + it('clears session UI and cached data for clearSession and resetSessionUi', () => { + useWorkspacePanelStore.setState((state) => ({ + ...state, + panelBySession: { + 'session-clear': { isOpen: true, activeView: 'all' }, + 'session-reset': { isOpen: true, activeView: 'all' }, + }, + statusBySession: { + 'session-clear': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + 'session-reset': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + expandedPathsBySession: { + 'session-clear': ['src'], + 'session-reset': ['src'], + }, + treeBySessionPath: { + 'session-clear': { + src: { state: 'ok', path: 'src', entries: [] }, + }, + 'session-reset': { + src: { state: 'ok', path: 'src', entries: [] }, + }, + }, + previewTabsBySession: { + 'session-clear': [ + { id: 'file:src/a.ts', path: 'src/a.ts', kind: 'file', title: 'a.ts' }, + ], + 'session-reset': [ + { id: 'file:src/b.ts', path: 'src/b.ts', kind: 'file', title: 'b.ts' }, + ], + }, + activePreviewTabIdBySession: { + 'session-clear': 'file:src/a.ts', + 'session-reset': 'file:src/b.ts', + }, + loading: { + statusBySession: { + 'session-clear': true, + 'session-reset': true, + }, + treeBySessionPath: { + 'session-clear::src': true, + 'session-reset::src': true, + }, + previewByTabId: { + 'session-clear::file:src/a.ts': true, + 'session-reset::file:src/b.ts': true, + }, + }, + errors: { + statusBySession: { + 'session-clear': 'bad', + 'session-reset': 'bad', + }, + treeBySessionPath: { + 'session-clear::src': 'bad', + 'session-reset::src': 'bad', + }, + previewByTabId: { + 'session-clear::file:src/a.ts': 'bad', + 'session-reset::file:src/b.ts': 'bad', + }, + }, + })) + + useWorkspacePanelStore.getState().clearSession('session-clear') + useWorkspacePanelStore.getState().resetSessionUi('session-reset') + + const state = useWorkspacePanelStore.getState() + expect(state.panelBySession['session-clear']).toBeUndefined() + expect(state.panelBySession['session-reset']).toBeUndefined() + expect(state.statusBySession['session-clear']).toBeUndefined() + expect(state.statusBySession['session-reset']).toBeUndefined() + expect(state.expandedPathsBySession['session-clear']).toBeUndefined() + expect(state.expandedPathsBySession['session-reset']).toBeUndefined() + expect(state.treeBySessionPath['session-clear']).toBeUndefined() + expect(state.treeBySessionPath['session-reset']).toBeUndefined() + expect(state.previewTabsBySession['session-clear']).toBeUndefined() + expect(state.previewTabsBySession['session-reset']).toBeUndefined() + expect(state.activePreviewTabIdBySession['session-clear']).toBeUndefined() + expect(state.activePreviewTabIdBySession['session-reset']).toBeUndefined() + expect(state.loading.statusBySession['session-clear']).toBeUndefined() + expect(state.loading.statusBySession['session-reset']).toBeUndefined() + expect(state.loading.treeBySessionPath['session-clear::src']).toBeUndefined() + expect(state.loading.treeBySessionPath['session-reset::src']).toBeUndefined() + expect(state.loading.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined() + expect(state.loading.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined() + expect(state.errors.statusBySession['session-clear']).toBeUndefined() + expect(state.errors.statusBySession['session-reset']).toBeUndefined() + expect(state.errors.treeBySessionPath['session-clear::src']).toBeUndefined() + expect(state.errors.treeBySessionPath['session-reset::src']).toBeUndefined() + expect(state.errors.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined() + expect(state.errors.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined() + }) +}) diff --git a/desktop/src/stores/workspacePanelStore.ts b/desktop/src/stores/workspacePanelStore.ts new file mode 100644 index 00000000..279ec1d8 --- /dev/null +++ b/desktop/src/stores/workspacePanelStore.ts @@ -0,0 +1,653 @@ +import { create } from 'zustand' +import { + sessionsApi, + type WorkspaceDiffResult, + type WorkspaceReadFileResult, + type WorkspaceStatusResult, + type WorkspaceTreeResult, +} from '../api/sessions' + +export const WORKSPACE_PANEL_DEFAULT_WIDTH = 860 +export const WORKSPACE_PANEL_MIN_WIDTH = 640 +export const WORKSPACE_PANEL_MAX_WIDTH = 1120 + +export type WorkspacePanelView = 'changed' | 'all' +export type WorkspacePreviewKind = 'file' | 'diff' +export type WorkspacePreviewState = + | 'loading' + | WorkspaceReadFileResult['state'] + | WorkspaceDiffResult['state'] + +export type WorkspacePreviewTab = { + id: string + path: string + kind: WorkspacePreviewKind + title: string + language?: string + content?: string + dataUrl?: string + mimeType?: string + previewType?: 'text' | 'image' + diff?: string + state?: WorkspacePreviewState + error?: string + size?: number +} + +export type WorkspacePanelSessionState = { + isOpen: boolean + activeView: WorkspacePanelView +} + +type WorkspacePanelLoadingState = { + statusBySession: Record + treeBySessionPath: Record + previewByTabId: Record +} + +type WorkspacePanelErrorState = { + statusBySession: Record + treeBySessionPath: Record + previewByTabId: Record +} + +type WorkspacePanelStore = { + panelBySession: Record + width: number + statusBySession: Record + expandedPathsBySession: Record + treeBySessionPath: Record | undefined> + previewTabsBySession: Record + activePreviewTabIdBySession: Record + loading: WorkspacePanelLoadingState + errors: WorkspacePanelErrorState + + isPanelOpen: (sessionId: string) => boolean + getActiveView: (sessionId: string) => WorkspacePanelView + openPanel: (sessionId: string) => void + closePanel: (sessionId: string) => void + togglePanel: (sessionId: string) => void + setWidth: (width: number) => void + setActiveView: (sessionId: string, view: WorkspacePanelView) => void + loadStatus: (sessionId: string) => Promise + loadTree: (sessionId: string, path?: string) => Promise + toggleTreeNode: (sessionId: string, path: string) => Promise + openPreview: (sessionId: string, path: string, kind: WorkspacePreviewKind) => Promise + closePreview: (sessionId: string, tabId: string) => void + clearSession: (sessionId: string) => void + resetSessionUi: (sessionId: string) => void +} + +const DEFAULT_PANEL_STATE: WorkspacePanelSessionState = { + isOpen: false, + activeView: 'changed', +} + +const statusRequestIds = new Map() +const treeRequestIds = new Map() +const previewRequestIds = new Map() + +function nextRequestId(store: Map, key: string) { + const requestId = (store.get(key) ?? 0) + 1 + store.set(key, requestId) + return requestId +} + +function invalidateRequest(store: Map, key: string) { + store.set(key, (store.get(key) ?? 0) + 1) +} + +function isLatestRequest(store: Map, key: string, requestId: number) { + return store.get(key) === requestId +} + +export function clampWorkspacePanelWidth(width: number) { + if (!Number.isFinite(width)) return WORKSPACE_PANEL_DEFAULT_WIDTH + const rounded = Math.round(width) + return Math.min(WORKSPACE_PANEL_MAX_WIDTH, Math.max(WORKSPACE_PANEL_MIN_WIDTH, rounded)) +} + +function getSessionPanelState( + panelBySession: Record, + sessionId: string, +) { + return panelBySession[sessionId] ?? DEFAULT_PANEL_STATE +} + +function makeTreeKey(sessionId: string, path: string) { + return `${sessionId}::${path}` +} + +export function getWorkspacePreviewTabId(path: string, kind: WorkspacePreviewKind) { + return `${kind}:${path}` +} + +function makePreviewKey(sessionId: string, tabId: string) { + return `${sessionId}::${tabId}` +} + +function getPathTitle(path: string) { + if (!path) return 'Workspace' + const segments = path.split('/').filter(Boolean) + return segments[segments.length - 1] ?? path +} + +function stripSessionKeys(record: Record, sessionId: string) { + const prefix = `${sessionId}::` + return Object.fromEntries( + Object.entries(record).filter(([key]) => !key.startsWith(prefix)), + ) as Record +} + +function removeRecordKey(record: Record, key: string) { + if (!(key in record)) return record + const { [key]: _removed, ...rest } = record + return rest +} + +function invalidateSessionScopedRequests(store: Map, sessionId: string) { + const prefix = `${sessionId}::` + for (const key of store.keys()) { + if (key.startsWith(prefix)) { + invalidateRequest(store, key) + } + } +} + +function upsertPreviewTab( + tabs: WorkspacePreviewTab[], + tabId: string, + update: WorkspacePreviewTab | ((current: WorkspacePreviewTab) => WorkspacePreviewTab), +) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index < 0) return tabs + + const current = tabs[index]! + const next = typeof update === 'function' ? update(current) : update + const nextTabs = [...tabs] + nextTabs[index] = next + return nextTabs +} + +export const useWorkspacePanelStore = create((set, get) => ({ + panelBySession: {}, + width: WORKSPACE_PANEL_DEFAULT_WIDTH, + statusBySession: {}, + expandedPathsBySession: {}, + treeBySessionPath: {}, + previewTabsBySession: {}, + activePreviewTabIdBySession: {}, + loading: { + statusBySession: {}, + treeBySessionPath: {}, + previewByTabId: {}, + }, + errors: { + statusBySession: {}, + treeBySessionPath: {}, + previewByTabId: {}, + }, + + isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen, + getActiveView: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).activeView, + + openPanel: (sessionId) => + set((state) => ({ + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...getSessionPanelState(state.panelBySession, sessionId), + isOpen: true, + }, + }, + })), + + closePanel: (sessionId) => + set((state) => ({ + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...getSessionPanelState(state.panelBySession, sessionId), + isOpen: false, + }, + }, + })), + + togglePanel: (sessionId) => + set((state) => { + const panel = getSessionPanelState(state.panelBySession, sessionId) + return { + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...panel, + isOpen: !panel.isOpen, + }, + }, + } + }), + + setWidth: (width) => set({ width: clampWorkspacePanelWidth(width) }), + + setActiveView: (sessionId, view) => + set((state) => ({ + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...getSessionPanelState(state.panelBySession, sessionId), + activeView: view, + }, + }, + })), + + loadStatus: async (sessionId) => { + const requestId = nextRequestId(statusRequestIds, sessionId) + + set((state) => ({ + loading: { + ...state.loading, + statusBySession: { + ...state.loading.statusBySession, + [sessionId]: true, + }, + }, + errors: { + ...state.errors, + statusBySession: { + ...state.errors.statusBySession, + [sessionId]: null, + }, + }, + })) + + try { + const result = await sessionsApi.getWorkspaceStatus(sessionId) + if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return + + set((state) => ({ + statusBySession: { + ...state.statusBySession, + [sessionId]: result, + }, + loading: { + ...state.loading, + statusBySession: { + ...state.loading.statusBySession, + [sessionId]: false, + }, + }, + errors: { + ...state.errors, + statusBySession: { + ...state.errors.statusBySession, + [sessionId]: result.error ?? null, + }, + }, + })) + } catch (error) { + if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return + + set((state) => ({ + loading: { + ...state.loading, + statusBySession: { + ...state.loading.statusBySession, + [sessionId]: false, + }, + }, + errors: { + ...state.errors, + statusBySession: { + ...state.errors.statusBySession, + [sessionId]: error instanceof Error ? error.message : 'Failed to load workspace status', + }, + }, + })) + } + }, + + loadTree: async (sessionId, path = '') => { + const treeKey = makeTreeKey(sessionId, path) + const requestId = nextRequestId(treeRequestIds, treeKey) + + set((state) => ({ + loading: { + ...state.loading, + treeBySessionPath: { + ...state.loading.treeBySessionPath, + [treeKey]: true, + }, + }, + errors: { + ...state.errors, + treeBySessionPath: { + ...state.errors.treeBySessionPath, + [treeKey]: null, + }, + }, + })) + + try { + const result = await sessionsApi.getWorkspaceTree(sessionId, path) + if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return + + set((state) => ({ + treeBySessionPath: { + ...state.treeBySessionPath, + [sessionId]: { + ...state.treeBySessionPath[sessionId], + [path]: result, + }, + }, + loading: { + ...state.loading, + treeBySessionPath: { + ...state.loading.treeBySessionPath, + [treeKey]: false, + }, + }, + errors: { + ...state.errors, + treeBySessionPath: { + ...state.errors.treeBySessionPath, + [treeKey]: result.error ?? null, + }, + }, + })) + } catch (error) { + if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return + + set((state) => ({ + loading: { + ...state.loading, + treeBySessionPath: { + ...state.loading.treeBySessionPath, + [treeKey]: false, + }, + }, + errors: { + ...state.errors, + treeBySessionPath: { + ...state.errors.treeBySessionPath, + [treeKey]: error instanceof Error ? error.message : 'Failed to load workspace tree', + }, + }, + })) + } + }, + + toggleTreeNode: async (sessionId, path) => { + let shouldLoad = false + + set((state) => { + const expanded = new Set(state.expandedPathsBySession[sessionId] ?? []) + if (expanded.has(path)) { + expanded.delete(path) + } else { + expanded.add(path) + if (!state.treeBySessionPath[sessionId]?.[path]) { + shouldLoad = true + } + } + + return { + expandedPathsBySession: { + ...state.expandedPathsBySession, + [sessionId]: [...expanded], + }, + } + }) + + if (shouldLoad) { + await get().loadTree(sessionId, path) + } + }, + + openPreview: async (sessionId, path, kind) => { + const tabId = getWorkspacePreviewTabId(path, kind) + const requestKey = makePreviewKey(sessionId, tabId) + const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId) + + if (existing) { + set((state) => ({ + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + [sessionId]: tabId, + }, + })) + return + } + + const requestId = nextRequestId(previewRequestIds, requestKey) + const baseTab: WorkspacePreviewTab = { + id: tabId, + path, + kind, + title: getPathTitle(path), + state: 'loading', + } + + set((state) => ({ + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: [...(state.previewTabsBySession[sessionId] ?? []), baseTab], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + [sessionId]: tabId, + }, + loading: { + ...state.loading, + previewByTabId: { + ...state.loading.previewByTabId, + [requestKey]: true, + }, + }, + errors: { + ...state.errors, + previewByTabId: { + ...state.errors.previewByTabId, + [requestKey]: null, + }, + }, + })) + + try { + if (kind === 'diff') { + const result = await sessionsApi.getWorkspaceDiff(sessionId, path) + if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return + if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return + + set((state) => { + const tabs = state.previewTabsBySession[sessionId] ?? [] + return { + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({ + ...current, + diff: result.diff ?? '', + content: undefined, + language: undefined, + size: undefined, + state: result.state, + error: result.error, + })), + }, + loading: { + ...state.loading, + previewByTabId: { + ...state.loading.previewByTabId, + [requestKey]: false, + }, + }, + errors: { + ...state.errors, + previewByTabId: { + ...state.errors.previewByTabId, + [requestKey]: result.error ?? null, + }, + }, + } + }) + return + } + + const result = await sessionsApi.getWorkspaceFile(sessionId, path) + if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return + if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return + + set((state) => { + const tabs = state.previewTabsBySession[sessionId] ?? [] + return { + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({ + ...current, + content: result.content, + dataUrl: result.dataUrl, + mimeType: result.mimeType, + previewType: result.previewType ?? 'text', + diff: undefined, + language: result.language, + size: result.size, + state: result.state, + error: result.error, + })), + }, + loading: { + ...state.loading, + previewByTabId: { + ...state.loading.previewByTabId, + [requestKey]: false, + }, + }, + errors: { + ...state.errors, + previewByTabId: { + ...state.errors.previewByTabId, + [requestKey]: result.error ?? null, + }, + }, + } + }) + } catch (error) { + if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return + if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return + + set((state) => { + const tabs = state.previewTabsBySession[sessionId] ?? [] + const message = error instanceof Error ? error.message : 'Failed to load workspace preview' + + return { + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({ + ...current, + state: 'error', + error: message, + })), + }, + loading: { + ...state.loading, + previewByTabId: { + ...state.loading.previewByTabId, + [requestKey]: false, + }, + }, + errors: { + ...state.errors, + previewByTabId: { + ...state.errors.previewByTabId, + [requestKey]: message, + }, + }, + } + }) + } + }, + + closePreview: (sessionId, tabId) => { + const requestKey = makePreviewKey(sessionId, tabId) + invalidateRequest(previewRequestIds, requestKey) + + set((state) => { + const tabs = state.previewTabsBySession[sessionId] ?? [] + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index < 0) { + return { + loading: { + ...state.loading, + previewByTabId: removeRecordKey(state.loading.previewByTabId, requestKey), + }, + errors: { + ...state.errors, + previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey), + }, + } + } + + const closingTab = tabs[index]! + const nextTabs = tabs.filter((tab) => tab.id !== closingTab.id) + const activeTabId = state.activePreviewTabIdBySession[sessionId] ?? null + + let nextActiveTabId = activeTabId + if (activeTabId === closingTab.id) { + if (nextTabs.length === 0) { + nextActiveTabId = null + } else if (index >= nextTabs.length) { + nextActiveTabId = nextTabs[nextTabs.length - 1]!.id + } else { + nextActiveTabId = nextTabs[index]!.id + } + } + + return { + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: nextTabs.length > 0 ? nextTabs : undefined, + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + [sessionId]: nextActiveTabId, + }, + loading: { + ...state.loading, + previewByTabId: removeRecordKey(state.loading.previewByTabId, requestKey), + }, + errors: { + ...state.errors, + previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey), + }, + } + }) + }, + + clearSession: (sessionId) => { + invalidateRequest(statusRequestIds, sessionId) + invalidateSessionScopedRequests(treeRequestIds, sessionId) + invalidateSessionScopedRequests(previewRequestIds, sessionId) + + set((state) => ({ + panelBySession: removeRecordKey(state.panelBySession, sessionId), + statusBySession: removeRecordKey(state.statusBySession, sessionId), + expandedPathsBySession: removeRecordKey(state.expandedPathsBySession, sessionId), + treeBySessionPath: removeRecordKey(state.treeBySessionPath, sessionId), + previewTabsBySession: removeRecordKey(state.previewTabsBySession, sessionId), + activePreviewTabIdBySession: removeRecordKey(state.activePreviewTabIdBySession, sessionId), + loading: { + statusBySession: removeRecordKey(state.loading.statusBySession, sessionId), + treeBySessionPath: stripSessionKeys(state.loading.treeBySessionPath, sessionId), + previewByTabId: stripSessionKeys(state.loading.previewByTabId, sessionId), + }, + errors: { + statusBySession: removeRecordKey(state.errors.statusBySession, sessionId), + treeBySessionPath: stripSessionKeys(state.errors.treeBySessionPath, sessionId), + previewByTabId: stripSessionKeys(state.errors.previewByTabId, sessionId), + }, + })) + }, + + resetSessionUi: (sessionId) => { + get().clearSession(sessionId) + }, +})) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 7738fcb1..6789913a 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' import * as fs from 'node:fs/promises' +import { execFileSync } from 'node:child_process' import * as path from 'node:path' import * as os from 'node:os' import { SessionService } from '../services/sessionService.js' @@ -75,6 +76,34 @@ async function writeSkill( ) } +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + }) +} + +async function createWorkspaceApiGitRepo(baseDir: string): Promise { + const workDir = path.join( + baseDir, + `workspace-api-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ) + + await fs.mkdir(path.join(workDir, 'src'), { recursive: true }) + git(workDir, 'init') + git(workDir, 'config', 'user.email', 'sessions-api@example.com') + git(workDir, 'config', 'user.name', 'Sessions API') + + await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\n') + await fs.writeFile(path.join(workDir, 'src', 'app.ts'), 'export const answer = 42\n') + git(workDir, 'add', 'tracked.txt', 'src/app.ts') + git(workDir, 'commit', '-m', 'initial') + + await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\nafter\n') + + return workDir +} + // Sample entries matching real CLI format function makeSnapshotEntry(): Record { return { @@ -136,6 +165,31 @@ function makeAssistantEntry(content: string, parentUuid?: string): Record }>, + parentUuid?: string, +): Record { + return { + parentUuid: parentUuid || null, + isSidechain: false, + type: 'assistant', + message: { + model: 'claude-opus-4-7', + id: `msg_${crypto.randomUUID().slice(0, 20)}`, + type: 'message', + role: 'assistant', + content: toolUses.map((toolUse) => ({ + type: 'tool_use', + id: toolUse.id, + name: toolUse.name, + input: toolUse.input, + })), + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:02:00.000Z', + } +} + function makeMetaUserEntry(): Record { return { parentUuid: null, @@ -930,6 +984,301 @@ describe('Sessions API', () => { ) }) + it('GET /api/sessions/:id/workspace/status|tree|file|diff should return workspace data', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`) + expect(statusRes.status).toBe(200) + const statusBody = await statusRes.json() as { + state: string + workDir: string + changedFiles: Array<{ path: string; status: string }> + isGitRepo: boolean + } + expect(statusBody.state).toBe('ok') + expect(statusBody.workDir).toBe(workDir) + expect(statusBody.isGitRepo).toBe(true) + expect(statusBody.changedFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'tracked.txt', status: 'modified' }), + ]), + ) + + const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`) + expect(treeRes.status).toBe(200) + const treeBody = await treeRes.json() as { + state: string + path: string + entries: Array<{ name: string; path: string; isDirectory: boolean }> + } + expect(treeBody).toMatchObject({ + state: 'ok', + path: '', + }) + expect(treeBody.entries).toEqual([ + { name: 'src', path: 'src', isDirectory: true }, + { name: 'tracked.txt', path: 'tracked.txt', isDirectory: false }, + ]) + + const fileRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`, + ) + expect(fileRes.status).toBe(200) + const fileBody = await fileRes.json() as { + state: string + path: string + content?: string + language: string + size: number + } + expect(fileBody).toMatchObject({ + state: 'ok', + path: 'src/app.ts', + language: 'typescript', + size: 25, + content: 'export const answer = 42\n', + }) + + const diffRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('tracked.txt')}`, + ) + expect(diffRes.status).toBe(200) + const diffBody = await diffRes.json() as { + state: string + path: string + diff?: string + } + expect(diffBody.state).toBe('ok') + expect(diffBody.path).toBe('tracked.txt') + expect(diffBody.diff).toContain('tracked.txt') + }) + + it('GET /api/sessions/:id/workspace/* should surface transcript changes for a non-git tmp session', async () => { + const sessionId = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff' + const workDir = await fs.mkdtemp(path.join(tmpDir, 'workspace-api-non-git-')) + const srcDir = path.join(workDir, 'src') + const notesDir = path.join(workDir, 'notes') + const assetsDir = path.join(workDir, 'assets') + + await fs.mkdir(srcDir, { recursive: true }) + await fs.mkdir(notesDir, { recursive: true }) + await fs.mkdir(assetsDir, { recursive: true }) + await fs.writeFile(path.join(workDir, 'README.md'), '# Temporary project\n') + await fs.writeFile(path.join(srcDir, 'app.ts'), 'export const answer = 2\n') + await fs.writeFile(path.join(notesDir, 'todo.md'), '- ship workspace panel\n') + await fs.writeFile( + path.join(assetsDir, 'pixel.png'), + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', + ), + ) + + await writeSessionFile(sanitizePath(workDir), sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry(workDir), + makeUserEntry('Update this temporary project'), + makeAssistantToolUseEntry([ + { + id: 'toolu-edit-app', + name: 'Edit', + input: { + file_path: path.join(workDir, 'src', 'app.ts'), + old_string: 'export const answer = 1\n', + new_string: 'export const answer = 2\n', + }, + }, + { + id: 'toolu-write-todo', + name: 'Write', + input: { + file_path: path.join(workDir, 'notes', 'todo.md'), + content: '- ship workspace panel\n', + }, + }, + ]), + ]) + + const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`) + expect(statusRes.status).toBe(200) + const statusBody = await statusRes.json() as { + state: string + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: Array<{ + path: string + status: string + additions: number + deletions: number + }> + } + expect(statusBody).toMatchObject({ + state: 'ok', + workDir, + repoName: path.basename(workDir), + branch: null, + isGitRepo: false, + }) + expect(statusBody.changedFiles).toEqual([ + expect.objectContaining({ + path: 'notes/todo.md', + status: 'added', + additions: 1, + deletions: 0, + }), + expect.objectContaining({ + path: 'src/app.ts', + status: 'modified', + additions: 1, + deletions: 1, + }), + ]) + + const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`) + expect(treeRes.status).toBe(200) + const treeBody = await treeRes.json() as { + state: string + path: string + entries: Array<{ name: string; path: string; isDirectory: boolean }> + } + expect(treeBody).toMatchObject({ state: 'ok', path: '' }) + expect(treeBody.entries).toEqual([ + { name: 'assets', path: 'assets', isDirectory: true }, + { name: 'notes', path: 'notes', isDirectory: true }, + { name: 'src', path: 'src', isDirectory: true }, + { name: 'README.md', path: 'README.md', isDirectory: false }, + ]) + + const srcTreeRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/tree?path=${encodeURIComponent('src')}`, + ) + expect(srcTreeRes.status).toBe(200) + expect(await srcTreeRes.json()).toMatchObject({ + state: 'ok', + path: 'src', + entries: [{ name: 'app.ts', path: 'src/app.ts', isDirectory: false }], + }) + + const fileRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`, + ) + expect(fileRes.status).toBe(200) + expect(await fileRes.json()).toMatchObject({ + state: 'ok', + path: 'src/app.ts', + previewType: 'text', + language: 'typescript', + content: 'export const answer = 2\n', + }) + + const imageRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('assets/pixel.png')}`, + ) + expect(imageRes.status).toBe(200) + const imageBody = await imageRes.json() as { + state: string + path: string + previewType: string + mimeType: string + dataUrl: string + } + expect(imageBody).toMatchObject({ + state: 'ok', + path: 'assets/pixel.png', + previewType: 'image', + mimeType: 'image/png', + }) + expect(imageBody.dataUrl).toStartWith('data:image/png;base64,') + + const appDiffRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('src/app.ts')}`, + ) + expect(appDiffRes.status).toBe(200) + const appDiffBody = await appDiffRes.json() as { state: string; path: string; diff?: string } + expect(appDiffBody).toMatchObject({ state: 'ok', path: 'src/app.ts' }) + expect(appDiffBody.diff).toContain('diff --session a/src/app.ts b/src/app.ts') + expect(appDiffBody.diff).toContain('-export const answer = 1') + expect(appDiffBody.diff).toContain('+export const answer = 2') + + const todoDiffRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('notes/todo.md')}`, + ) + expect(todoDiffRes.status).toBe(200) + const todoDiffBody = await todoDiffRes.json() as { state: string; path: string; diff?: string } + expect(todoDiffBody).toMatchObject({ state: 'ok', path: 'notes/todo.md' }) + expect(todoDiffBody.diff).toContain('--- /dev/null') + expect(todoDiffBody.diff).toContain('+++ b/notes/todo.md') + expect(todoDiffBody.diff).toContain('+- ship workspace panel') + }) + + it('GET /api/sessions/:id/workspace/file and diff should require a path query', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + for (const route of ['file', 'diff']) { + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/${route}`) + expect(res.status).toBe(400) + expect(await res.json()).toMatchObject({ + error: 'BAD_REQUEST', + }) + } + }) + + it('GET /api/sessions/:id/workspace/file and tree should reject traversal with 403', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + for (const route of ['file', 'tree']) { + const res = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/${route}?path=${encodeURIComponent('../outside.txt')}`, + ) + expect(res.status).toBe(403) + expect(await res.json()).toMatchObject({ + error: 'FORBIDDEN', + }) + } + }) + + it('GET /api/sessions/:id/workspace/diff should reject traversal with 403', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + const res = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('../outside.txt')}`, + ) + expect(res.status).toBe(403) + expect(await res.json()).toMatchObject({ + error: 'FORBIDDEN', + }) + }) + + it('GET /api/sessions/:id/workspace/status should 404 for unknown sessions', async () => { + const res = await fetch( + `${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000/workspace/status`, + ) + expect(res.status).toBe(404) + expect(await res.json()).toMatchObject({ + error: 'NOT_FOUND', + }) + }) + + it('non-GET workspace routes should return 405', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`, { + method: 'POST', + }) + + expect(res.status).toBe(405) + expect(await res.json()).toMatchObject({ + error: 'METHOD_NOT_ALLOWED', + }) + }) + it('POST /api/sessions/:id/rewind should preview and trim the active conversation chain', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const firstUserId = crypto.randomUUID() diff --git a/src/server/__tests__/workspace-service.test.ts b/src/server/__tests__/workspace-service.test.ts new file mode 100644 index 00000000..5846b8d1 --- /dev/null +++ b/src/server/__tests__/workspace-service.test.ts @@ -0,0 +1,444 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import * as fs from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import * as os from 'node:os' +import * as path from 'node:path' +import { WorkspaceService } from '../services/workspaceService.js' + +const cleanupDirs = new Set() +const ONE_MIB = 1024 * 1024 + +function trackDir(dir: string): string { + cleanupDirs.add(dir) + return dir +} + +async function makeTempDir(prefix: string): Promise { + return trackDir(await fs.mkdtemp(path.join(os.tmpdir(), prefix))) +} + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + }) +} + +async function createGitWorkspace(): Promise { + const repoDir = await makeTempDir('workspace-service-git-') + + git(repoDir, 'init') + git(repoDir, 'config', 'user.email', 'workspace-service@example.com') + git(repoDir, 'config', 'user.name', 'Workspace Service') + + await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\n') + await fs.writeFile(path.join(repoDir, 'deleted.txt'), 'delete me\n') + await fs.writeFile(path.join(repoDir, 'clean.txt'), 'clean\n') + git(repoDir, 'add', 'tracked.txt', 'deleted.txt', 'clean.txt') + git(repoDir, 'commit', '-m', 'initial') + + await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\nafter\n') + await fs.writeFile(path.join(repoDir, 'new.txt'), 'new file\n') + git(repoDir, 'add', 'new.txt') + await fs.unlink(path.join(repoDir, 'deleted.txt')) + await fs.writeFile(path.join(repoDir, 'untracked.txt'), 'still untracked\n') + + return repoDir +} + +async function createNestedGitWorkspace(): Promise<{ + repoDir: string + workDir: string +}> { + const repoDir = await makeTempDir('workspace-service-nested-git-') + const workDir = path.join(repoDir, 'subdir') + + git(repoDir, 'init') + git(repoDir, 'config', 'user.email', 'workspace-service@example.com') + git(repoDir, 'config', 'user.name', 'Workspace Service') + + await fs.mkdir(workDir) + await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\n') + await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\n') + git(repoDir, 'add', 'root.txt', 'subdir/sub.txt') + git(repoDir, 'commit', '-m', 'initial') + + await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\nroot changed\n') + await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\nsub changed\n') + + return { repoDir, workDir } +} + +afterEach(async () => { + for (const dir of cleanupDirs) { + await fs.rm(dir, { recursive: true, force: true }) + } + cleanupDirs.clear() +}) + +describe('WorkspaceService', () => { + it('returns git status for modified, added, deleted, and untracked files', async () => { + const repoDir = await createGitWorkspace() + const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null) + + const result = await service.getStatus('session-1') + + expect(result.state).toBe('ok') + expect(result.workDir).toBe(repoDir) + expect(result.isGitRepo).toBe(true) + expect(result.repoName).toBe(path.basename(repoDir)) + expect(result.branch).toBeTruthy() + + const files = new Map(result.changedFiles.map((file) => [file.path, file])) + expect(Array.from(files.keys()).sort()).toEqual([ + 'deleted.txt', + 'new.txt', + 'tracked.txt', + 'untracked.txt', + ]) + expect(files.get('tracked.txt')?.status).toBe('modified') + expect(files.get('tracked.txt')?.additions).toBeGreaterThan(0) + expect(files.get('new.txt')?.status).toBe('added') + expect(files.get('new.txt')?.additions).toBeGreaterThan(0) + expect(files.get('deleted.txt')?.status).toBe('deleted') + expect(files.get('deleted.txt')?.deletions).toBeGreaterThan(0) + expect(files.get('untracked.txt')).toMatchObject({ + status: 'untracked', + additions: 1, + deletions: 0, + }) + }) + + it('scopes git status and diff paths to a nested workDir inside a repo', async () => { + const { repoDir, workDir } = await createNestedGitWorkspace() + const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? workDir : null) + + const status = await service.getStatus('session-1') + + expect(status.state).toBe('ok') + expect(status.workDir).toBe(workDir) + expect(status.repoName).toBe(path.basename(repoDir)) + expect(status.changedFiles).toHaveLength(1) + expect(status.changedFiles[0]).toMatchObject({ + path: 'sub.txt', + status: 'modified', + }) + expect(status.changedFiles[0]?.additions).toBeGreaterThan(0) + expect(status.changedFiles[0]?.deletions).toBeGreaterThanOrEqual(0) + expect(status.changedFiles.some((file) => file.path === 'root.txt')).toBe(false) + + const diff = await service.getDiff('session-1', 'sub.txt') + expect(diff.state).toBe('ok') + expect(diff.diff).toContain('subdir/sub.txt') + expect(diff.diff?.length).toBeGreaterThan(0) + }) + + it('returns explicit non-git and missing-workdir states', async () => { + const nonGitDir = await makeTempDir('workspace-service-non-git-') + const missingDir = path.join(await makeTempDir('workspace-service-missing-parent-'), 'missing') + const service = new WorkspaceService(async (sessionId) => { + if (sessionId === 'non-git') return nonGitDir + if (sessionId === 'missing') return missingDir + return null + }) + + await expect(service.getStatus('unknown')).rejects.toThrow('Session not found: unknown') + + await expect(service.getStatus('non-git')).resolves.toMatchObject({ + state: 'ok', + workDir: nonGitDir, + repoName: path.basename(nonGitDir), + isGitRepo: false, + changedFiles: [], + }) + + await expect(service.getStatus('missing')).resolves.toMatchObject({ + state: 'missing_workdir', + workDir: missingDir, + isGitRepo: false, + changedFiles: [], + }) + }) + + it('reports session tool edits without requiring a git repository', async () => { + const nonGitDir = await makeTempDir('workspace-service-session-changes-') + await fs.mkdir(path.join(nonGitDir, 'src')) + await fs.writeFile(path.join(nonGitDir, 'src/App.jsx'), 'export default function App() { return
New
}\n') + + const service = new WorkspaceService( + async () => nonGitDir, + async () => [{ + id: 'assistant-1', + type: 'tool_use', + timestamp: new Date().toISOString(), + content: [{ + type: 'tool_use', + name: 'Edit', + input: { + file_path: 'src/App.jsx', + old_string: 'export default function App() { return
Old
}\n', + new_string: 'export default function App() { return
New
}\n', + }, + }], + }], + ) + + const status = await service.getStatus('session-1') + + expect(status).toMatchObject({ + state: 'ok', + workDir: nonGitDir, + isGitRepo: false, + changedFiles: [{ + path: 'src/App.jsx', + status: 'modified', + additions: 1, + deletions: 1, + }], + }) + + const diff = await service.getDiff('session-1', 'src/App.jsx') + expect(diff.state).toBe('ok') + expect(diff.diff).toContain('diff --session a/src/App.jsx b/src/App.jsx') + expect(diff.diff).toContain('-export default function App() { return
Old
}') + expect(diff.diff).toContain('+export default function App() { return
New
}') + }) + + it('rejects traversal attempts for file, diff, and tree access', async () => { + const repoDir = await createGitWorkspace() + const service = new WorkspaceService(async () => repoDir) + + await expect(service.readFile('session-1', '../outside.txt')).rejects.toThrow(/outside workspace/) + await expect(service.getDiff('session-1', '../outside.txt')).resolves.toMatchObject({ + state: 'error', + path: '../outside.txt', + }) + await expect(service.readTree('session-1', '../outside')).rejects.toThrow(/outside workspace/) + }) + + it('rejects symlink targets that escape the workspace root', async () => { + const workDir = await makeTempDir('workspace-service-symlink-') + const outsideDir = await makeTempDir('workspace-service-symlink-outside-') + const outsideFile = path.join(outsideDir, 'secret.txt') + await fs.writeFile(outsideFile, 'top secret\n') + await fs.symlink(outsideFile, path.join(workDir, 'escape.txt')) + + const service = new WorkspaceService(async () => workDir) + + await expect(service.readFile('session-1', 'escape.txt')).rejects.toThrow(/outside workspace/) + }) + + it('returns error for an untracked symlink that escapes the workspace root', async () => { + const repoDir = await makeTempDir('workspace-service-symlink-git-') + const outsideDir = await makeTempDir('workspace-service-symlink-git-outside-') + const outsideFile = path.join(outsideDir, 'secret.txt') + + git(repoDir, 'init') + git(repoDir, 'config', 'user.email', 'workspace-service@example.com') + git(repoDir, 'config', 'user.name', 'Workspace Service') + await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'tracked\n') + git(repoDir, 'add', 'tracked.txt') + git(repoDir, 'commit', '-m', 'initial') + + await fs.writeFile(outsideFile, 'top secret\n') + await fs.symlink(outsideFile, path.join(repoDir, 'escape.txt')) + + const service = new WorkspaceService(async () => repoDir) + + const status = await service.getStatus('session-1') + expect(status.state).toBe('error') + expect(status.error).toMatch(/outside workspace/) + + await expect(service.getDiff('session-1', 'escape.txt')).resolves.toMatchObject({ + state: 'error', + path: 'escape.txt', + }) + const diffOutcome = await service.getDiff('session-1', 'escape.txt') + expect(diffOutcome.error).toMatch(/outside workspace/) + }) + + it('returns explicit readFile states for text, binary, too-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.txt'), Buffer.alloc(ONE_MIB + 1, 'a')) + await fs.mkdir(path.join(workDir, 'folder')) + + await expect(service.readFile('session-1', 'note.ts')).resolves.toMatchObject({ + state: 'ok', + language: 'typescript', + size: 25, + content: 'export const answer = 42\n', + }) + await expect(service.readFile('session-1', 'binary.bin')).resolves.toMatchObject({ + state: 'binary', + language: 'binary', + size: 4, + }) + await expect(service.readFile('session-1', 'image.png')).resolves.toMatchObject({ + state: 'ok', + previewType: 'image', + language: 'image', + mimeType: 'image/png', + dataUrl: 'data:image/png;base64,iVBORwA=', + size: 5, + }) + await expect(service.readFile('session-1', 'large.txt')).resolves.toMatchObject({ + state: 'too_large', + language: 'text', + size: ONE_MIB + 1, + }) + await expect(service.readFile('session-1', 'missing.txt')).resolves.toMatchObject({ + state: 'missing', + }) + await expect(service.readFile('session-1', 'folder')).resolves.toMatchObject({ + state: 'missing', + }) + }) + + it('lists a single directory level with dotfiles excluded and directories first', async () => { + const workDir = await makeTempDir('workspace-service-tree-') + const service = new WorkspaceService(async () => workDir) + + await fs.mkdir(path.join(workDir, 'b-dir')) + await fs.mkdir(path.join(workDir, 'a-dir')) + await fs.mkdir(path.join(workDir, 'a-dir', 'inner')) + await fs.writeFile(path.join(workDir, 'a-dir', 'note.txt'), 'nested\n') + await fs.writeFile(path.join(workDir, 'z-file.txt'), 'root file\n') + await fs.writeFile(path.join(workDir, '.hidden.txt'), 'ignore\n') + + await expect(service.readTree('session-1')).resolves.toMatchObject({ + state: 'ok', + path: '', + entries: [ + { name: 'a-dir', path: 'a-dir', isDirectory: true }, + { name: 'b-dir', path: 'b-dir', isDirectory: true }, + { name: 'z-file.txt', path: 'z-file.txt', isDirectory: false }, + ], + }) + + await expect(service.readTree('session-1', 'a-dir')).resolves.toMatchObject({ + state: 'ok', + path: 'a-dir', + entries: [ + { name: 'inner', path: 'a-dir/inner', isDirectory: true }, + { name: 'note.txt', path: 'a-dir/note.txt', isDirectory: false }, + ], + }) + }) + + it('returns diffs for modified, added, deleted, and untracked files', async () => { + const repoDir = await createGitWorkspace() + const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null) + + const modified = await service.getDiff('session-1', 'tracked.txt') + expect(modified.state).toBe('ok') + expect(modified.diff).toContain('tracked.txt') + expect(modified.diff.length).toBeGreaterThan(0) + + const added = await service.getDiff('session-1', 'new.txt') + expect(added.state).toBe('ok') + expect(added.diff).toContain('new.txt') + expect(added.diff.length).toBeGreaterThan(0) + + const deleted = await service.getDiff('session-1', 'deleted.txt') + expect(deleted.state).toBe('ok') + expect(deleted.diff).toContain('deleted.txt') + expect(deleted.diff.length).toBeGreaterThan(0) + + const untracked = await service.getDiff('session-1', 'untracked.txt') + expect(untracked.state).toBe('ok') + expect(untracked.diff).toContain('untracked.txt') + expect(untracked.diff.length).toBeGreaterThan(0) + + await expect(service.getDiff('session-1', 'clean.txt')).resolves.toMatchObject({ + state: 'missing', + path: 'clean.txt', + }) + + const nonGitDir = await makeTempDir('workspace-service-diff-non-git-') + const nonGitService = new WorkspaceService(async () => nonGitDir) + await expect(nonGitService.getDiff('session-1', 'whatever.txt')).resolves.toMatchObject({ + state: 'not_git_repo', + path: 'whatever.txt', + }) + }) + + it('returns explicit error state when git status fails instead of ok-empty', async () => { + const repoDir = await createGitWorkspace() + const service = new WorkspaceService(async () => repoDir) as WorkspaceService & { + runGit: (workDir: string, args: string[]) => Promise<{ + stdout: string + stderr: string + code: number + }> + } + + service.runGit = async (workDir, args) => { + if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') { + return { stdout: `${workDir}\n`, stderr: '', code: 0 } + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') { + return { stdout: 'main\n', stderr: '', code: 0 } + } + if (args[0] === 'status') { + return { stdout: '', stderr: 'fatal: synthetic git failure', code: 1 } + } + return { stdout: '', stderr: 'unexpected call', code: 1 } + } + + await expect(service.getStatus('session-1')).resolves.toMatchObject({ + state: 'error', + isGitRepo: true, + }) + + const result = await service.getStatus('session-1') + expect(result.state).toBe('error') + expect(result.changedFiles).toEqual([]) + expect(result.error).toContain('Failed to read git status') + expect(result.error).toContain('synthetic git failure') + }) + + it('reads tracked diff stats in one bulk git call', async () => { + const repoDir = await makeTempDir('workspace-service-bulk-stats-') + await fs.writeFile(path.join(repoDir, 'a.txt'), 'a\n') + await fs.writeFile(path.join(repoDir, 'b.txt'), 'b\n') + const diffStatCalls: string[][] = [] + const service = new WorkspaceService(async () => repoDir) as WorkspaceService & { + runGit: (workDir: string, args: string[]) => Promise<{ + stdout: string + stderr: string + code: number + }> + } + + service.runGit = async (_workDir, args) => { + if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') { + return { stdout: `${repoDir}\n`, stderr: '', code: 0 } + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') { + return { stdout: 'main\n', stderr: '', code: 0 } + } + if (args[0] === 'status') { + return { stdout: ' M a.txt\0 M b.txt\0', stderr: '', code: 0 } + } + if (args[0] === 'diff' && args.includes('--numstat')) { + diffStatCalls.push(args) + return { stdout: '1\t0\ta.txt\n2\t3\tb.txt\n', stderr: '', code: 0 } + } + return { stdout: '', stderr: `unexpected git call: ${args.join(' ')}`, code: 1 } + } + + const result = await service.getStatus('session-1') + + expect(result.state).toBe('ok') + expect(diffStatCalls).toHaveLength(1) + expect(result.changedFiles).toEqual([ + { path: 'a.txt', oldPath: undefined, status: 'modified', additions: 1, deletions: 0 }, + { path: 'b.txt', oldPath: undefined, status: 'modified', additions: 2, deletions: 3 }, + ]) + }) +}) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 155289b4..c7766c78 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -18,12 +18,18 @@ import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { getSlashCommands } from '../ws/handler.js' import { getCommandName } from '../../commands.js' import { getSkillDirCommands } from '../../skills/loadSkillsDir.js' +import { WorkspaceService } from '../services/workspaceService.js' import { executeSessionRewind, previewSessionRewind, type RewindTargetSelector, } from '../services/sessionRewindService.js' +const workspaceService = new WorkspaceService(async (sessionId) => ( + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId) +), async (sessionId) => sessionService.getSessionMessages(sessionId)) + export async function handleSessionsApi( req: Request, url: URL, @@ -109,6 +115,16 @@ export async function handleSessionsApi( return await getSessionInspection(sessionId, url) } + if (subResource === 'workspace') { + if (req.method !== 'GET') { + return Response.json( + { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, + { status: 405 } + ) + } + return await handleSessionWorkspaceRoute(sessionId, url, segments[4]) + } + // Route to conversations handler if sub-resource is 'chat' if (subResource === 'chat') { // This is handled by the conversations API, but in case the router @@ -174,6 +190,36 @@ async function getSessionMessages(sessionId: string): Promise { return Response.json({ messages }) } +async function handleSessionWorkspaceRoute( + sessionId: string, + url: URL, + workspaceResource?: string, +): Promise { + await requireSessionWorkspace(sessionId) + + switch (workspaceResource) { + case 'status': + return Response.json(await workspaceService.getStatus(sessionId)) + case 'tree': + return await runWorkspaceRequest(() => workspaceService.readTree( + sessionId, + url.searchParams.get('path') || '', + )) + case 'file': + return await runWorkspaceRequest(() => workspaceService.readFile( + sessionId, + requireWorkspacePath(url, 'file'), + )) + case 'diff': + return await runWorkspaceDiffRequest(() => workspaceService.getDiff( + sessionId, + requireWorkspacePath(url, 'diff'), + )) + default: + throw ApiError.notFound(`Unknown workspace resource: ${workspaceResource || 'workspace'}`) + } +} + async function createSession(req: Request): Promise { let body: { workDir?: string } try { @@ -190,6 +236,61 @@ async function createSession(req: Request): Promise { return Response.json(result, { status: 201 }) } +async function requireSessionWorkspace(sessionId: string): Promise { + const workDir = + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId) + + if (!workDir) { + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + + return workDir +} + +function requireWorkspacePath(url: URL, route: 'file' | 'diff'): string { + const filePath = url.searchParams.get('path') + if (!filePath) { + throw ApiError.badRequest(`path query parameter is required for workspace ${route}`) + } + return filePath +} + +async function runWorkspaceRequest(operation: () => Promise): Promise { + try { + return Response.json(await operation()) + } catch (error) { + if (isOutsideWorkspaceError(error)) { + throw new ApiError(403, error.message, 'FORBIDDEN') + } + if (isSessionNotFoundError(error)) { + throw ApiError.notFound(error.message) + } + throw error + } +} + +async function runWorkspaceDiffRequest( + operation: () => Promise, +): Promise { + const result = await runWorkspaceRequest(operation) + const body = await result.clone().json() as T + + if (body.state === 'error' && typeof body.error === 'string' && body.error.includes('outside workspace')) { + throw new ApiError(403, body.error, 'FORBIDDEN') + } + + return result +} + +function isOutsideWorkspaceError(error: unknown): error is Error { + return error instanceof Error && error.message.includes('outside workspace') +} + +function isSessionNotFoundError(error: unknown): error is Error { + return error instanceof Error && error.message.startsWith('Session not found:') +} + async function deleteSession(sessionId: string): Promise { await sessionService.deleteSession(sessionId) return Response.json({ ok: true }) diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts new file mode 100644 index 00000000..7d954d6f --- /dev/null +++ b/src/server/services/workspaceService.ts @@ -0,0 +1,1483 @@ +import * as fs from 'node:fs/promises' +import { execFile as execFileCallback } from 'node:child_process' +import * as path from 'node:path' +import { promisify } from 'node:util' +import type { MessageEntry } from './sessionService.js' + +const MAX_PREVIEW_BYTES = 1024 * 1024 +const MAX_UNTRACKED_STAT_BYTES = 256 * 1024 +const GIT_TIMEOUT_MS = 5_000 +const MAX_GIT_BUFFER_BYTES = 2_000_000 +const execFile = promisify(execFileCallback) + +const LANGUAGE_MAP: Record = { + cjs: 'javascript', + css: 'css', + go: 'go', + html: 'html', + java: 'java', + js: 'javascript', + json: 'json', + jsx: 'jsx', + md: 'markdown', + mjs: 'javascript', + py: 'python', + rs: 'rust', + sh: 'bash', + sql: 'sql', + ts: 'typescript', + tsx: 'tsx', + txt: 'text', + xml: 'xml', + yaml: 'yaml', + yml: 'yaml', +} + +const IMAGE_MIME_BY_EXTENSION: Record = { + apng: 'image/apng', + avif: 'image/avif', + gif: 'image/gif', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + svg: 'image/svg+xml', + webp: 'image/webp', +} + +export type WorkspaceFileStatus = + | 'modified' + | 'added' + | 'deleted' + | 'renamed' + | 'untracked' + | 'copied' + | 'type_changed' + | 'unknown' + +export type WorkspaceChangedFile = { + path: string + oldPath?: string + status: WorkspaceFileStatus + additions: number + deletions: number +} + +export type WorkspaceStatusResult = { + state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error' + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: WorkspaceChangedFile[] + error?: string +} + +export type WorkspaceReadFileResult = { + state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error' + path: string + previewType?: 'text' | 'image' + content?: string + dataUrl?: string + mimeType?: string + language: string + size: number + error?: string +} + +export type WorkspaceTreeEntry = { + name: string + path: string + isDirectory: boolean +} + +export type WorkspaceTreeResult = { + state: 'ok' | 'missing' | 'error' + path: string + entries: WorkspaceTreeEntry[] + error?: string +} + +export type WorkspaceDiffResult = { + state: 'ok' | 'missing' | 'not_git_repo' | 'error' + path: string + diff?: string + error?: string +} + +type StatusEntry = { + path: string + oldPath?: string + code: string + status: WorkspaceFileStatus +} + +type ScopedStatusEntry = { + repoPath: string + repoOldPath?: string + path: string + oldPath?: string + status: WorkspaceFileStatus + absolutePath: string + canonicalWorkspaceRoot: string +} + +type GitRepoInfo = + | { + kind: 'not_git_repo' + } + | { + kind: 'ok' + repoRoot: string + branch: string | null + } + | { + kind: 'error' + message: string + } + +type WorkspacePathResolution = { + requestedPath: string + relativePath: string + absolutePath: string + workspaceRoot: string + canonicalWorkspaceRoot: string + canonicalTargetPath: string +} + +type WorkspaceStatResult = + | { + kind: 'ok' + stat: Awaited> + } + | { + kind: 'missing' + } + | { + kind: 'error' + message: string + } + +type GitCommandResult = { + stdout: string + stderr: string + code: number +} + +type DiffStatsResult = + | { + kind: 'ok' + additions: number + deletions: number + } + | { + kind: 'error' + message: string + } + +type DiffStatsByRepoPathResult = + | { + kind: 'ok' + statsByRepoPath: Map + } + | { + kind: 'error' + message: string + } + +type UntrackedDiffResult = + | { + kind: 'ok' + diff: string + } + | { + kind: 'missing' + } + | { + kind: 'error' + message: string + } + +type SessionFileChange = WorkspaceChangedFile & { + diff?: string +} + +export function parseStatus(code: string): WorkspaceFileStatus { + const x = code[0] ?? ' ' + const y = code[1] ?? ' ' + + if (code === '??') return 'untracked' + if (x === 'R' || y === 'R') return 'renamed' + if (x === 'C' || y === 'C') return 'copied' + if (x === 'T' || y === 'T') return 'type_changed' + if (x === 'D' || y === 'D') return 'deleted' + if (x === 'A' || y === 'A') return 'added' + if (x === 'M' || y === 'M') return 'modified' + return 'unknown' +} + +export class WorkspaceService { + constructor( + private readonly resolveSessionWorkDir: ( + sessionId: string, + ) => Promise, + private readonly resolveSessionMessages: ( + sessionId: string, + ) => Promise = async () => [], + ) {} + + async getStatus(sessionId: string): Promise { + const workDir = await this.requireWorkDir(sessionId) + const workspaceInfo = await this.getWorkspaceRoot(workDir) + if (workspaceInfo.kind === 'missing') { + return { + state: 'missing_workdir', + workDir, + repoName: null, + branch: null, + isGitRepo: false, + changedFiles: [], + } + } + if (workspaceInfo.kind === 'error') { + return { + state: 'error', + workDir, + repoName: null, + branch: null, + isGitRepo: false, + changedFiles: [], + error: workspaceInfo.message, + } + } + + const repoInfo = await this.getGitRepoInfo(workDir) + const sessionChanges = await this.getSessionFileChanges( + sessionId, + workspaceInfo.workspaceRoot, + ) + + if (sessionChanges.length > 0) { + sessionChanges.sort((a, b) => a.path.localeCompare(b.path)) + return { + state: 'ok', + workDir, + repoName: repoInfo.kind === 'ok' + ? path.basename(repoInfo.repoRoot) + : path.basename(workspaceInfo.workspaceRoot), + branch: repoInfo.kind === 'ok' ? repoInfo.branch : null, + isGitRepo: repoInfo.kind === 'ok', + changedFiles: sessionChanges.map(({ diff: _diff, ...change }) => change), + } + } + + if (repoInfo.kind === 'not_git_repo') { + return { + state: 'ok', + workDir, + repoName: path.basename(workspaceInfo.workspaceRoot), + branch: null, + isGitRepo: false, + changedFiles: [], + } + } + if (repoInfo.kind === 'error') { + return { + state: 'error', + workDir, + repoName: null, + branch: null, + isGitRepo: false, + changedFiles: [], + error: repoInfo.message, + } + } + + const statusEntries = await this.getStatusEntries(repoInfo.repoRoot) + if (statusEntries.kind === 'error') { + return { + state: 'error', + workDir, + repoName: path.basename(repoInfo.repoRoot), + branch: repoInfo.branch, + isGitRepo: true, + changedFiles: [], + error: statusEntries.message, + } + } + const scopedEntries = this.scopeStatusEntries( + statusEntries.entries, + repoInfo.repoRoot, + workspaceInfo.canonicalWorkspaceRoot, + ) + const trackedStats = await this.getTrackedDiffStats(repoInfo.repoRoot, scopedEntries) + if (trackedStats.kind === 'error') { + return { + state: 'error', + workDir, + repoName: path.basename(repoInfo.repoRoot), + branch: repoInfo.branch, + isGitRepo: true, + changedFiles: [], + error: trackedStats.message, + } + } + + const changedFiles = await Promise.all( + scopedEntries.map(async (entry) => { + const stats = entry.status === 'untracked' + ? await this.getDiffStats(repoInfo.repoRoot, entry) + : { + kind: 'ok' as const, + ...(trackedStats.statsByRepoPath.get(entry.repoPath) ?? { + additions: 0, + deletions: 0, + }), + } + + if (stats.kind === 'error') { + throw new Error(stats.message) + } + + return { + path: entry.path, + oldPath: entry.oldPath, + status: entry.status, + additions: stats.additions, + deletions: stats.deletions, + } satisfies WorkspaceChangedFile + }), + ).catch((error) => error as Error) + + if (changedFiles instanceof Error) { + return { + state: 'error', + workDir, + repoName: path.basename(repoInfo.repoRoot), + branch: repoInfo.branch, + isGitRepo: true, + changedFiles: [], + error: changedFiles.message, + } + } + + changedFiles.sort((a, b) => a.path.localeCompare(b.path)) + + return { + state: 'ok', + workDir, + repoName: path.basename(repoInfo.repoRoot), + branch: repoInfo.branch, + isGitRepo: true, + changedFiles, + } + } + + async readFile( + sessionId: string, + filePath: string, + ): Promise { + const resolvedPath = await this.resolveWorkspacePath(sessionId, filePath) + + const stat = await this.safeStat(resolvedPath.absolutePath) + if (stat.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + language: this.detectLanguage(resolvedPath.absolutePath), + size: 0, + error: stat.message, + } + } + if (stat.kind === 'missing' || !stat.stat.isFile()) { + return { + state: 'missing', + path: resolvedPath.relativePath, + language: this.detectLanguage(resolvedPath.absolutePath), + size: 0, + } + } + + 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, + } + } + + let content: Buffer + try { + content = await fs.readFile(resolvedPath.absolutePath) + } catch (error) { + return { + state: 'error', + path: resolvedPath.relativePath, + language, + size: stat.stat.size, + error: this.formatFsError( + 'Failed to read workspace file', + resolvedPath.absolutePath, + error, + ), + } + } + const imageMimeType = this.detectImageMimeType(resolvedPath.absolutePath) + if (imageMimeType) { + return { + state: 'ok', + path: resolvedPath.relativePath, + previewType: 'image', + dataUrl: `data:${imageMimeType};base64,${content.toString('base64')}`, + mimeType: imageMimeType, + language: 'image', + size: stat.stat.size, + } + } + + if (content.includes(0)) { + return { + state: 'binary', + path: resolvedPath.relativePath, + language: 'binary', + size: stat.stat.size, + } + } + + return { + state: 'ok', + path: resolvedPath.relativePath, + previewType: 'text', + content: content.toString('utf8'), + language, + size: stat.stat.size, + } + } + + async readTree( + sessionId: string, + treePath = '', + ): Promise { + const resolvedPath = await this.resolveWorkspacePath(sessionId, treePath) + + const stat = await this.safeStat(resolvedPath.absolutePath) + if (stat.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + entries: [], + error: stat.message, + } + } + if (stat.kind === 'missing' || !stat.stat.isDirectory()) { + return { state: 'missing', path: resolvedPath.relativePath, entries: [] } + } + + let entries: import('node:fs').Dirent[] + try { + entries = await fs.readdir(resolvedPath.absolutePath, { withFileTypes: true }) + } catch (error) { + return { + state: 'error', + path: resolvedPath.relativePath, + entries: [], + error: this.formatFsError( + 'Failed to read workspace directory', + resolvedPath.absolutePath, + error, + ), + } + } + const visibleEntries = entries + .filter((entry) => !entry.name.startsWith('.')) + .sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) { + return a.isDirectory() ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + .map((entry) => ({ + name: entry.name, + path: this.normalizeRelativePath( + path.relative( + resolvedPath.workspaceRoot, + path.join(resolvedPath.absolutePath, entry.name), + ), + ), + isDirectory: entry.isDirectory(), + })) + + return { + state: 'ok', + path: resolvedPath.relativePath, + entries: visibleEntries, + } + } + + async getDiff( + sessionId: string, + filePath: string, + ): Promise { + let resolvedPath: WorkspacePathResolution + try { + resolvedPath = await this.resolveWorkspacePath(sessionId, filePath) + } catch (error) { + return { + state: 'error', + path: this.normalizeRequestedPath(filePath), + error: error instanceof Error ? error.message : String(error), + } + } + + const sessionDiff = await this.getSessionDiff(sessionId, resolvedPath.relativePath) + if (sessionDiff) { + return { state: 'ok', path: resolvedPath.relativePath, diff: sessionDiff } + } + + const repoInfo = await this.getGitRepoInfo(resolvedPath.workspaceRoot) + if (repoInfo.kind === 'not_git_repo') { + return { state: 'not_git_repo', path: resolvedPath.relativePath } + } + if (repoInfo.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + error: repoInfo.message, + } + } + + const statusEntries = await this.getStatusEntries(repoInfo.repoRoot) + if (statusEntries.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + error: statusEntries.message, + } + } + const scopedEntries = this.scopeStatusEntries( + statusEntries.entries, + repoInfo.repoRoot, + resolvedPath.canonicalWorkspaceRoot, + ) + const repoRelativePath = this.toRepoRelativePath( + repoInfo.repoRoot, + resolvedPath.canonicalTargetPath, + ) + + const statusEntry = scopedEntries.find( + (entry) => + entry.repoPath === repoRelativePath || + entry.repoOldPath === repoRelativePath, + ) + + if (!statusEntry) { + return { state: 'missing', path: resolvedPath.relativePath } + } + + if (statusEntry.status === 'untracked') { + const diff = await this.buildUntrackedDiff( + resolvedPath.canonicalTargetPath, + resolvedPath.relativePath, + ) + if (diff.kind === 'missing') { + return { state: 'missing', path: resolvedPath.relativePath } + } + if (diff.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + error: diff.message, + } + } + return { state: 'ok', path: resolvedPath.relativePath, diff: diff.diff } + } + + const targetPath = statusEntry.repoPath + const diff = await this.runGitDiff(repoInfo.repoRoot, targetPath) + if (diff.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + error: diff.message, + } + } + if (!diff.diff.trim()) { + return { state: 'missing', path: resolvedPath.relativePath } + } + + return { state: 'ok', path: resolvedPath.relativePath, diff: diff.diff } + } + + private async getSessionDiff( + sessionId: string, + relativePath: string, + ): Promise { + const workDir = await this.requireWorkDir(sessionId) + const changes = await this.getSessionFileChanges(sessionId, workDir) + const change = changes.find((entry) => entry.path === relativePath) + if (!change) return null + if (change.diff?.trim()) return change.diff + + const file = await this.readFile(sessionId, relativePath) + if (file.state !== 'ok' || file.previewType === 'image' || typeof file.content !== 'string') { + return null + } + return this.buildSyntheticDiff('/dev/null', relativePath, '', file.content) + } + + private async getSessionFileChanges( + sessionId: string, + workspaceRoot: string, + ): Promise { + let messages: MessageEntry[] + try { + messages = await this.resolveSessionMessages(sessionId) + } catch { + return [] + } + + const changes = new Map() + + for (const message of messages) { + if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue + + for (const block of message.content) { + if (!block || typeof block !== 'object') continue + const record = block as Record + if (record.type !== 'tool_use' || typeof record.name !== 'string') continue + const input = record.input + if (!input || typeof input !== 'object') continue + + for (const change of this.extractSessionChangesFromTool( + record.name, + input as Record, + workspaceRoot, + )) { + const existing = changes.get(change.path) + if (!existing) { + changes.set(change.path, change) + continue + } + + changes.set(change.path, { + ...existing, + status: existing.status === 'added' ? existing.status : change.status, + additions: existing.additions + change.additions, + deletions: existing.deletions + change.deletions, + diff: [existing.diff, change.diff].filter(Boolean).join('\n'), + }) + } + } + } + + return [...changes.values()] + } + + private extractSessionChangesFromTool( + toolName: string, + input: Record, + workspaceRoot: string, + ): SessionFileChange[] { + const normalizedToolName = toolName.toLowerCase() + if (normalizedToolName === 'write') { + const filePath = this.resolveSessionToolPath(input.file_path ?? input.path, workspaceRoot) + if (!filePath) return [] + const content = typeof input.content === 'string' ? input.content : '' + return [{ + path: filePath, + status: 'added', + additions: this.countChangedLines(content), + deletions: 0, + diff: this.buildSyntheticDiff('/dev/null', filePath, '', content), + }] + } + + if (normalizedToolName === 'edit') { + const filePath = this.resolveSessionToolPath(input.file_path ?? input.path, workspaceRoot) + if (!filePath) return [] + return [this.buildEditSessionChange(filePath, input)] + } + + if (normalizedToolName === 'multiedit') { + const filePath = this.resolveSessionToolPath(input.file_path ?? input.path, workspaceRoot) + if (!filePath || !Array.isArray(input.edits)) return [] + return input.edits + .filter((edit): edit is Record => !!edit && typeof edit === 'object') + .map((edit) => this.buildEditSessionChange(filePath, edit)) + } + + if (normalizedToolName === 'notebookedit') { + const filePath = this.resolveSessionToolPath( + input.notebook_path ?? input.file_path ?? input.path, + workspaceRoot, + ) + if (!filePath) return [] + const oldString = typeof input.old_source === 'string' ? input.old_source : '' + const newString = typeof input.new_source === 'string' ? input.new_source : '' + return [{ + path: filePath, + status: oldString ? 'modified' : 'added', + additions: this.countChangedLines(newString), + deletions: this.countChangedLines(oldString), + diff: this.buildSyntheticDiff(filePath, filePath, oldString, newString), + }] + } + + if (normalizedToolName === 'apply_patch') { + return this.extractApplyPatchSessionChanges(input.patch, workspaceRoot) + } + + return [] + } + + private buildEditSessionChange( + filePath: string, + input: Record, + ): SessionFileChange { + const oldString = typeof input.old_string === 'string' ? input.old_string : '' + const newString = typeof input.new_string === 'string' ? input.new_string : '' + return { + path: filePath, + status: oldString ? 'modified' : 'added', + additions: this.countChangedLines(newString), + deletions: this.countChangedLines(oldString), + diff: this.buildSyntheticDiff(filePath, filePath, oldString, newString), + } + } + + private extractApplyPatchSessionChanges( + patch: unknown, + workspaceRoot: string, + ): SessionFileChange[] { + if (typeof patch !== 'string') return [] + const changes: SessionFileChange[] = [] + + for (const line of patch.split('\n')) { + const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) + if (!match?.[1]) continue + const filePath = this.resolveSessionToolPath(match[1], workspaceRoot) + if (!filePath) continue + const status: WorkspaceFileStatus = line.includes('Add File') + ? 'added' + : line.includes('Delete File') + ? 'deleted' + : 'modified' + changes.push({ + path: filePath, + status, + additions: 0, + deletions: 0, + }) + } + + return changes + } + + private resolveSessionToolPath( + filePath: unknown, + workspaceRoot: string, + ): string | null { + if (typeof filePath !== 'string' || !filePath.trim()) return null + const absolutePath = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(workspaceRoot, filePath) + if (!this.isWithinRoot(absolutePath, workspaceRoot)) return null + return this.normalizeRelativePath(path.relative(workspaceRoot, absolutePath)) + } + + private countChangedLines(value: string): number { + if (!value) return 0 + return value.endsWith('\n') + ? value.split('\n').length - 1 + : value.split('\n').length + } + + private buildSyntheticDiff( + oldPath: string, + newPath: string, + oldContent: string, + newContent: string, + ): string { + const oldLines = oldContent ? oldContent.split('\n') : [] + const newLines = newContent ? newContent.split('\n') : [] + if (oldLines.at(-1) === '') oldLines.pop() + if (newLines.at(-1) === '') newLines.pop() + + return [ + `diff --session a/${oldPath} b/${newPath}`, + `--- ${oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}`}`, + `+++ b/${newPath}`, + `@@ -1,${oldLines.length} +1,${newLines.length} @@`, + ...oldLines.map((line) => `-${line}`), + ...newLines.map((line) => `+${line}`), + ].join('\n') + } + + private async requireWorkDir(sessionId: string): Promise { + const workDir = await this.resolveSessionWorkDir(sessionId) + if (!workDir) { + throw new Error(`Session not found: ${sessionId}`) + } + return path.resolve(workDir) + } + + private async getWorkspaceRoot( + workDir: string, + ): Promise< + | { kind: 'ok'; workspaceRoot: string; canonicalWorkspaceRoot: string } + | { kind: 'missing' } + | { kind: 'error'; message: string } + > { + const stat = await this.safeStat(workDir) + if (stat.kind === 'missing') { + return { kind: 'missing' } + } + if (stat.kind === 'error') { + return { kind: 'error', message: stat.message } + } + + try { + return { + kind: 'ok', + workspaceRoot: workDir, + canonicalWorkspaceRoot: await fs.realpath(workDir), + } + } catch (error) { + return { + kind: 'error', + message: this.formatFsError( + 'Failed to canonicalize workspace root', + workDir, + error, + ), + } + } + } + + private async resolveWorkspacePath( + sessionId: string, + requestedPath: string, + ): Promise { + const workDir = await this.requireWorkDir(sessionId) + const workspaceRoot = await this.getWorkspaceRoot(workDir) + if (workspaceRoot.kind === 'missing') { + throw new Error(`Workspace root is missing: ${workDir}`) + } + if (workspaceRoot.kind === 'error') { + throw new Error(workspaceRoot.message) + } + + const absolutePath = path.resolve(workDir, requestedPath || '.') + if (!this.isWithinRoot(absolutePath, workDir)) { + throw new Error(`Path is outside workspace: ${requestedPath}`) + } + + const canonicalTargetPath = await this.resolveCanonicalTargetPath( + workspaceRoot.canonicalWorkspaceRoot, + absolutePath, + requestedPath, + ) + + return { + absolutePath, + requestedPath, + workspaceRoot: workspaceRoot.workspaceRoot, + canonicalWorkspaceRoot: workspaceRoot.canonicalWorkspaceRoot, + canonicalTargetPath, + relativePath: this.normalizeRelativePath( + path.relative(workspaceRoot.workspaceRoot, absolutePath), + ), + } + } + + private async validateCanonicalWorkspacePath( + canonicalWorkspaceRoot: string, + absolutePath: string, + requestedPath: string, + ): Promise< + | { kind: 'ok'; canonicalTargetPath: string } + | { kind: 'error'; message: string } + > { + try { + return { + kind: 'ok', + canonicalTargetPath: await this.resolveCanonicalTargetPath( + canonicalWorkspaceRoot, + absolutePath, + requestedPath, + ), + } + } catch (error) { + return { + kind: 'error', + message: error instanceof Error ? error.message : String(error), + } + } + } + + private async resolveCanonicalTargetPath( + canonicalWorkspaceRoot: string, + absolutePath: string, + requestedPath: string, + ): Promise { + let probePath = absolutePath + const missingSuffix: string[] = [] + + for (;;) { + try { + const canonicalBase = await fs.realpath(probePath) + const canonicalTarget = path.resolve(canonicalBase, ...missingSuffix) + if (!this.isWithinRoot(canonicalTarget, canonicalWorkspaceRoot)) { + throw new Error(`Path is outside workspace: ${requestedPath}`) + } + return canonicalTarget + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { + if (probePath === canonicalWorkspaceRoot) { + const candidate = path.resolve(canonicalWorkspaceRoot, ...missingSuffix) + if (!this.isWithinRoot(candidate, canonicalWorkspaceRoot)) { + throw new Error(`Path is outside workspace: ${requestedPath}`) + } + return candidate + } + + missingSuffix.unshift(path.basename(probePath)) + const parentPath = path.dirname(probePath) + if (parentPath === probePath) { + throw err + } + probePath = parentPath + continue + } + + throw new Error( + this.formatFsError( + 'Failed to canonicalize workspace path', + absolutePath, + error, + ), + ) + } + } + } + + private isWithinRoot(targetPath: string, rootPath: string): boolean { + return targetPath === rootPath || targetPath.startsWith(`${rootPath}${path.sep}`) + } + + private normalizeRelativePath(filePath: string): string { + if (!filePath || filePath === '.') return '' + return filePath.split(path.sep).join('/') + } + + private normalizeRequestedPath(filePath: string): string { + if (!filePath) return '' + return filePath.split(path.sep).join('/') + } + + private scopeStatusEntries( + entries: StatusEntry[], + repoRoot: string, + workDir: string, + ): ScopedStatusEntry[] { + const workDirFromRepo = this.normalizeRelativePath(path.relative(repoRoot, workDir)) + + return entries.flatMap((entry) => { + const scopedPath = this.rebaseRepoPathToWorkspacePath(entry.path, workDirFromRepo) + if (scopedPath === null) { + return [] + } + + const scopedOldPath = entry.oldPath + ? this.rebaseRepoPathToWorkspacePath(entry.oldPath, workDirFromRepo) + : undefined + + return [{ + repoPath: entry.path, + repoOldPath: entry.oldPath, + path: scopedPath, + oldPath: scopedOldPath ?? undefined, + status: entry.status, + absolutePath: path.resolve(repoRoot, entry.path), + canonicalWorkspaceRoot: workDir, + }] + }) + } + + private rebaseRepoPathToWorkspacePath( + repoPath: string, + workDirFromRepo: string, + ): string | null { + const normalizedRepoPath = this.normalizeRelativePath(repoPath) + if (!workDirFromRepo) { + return normalizedRepoPath + } + + const rebasedPath = path.posix.relative(workDirFromRepo, normalizedRepoPath) + if ( + !rebasedPath || + rebasedPath === '.' || + rebasedPath === '..' || + rebasedPath.startsWith('../') + ) { + return null + } + + return rebasedPath + } + + private toRepoRelativePath( + repoRoot: string, + canonicalTargetPath: string, + ): string { + return this.normalizeRelativePath(path.relative(repoRoot, canonicalTargetPath)) + } + + private detectLanguage(filePath: string): string { + const ext = path.extname(filePath).slice(1).toLowerCase() + return LANGUAGE_MAP[ext] || 'text' + } + + private detectImageMimeType(filePath: string): string | null { + const ext = path.extname(filePath).slice(1).toLowerCase() + return IMAGE_MIME_BY_EXTENSION[ext] ?? null + } + + private async safeStat(targetPath: string): Promise { + try { + return { + kind: 'ok', + stat: await fs.stat(targetPath), + } + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { + return { kind: 'missing' } + } + return { + kind: 'error', + message: this.formatFsError('Failed to stat workspace path', targetPath, error), + } + } + } + + private async getGitRepoInfo(workDir: string): Promise { + const rootResult = await this.runGit(workDir, ['rev-parse', '--show-toplevel']) + + if (rootResult.code !== 0) { + const stderr = rootResult.stderr.trim() + if (stderr.includes('not a git repository')) { + return { kind: 'not_git_repo' } + } + return { + kind: 'error', + message: this.formatGitError( + 'Failed to inspect git repository', + ['rev-parse', '--show-toplevel'], + workDir, + rootResult, + ), + } + } + + let repoRoot: string + try { + repoRoot = await fs.realpath(path.resolve(rootResult.stdout.trim())) + } catch (error) { + return { + kind: 'error', + message: this.formatFsError( + 'Failed to canonicalize git repository root', + path.resolve(rootResult.stdout.trim()), + error, + ), + } + } + const branchResult = await this.runGit(workDir, [ + 'rev-parse', + '--abbrev-ref', + 'HEAD', + ]) + + return { + kind: 'ok', + repoRoot, + branch: + branchResult.code === 0 + ? branchResult.stdout.trim() || null + : null, + } + } + + private async getStatusEntries( + workDir: string, + ): Promise<{ kind: 'ok'; entries: StatusEntry[] } | { kind: 'error'; message: string }> { + const result = await this.runGit(workDir, [ + 'status', + '--porcelain=v1', + '-z', + '--untracked-files=all', + ]) + + if (result.code !== 0) { + return { + kind: 'error', + message: this.formatGitError( + 'Failed to read git status', + ['status', '--porcelain=v1', '-z', '--untracked-files=all'], + workDir, + result, + ), + } + } + + const parts = result.stdout.split('\0') + const entries: StatusEntry[] = [] + + for (let i = 0; i < parts.length; i++) { + const record = parts[i] + if (!record) continue + + const code = record.slice(0, 2) + const currentPath = this.normalizeRelativePath(record.slice(3)) + const status = parseStatus(code) + + if (status === 'renamed' || status === 'copied') { + const oldPath = this.normalizeRelativePath(parts[++i] || '') + entries.push({ + path: currentPath, + oldPath, + code, + status, + }) + continue + } + + entries.push({ + path: currentPath, + code, + status, + }) + } + + return { kind: 'ok', entries } + } + + private async getDiffStats( + workDir: string, + entry: ScopedStatusEntry, + ): Promise { + if (entry.status === 'untracked') { + const validatedPath = await this.validateCanonicalWorkspacePath( + entry.canonicalWorkspaceRoot, + entry.absolutePath, + entry.path, + ) + if (validatedPath.kind === 'error') { + return { kind: 'error', message: validatedPath.message } + } + + return this.getUntrackedStats(validatedPath.canonicalTargetPath) + } + + const result = await this.runGit(workDir, [ + 'diff', + '--numstat', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + entry.repoPath, + ]) + + if (result.code !== 0) { + return { + kind: 'error', + message: this.formatGitError( + 'Failed to read git diff stats', + [ + 'diff', + '--numstat', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + entry.repoPath, + ], + workDir, + result, + ), + } + } + + const line = result.stdout.trim().split('\n').find(Boolean) + if (!line) { + return { + kind: 'ok', + additions: 0, + deletions: 0, + } + } + + const [additions, deletions] = line.split('\t') + return { + kind: 'ok', + additions: additions === '-' ? 0 : parseInt(additions || '0', 10) || 0, + deletions: deletions === '-' ? 0 : parseInt(deletions || '0', 10) || 0, + } + } + + private async getTrackedDiffStats( + workDir: string, + entries: ScopedStatusEntry[], + ): Promise { + if (!entries.some((entry) => entry.status !== 'untracked')) { + return { kind: 'ok', statsByRepoPath: new Map() } + } + + const result = await this.runGit(workDir, [ + 'diff', + '--numstat', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + ]) + + if (result.code !== 0) { + return { + kind: 'error', + message: this.formatGitError( + 'Failed to read git diff stats', + [ + 'diff', + '--numstat', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + ], + workDir, + result, + ), + } + } + + const statsByRepoPath = new Map() + for (const line of result.stdout.trim().split('\n')) { + if (!line) continue + const [additions, deletions, repoPath] = line.split('\t') + if (!repoPath) continue + statsByRepoPath.set(this.normalizeRelativePath(repoPath), { + additions: additions === '-' ? 0 : parseInt(additions || '0', 10) || 0, + deletions: deletions === '-' ? 0 : parseInt(deletions || '0', 10) || 0, + }) + } + + return { kind: 'ok', statsByRepoPath } + } + + private async getUntrackedStats( + absolutePath: string, + ): Promise { + try { + const stat = await fs.stat(absolutePath) + if (!stat.isFile()) { + return { kind: 'ok', additions: 0, deletions: 0 } + } + if (stat.size > MAX_UNTRACKED_STAT_BYTES) { + return { kind: 'ok', additions: 0, deletions: 0 } + } + + const content = await fs.readFile(absolutePath, 'utf8') + return { + kind: 'ok', + additions: this.countTextLines(content), + deletions: 0, + } + } catch (error) { + return { + kind: 'error', + message: this.formatFsError( + 'Failed to read untracked workspace file', + absolutePath, + error, + ), + } + } + } + + private countTextLines(content: string): number { + if (!content) return 0 + const lines = content.split(/\r\n|\r|\n/) + if (lines[lines.length - 1] === '') { + lines.pop() + } + return lines.length + } + + private async runGitDiff( + workDir: string, + relativePath: string, + ): Promise<{ kind: 'ok'; diff: string } | { kind: 'error'; message: string }> { + const result = await this.runGit(workDir, [ + 'diff', + '--no-ext-diff', + '--binary', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + relativePath, + ]) + + if (result.code !== 0) { + return { + kind: 'error', + message: this.formatGitError( + 'Failed to read git diff', + [ + 'diff', + '--no-ext-diff', + '--binary', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + relativePath, + ], + workDir, + result, + ), + } + } + + return { kind: 'ok', diff: result.stdout } + } + + private async runGit( + workDir: string, + args: string[], + ): Promise { + try { + const result = await execFile('git', args, { + cwd: workDir, + timeout: GIT_TIMEOUT_MS, + maxBuffer: MAX_GIT_BUFFER_BYTES, + encoding: 'utf8', + }) + + return { + stdout: result.stdout, + stderr: result.stderr, + code: 0, + } + } catch (error) { + const err = error as NodeJS.ErrnoException & { + stdout?: string | Buffer + stderr?: string | Buffer + code?: number | string + } + + return { + stdout: + typeof err.stdout === 'string' + ? err.stdout + : Buffer.isBuffer(err.stdout) + ? err.stdout.toString('utf8') + : '', + stderr: + typeof err.stderr === 'string' + ? err.stderr + : Buffer.isBuffer(err.stderr) + ? err.stderr.toString('utf8') + : '', + code: typeof err.code === 'number' ? err.code : 1, + } + } + } + + private async buildUntrackedDiff( + absolutePath: string, + relativePath: string, + ): Promise { + const stat = await this.safeStat(absolutePath) + if (stat.kind === 'error') { + return { kind: 'error', message: stat.message } + } + if (stat.kind === 'missing' || !stat.stat.isFile()) { + return { kind: 'missing' } + } + + let buffer: Buffer + try { + buffer = await fs.readFile(absolutePath) + } catch (error) { + return { + kind: 'error', + message: this.formatFsError( + 'Failed to read untracked workspace file', + absolutePath, + error, + ), + } + } + if (buffer.includes(0)) { + return { + kind: 'ok', + diff: [ + `diff --git a/${relativePath} b/${relativePath}`, + 'new file mode 100644', + `Binary files /dev/null and b/${relativePath} differ`, + '', + ].join('\n'), + } + } + + const content = buffer.toString('utf8') + const lines = content.split(/\r\n|\r|\n/) + if (lines[lines.length - 1] === '') { + lines.pop() + } + + const hunkLines = lines.map((line) => `+${line}`) + if (hunkLines.length === 0) { + hunkLines.push('+') + } + + return { + kind: 'ok', + diff: [ + `diff --git a/${relativePath} b/${relativePath}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${relativePath}`, + `@@ -0,0 +1,${hunkLines.length} @@`, + ...hunkLines, + '', + ].join('\n'), + } + } + + private formatFsError( + prefix: string, + targetPath: string, + error: unknown, + ): string { + const err = error as NodeJS.ErrnoException + const code = err.code ? `${err.code}: ` : '' + return `${prefix} (${targetPath}): ${code}${err.message || 'unknown error'}` + } + + private formatGitError( + prefix: string, + args: string[], + workDir: string, + result: GitCommandResult, + ): string { + const stderr = result.stderr.trim() || result.stdout.trim() || 'unknown git failure' + return `${prefix} (git ${args.join(' ')} in ${workDir}): ${stderr}` + } +}