mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: keep workspace review surfaces usable across sessions
Workspace inspection now shares the same diff renderer between chat change cards and panel previews, supports scoped preview-tab closing, confirms checkpoint undo, resolves rewind checkpoints from the prompt cwd, and removes light-only workspace chrome so dark theme remains coherent. Constraint: Workspace review must keep working for existing transcript sessions and non-git session-derived changes. Rejected: Keep a separate chat diff preview | it drifted from panel rendering and dark theme behavior. Confidence: high Scope-risk: moderate Directive: Keep chat and workspace diff rendering shared so theme and truncation behavior do not diverge again. Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx src/components/chat/MessageList.test.tsx src/stores/workspacePanelStore.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: bun test src/server/__tests__/sessions.test.ts Tested: Browser dark workspace preview at http://127.0.0.1:59468/ with src/App.jsx diff Not-tested: macOS packaged app build
This commit is contained in:
parent
339a06b281
commit
aaf25159af
@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { sessionsApi, type SessionRewindResponse } from '../../api/sessions'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface'
|
||||
|
||||
type DiffPreviewState = {
|
||||
loading: boolean
|
||||
@ -75,7 +76,7 @@ export function CurrentTurnChangeCard({
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mx-auto mb-5 max-w-[760px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
|
||||
className="mx-auto mb-5 w-full max-w-[860px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
|
||||
aria-label={t('chat.turnChangesCardLabel')}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
@ -142,7 +143,11 @@ export function CurrentTurnChangeCard({
|
||||
{diffState.error}
|
||||
</div>
|
||||
) : diffState?.diff ? (
|
||||
<DiffPreview diff={diffState.diff} />
|
||||
<WorkspaceDiffSurface
|
||||
value={diffState.diff}
|
||||
path={filePath}
|
||||
className="max-h-[430px] overflow-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('chat.turnChangesDiffUnavailable')}
|
||||
@ -164,28 +169,6 @@ export function CurrentTurnChangeCard({
|
||||
)
|
||||
}
|
||||
|
||||
function DiffPreview({ diff }: { diff: string }) {
|
||||
const lines = diff.split('\n').slice(0, 220)
|
||||
return (
|
||||
<pre className="max-h-[360px] overflow-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)] p-3 font-mono text-[12px] leading-5 text-[var(--color-code-fg)]">
|
||||
{lines.map((line, index) => {
|
||||
const colorClass = line.startsWith('+') && !line.startsWith('+++')
|
||||
? 'text-[var(--color-success)]'
|
||||
: line.startsWith('-') && !line.startsWith('---')
|
||||
? 'text-[var(--color-error)]'
|
||||
: line.startsWith('@@')
|
||||
? 'text-[var(--color-brand)]'
|
||||
: 'text-[var(--color-code-fg)]'
|
||||
return (
|
||||
<div key={`${index}-${line}`} className={`${colorClass} whitespace-pre`}>
|
||||
{line || ' '}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function relativizeWorkspacePath(filePath: string, workDir: string | null): string {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||
if (!workDir || !normalizedPath.startsWith('/')) return normalizedPath
|
||||
|
||||
@ -744,6 +744,7 @@ describe('MessageList nested tool calls', () => {
|
||||
render(<MessageList />)
|
||||
|
||||
expect(await screen.findByText('2 files changed')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Current turn changed files').className).toContain('w-full max-w-[860px]')
|
||||
expect(screen.getByText('+12')).toBeTruthy()
|
||||
expect(screen.getByText('-4')).toBeTruthy()
|
||||
expect(screen.getByText('src/App.tsx')).toBeTruthy()
|
||||
@ -812,11 +813,12 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/App.tsx' }))
|
||||
|
||||
expect(await screen.findByText('+new')).toBeTruthy()
|
||||
const diffSurface = await screen.findByTestId('workspace-code')
|
||||
expect(diffSurface.textContent).toContain('+new')
|
||||
expect(sessionsApi.getWorkspaceDiff).toHaveBeenCalledWith(ACTIVE_TAB, 'src/App.tsx')
|
||||
})
|
||||
|
||||
it('undoes the current turn from the change card using checkpoint rewind', async () => {
|
||||
it('confirms before undoing the current turn from the change card', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind')
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
@ -889,6 +891,12 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Undo current turn changes' }))
|
||||
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledTimes(1)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' })
|
||||
expect(within(dialog).getByText('This will rewind the latest assistant response and restore tracked files for this turn.')).toBeTruthy()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-1',
|
||||
|
||||
@ -21,6 +21,7 @@ import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import { Modal } from '../shared/Modal'
|
||||
import { Button } from '../shared/Button'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
@ -204,6 +205,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const [currentTurnError, setCurrentTurnError] = useState<string | null>(null)
|
||||
const [isLoadingCurrentTurnPreview, setIsLoadingCurrentTurnPreview] = useState(false)
|
||||
const [isUndoingCurrentTurn, setIsUndoingCurrentTurn] = useState(false)
|
||||
const [currentTurnUndoConfirmOpen, setCurrentTurnUndoConfirmOpen] = useState(false)
|
||||
|
||||
const updateAutoScrollState = useCallback(() => {
|
||||
const container = scrollContainerRef.current
|
||||
@ -438,6 +440,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
})
|
||||
|
||||
setCurrentTurnPreview(null)
|
||||
setCurrentTurnUndoConfirmOpen(false)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
@ -448,6 +451,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
? error.message
|
||||
: String(error)
|
||||
setCurrentTurnError(message)
|
||||
setCurrentTurnUndoConfirmOpen(false)
|
||||
} finally {
|
||||
setIsUndoingCurrentTurn(false)
|
||||
}
|
||||
@ -545,13 +549,13 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
error={currentTurnError}
|
||||
isUndoing={isUndoingCurrentTurn}
|
||||
onUndo={() => {
|
||||
void handleUndoCurrentTurn()
|
||||
setCurrentTurnUndoConfirmOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!currentTurnPreview && currentTurnError && (
|
||||
<div className="mx-auto mb-5 max-w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
|
||||
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
|
||||
{currentTurnError}
|
||||
</div>
|
||||
)}
|
||||
@ -671,6 +675,22 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={currentTurnUndoConfirmOpen}
|
||||
onClose={() => {
|
||||
if (!isUndoingCurrentTurn) {
|
||||
setCurrentTurnUndoConfirmOpen(false)
|
||||
}
|
||||
}}
|
||||
onConfirm={handleUndoCurrentTurn}
|
||||
title={t('chat.turnChangesConfirmTitle')}
|
||||
body={t('chat.turnChangesConfirmBody')}
|
||||
confirmLabel={t('chat.turnChangesConfirmUndo')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
confirmVariant="danger"
|
||||
loading={isUndoingCurrentTurn}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
174
desktop/src/components/workspace/WorkspaceCodeSurface.tsx
Normal file
174
desktop/src/components/workspace/WorkspaceCodeSurface.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export const WORKSPACE_PREVIEW_LINE_LIMIT = 420
|
||||
|
||||
export 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)' } },
|
||||
],
|
||||
}
|
||||
|
||||
export 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()
|
||||
}
|
||||
|
||||
export function normalizePrismLanguage(language: string) {
|
||||
const lower = language.toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
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
|
||||
}
|
||||
|
||||
export function getLanguageFromPath(path: string) {
|
||||
return normalizePrismLanguage(getFileExtension(path) || 'text')
|
||||
}
|
||||
|
||||
export function InlineHighlightedCode({
|
||||
value,
|
||||
language,
|
||||
}: {
|
||||
value: string
|
||||
language: string
|
||||
}) {
|
||||
return (
|
||||
<Highlight
|
||||
theme={workspacePrismTheme}
|
||||
code={value}
|
||||
language={normalizePrismLanguage(language)}
|
||||
>
|
||||
{({ tokens, getTokenProps }) => (
|
||||
<>
|
||||
{(tokens[0] ?? []).map((token, tokenIndex) => {
|
||||
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
|
||||
return <span key={String(tokenKey)} {...tokenProps} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Highlight>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceDiffSurface({
|
||||
value,
|
||||
path,
|
||||
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
|
||||
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
}: {
|
||||
value: string
|
||||
path: string
|
||||
className?: string
|
||||
lineLimit?: number
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const lines = value.split('\n')
|
||||
const visibleLines = lines.slice(0, lineLimit)
|
||||
const hiddenLineCount = Math.max(0, lines.length - visibleLines.length)
|
||||
const language = getLanguageFromPath(path)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="relative min-w-max py-2">
|
||||
<pre
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55] text-[var(--color-code-fg)]"
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={`${index}:${line}`}
|
||||
className={`grid grid-cols-[48px_18px_minmax(0,1fr)] gap-2 px-3 ${
|
||||
isAdded
|
||||
? 'bg-[var(--color-diff-added-bg)]'
|
||||
: isRemoved
|
||||
? 'bg-[var(--color-diff-removed-bg)]'
|
||||
: isHunk
|
||||
? 'bg-[var(--color-diff-highlight-bg)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="select-none text-right text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={`select-none text-center ${
|
||||
isAdded
|
||||
? 'text-[var(--color-diff-added-text)]'
|
||||
: isRemoved
|
||||
? 'text-[var(--color-diff-removed-text)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span
|
||||
className={`whitespace-pre pr-6 ${
|
||||
isFileHeader
|
||||
? 'font-semibold text-[var(--color-text-secondary)]'
|
||||
: isHunk
|
||||
? 'font-semibold text-[var(--color-warning)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isCodeLine ? (
|
||||
code ? <InlineHighlightedCode value={code} language={language} /> : ' '
|
||||
) : (
|
||||
code || ' '
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
{hiddenLineCount > 0 && (
|
||||
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
{t('workspace.previewLineLimit', { count: lineLimit })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -587,6 +587,86 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.queryByTestId('workspace-code')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a context menu for preview tabs and closes tabs to the right', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-preview-menu': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-preview-menu': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-preview-menu': [
|
||||
{
|
||||
id: 'file:src/App.jsx',
|
||||
path: 'src/App.jsx',
|
||||
kind: 'file',
|
||||
title: 'App.jsx',
|
||||
language: 'jsx',
|
||||
content: 'app',
|
||||
state: 'ok',
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
id: 'diff:vite.config.js',
|
||||
path: 'vite.config.js',
|
||||
kind: 'diff',
|
||||
title: 'vite.config.js',
|
||||
diff: '@@ -1 +1 @@',
|
||||
state: 'ok',
|
||||
size: 12,
|
||||
},
|
||||
{
|
||||
id: 'file:src/index.css',
|
||||
path: 'src/index.css',
|
||||
kind: 'file',
|
||||
title: 'index.css',
|
||||
language: 'css',
|
||||
content: 'body{}',
|
||||
state: 'ok',
|
||||
size: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-preview-menu': 'diff:vite.config.js',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-preview-menu')
|
||||
|
||||
await act(() => {
|
||||
fireEvent.contextMenu(view.getByRole('tab', { name: /vite\.config\.js/i }), {
|
||||
clientX: 320,
|
||||
clientY: 42,
|
||||
})
|
||||
})
|
||||
|
||||
await clickElement(view.getByRole('menuitem', { name: 'Close to the Right' }))
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-menu']).toMatchObject([
|
||||
{ id: 'file:src/App.jsx' },
|
||||
{ id: 'diff:vite.config.js' },
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-menu']).toBe('diff:vite.config.js')
|
||||
expect(view.queryByRole('tab', { name: /index\.css/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the localized view menu label', async () => {
|
||||
await setSettingsState({ ...settingsInitialState, locale: 'zh' })
|
||||
await setWorkspaceState((state) => ({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react'
|
||||
import { Highlight } from 'prism-react-renderer'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceFileStatus,
|
||||
@ -10,10 +10,18 @@ import { useTranslation } from '../../i18n'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
useWorkspacePanelStore,
|
||||
type WorkspacePreviewCloseScope,
|
||||
type WorkspacePreviewKind,
|
||||
type WorkspacePreviewTab,
|
||||
} from '../../stores/workspacePanelStore'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import {
|
||||
getFileExtension,
|
||||
normalizePrismLanguage,
|
||||
WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
WorkspaceDiffSurface,
|
||||
workspacePrismTheme,
|
||||
} from './WorkspaceCodeSurface'
|
||||
|
||||
type WorkspacePanelProps = {
|
||||
sessionId: string
|
||||
@ -71,44 +79,20 @@ const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className:
|
||||
const EMPTY_TREE_BY_PATH: Record<string, WorkspaceTreeResult | undefined> = {}
|
||||
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<string, { label: string; className: string }> = {
|
||||
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]' },
|
||||
ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
||||
tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
||||
js: { label: 'JS', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' },
|
||||
jsx: { label: 'JSX', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' },
|
||||
json: { label: '{}', className: 'bg-[var(--color-tertiary)]/14 text-[var(--color-tertiary)]' },
|
||||
md: { label: 'MD', className: 'bg-[var(--color-text-tertiary)]/14 text-[var(--color-text-secondary)]' },
|
||||
css: { label: 'CSS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
||||
html: { label: 'H', className: 'bg-[var(--color-brand)]/14 text-[var(--color-brand)]' },
|
||||
png: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
|
||||
jpg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
|
||||
jpeg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
|
||||
gif: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
|
||||
svg: { label: 'SVG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
|
||||
}
|
||||
|
||||
function makeTreeStateKey(sessionId: string, path: string) {
|
||||
@ -136,47 +120,14 @@ function getPreviewKindLabel(
|
||||
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]',
|
||||
className: 'bg-[var(--color-text-tertiary)]/12 text-[var(--color-text-secondary)]',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePrismLanguage(language: string) {
|
||||
const lower = language.toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
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 isMarkdownPreview(tab: WorkspacePreviewTab) {
|
||||
if (tab.kind !== 'file') return false
|
||||
const language = (tab.language ?? '').toLowerCase()
|
||||
@ -196,31 +147,6 @@ function FileTypeBadge({ name, subtle = false }: { name: string; subtle?: boolea
|
||||
)
|
||||
}
|
||||
|
||||
function InlineHighlightedCode({
|
||||
value,
|
||||
language,
|
||||
}: {
|
||||
value: string
|
||||
language: string
|
||||
}) {
|
||||
return (
|
||||
<Highlight
|
||||
theme={workspacePrismTheme}
|
||||
code={value}
|
||||
language={normalizePrismLanguage(language)}
|
||||
>
|
||||
{({ tokens, getTokenProps }) => (
|
||||
<>
|
||||
{(tokens[0] ?? []).map((token, tokenIndex) => {
|
||||
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
|
||||
return <span key={String(tokenKey)} {...tokenProps} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Highlight>
|
||||
)
|
||||
}
|
||||
|
||||
function getInlineStateMessage(
|
||||
t: ReturnType<typeof useTranslation>,
|
||||
state: WorkspacePreviewTab['state'] | WorkspaceTreeResult['state'] | 'not_git_repo' | undefined,
|
||||
@ -316,7 +242,7 @@ function ToolbarIconButton({
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[9px] text-[#777c83] transition-colors hover:bg-[#f1f1f1] hover:text-[#272a2e] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0a96ff]/45"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[9px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{icon}</span>
|
||||
</button>
|
||||
@ -333,22 +259,22 @@ function WorkspaceFilterInput({
|
||||
const t = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-[#ececec] px-3 py-2">
|
||||
<label className="flex h-8 items-center gap-2 rounded-[9px] border border-[#e5e5e5] bg-white px-2.5 text-[#8b9096] transition-colors focus-within:border-[#0a96ff] focus-within:ring-2 focus-within:ring-[#0a96ff]/10">
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] px-3 py-2">
|
||||
<label className="flex h-8 items-center gap-2 rounded-[9px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[var(--color-text-tertiary)] transition-colors focus-within:border-[var(--color-border-focus)] focus-within:ring-2 focus-within:ring-[var(--color-brand)]/10">
|
||||
<span className="material-symbols-outlined shrink-0 text-[17px]">search</span>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
aria-label={t('workspace.filterPlaceholder')}
|
||||
placeholder={t('workspace.filterPlaceholder')}
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] text-[#26292d] outline-none placeholder:text-[#a0a4aa]"
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workspace.clearFilter')}
|
||||
onClick={() => onChange('')}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[#9ba0a6] transition-colors hover:bg-[#eeeeee] hover:text-[#30343a]"
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">close</span>
|
||||
</button>
|
||||
@ -373,12 +299,12 @@ function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) {
|
||||
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 visibleLines = lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
|
||||
const visibleCode = visibleLines.join('\n')
|
||||
const hiddenLineCount = Math.max(0, lines.length - visibleLines.length)
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[#fdfdfc]">
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]">
|
||||
<div className="relative min-w-max py-2">
|
||||
<Highlight
|
||||
theme={workspacePrismTheme}
|
||||
@ -398,9 +324,9 @@ function CodeSurface({ value, language }: { value: string; language: string }) {
|
||||
<div
|
||||
key={String(lineKey)}
|
||||
{...lineProps}
|
||||
className="grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[#f4f4f3]"
|
||||
className="grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<span className="select-none text-right text-[11px] text-[#96999d]">
|
||||
<span className="select-none text-right text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre pr-6">
|
||||
@ -416,8 +342,8 @@ function CodeSurface({ value, language }: { value: string; language: string }) {
|
||||
)}
|
||||
</Highlight>
|
||||
{hiddenLineCount > 0 && (
|
||||
<div className="sticky bottom-0 border-t border-[#e7e7e7] bg-white/95 px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
{t('workspace.previewLineLimit', { count: PREVIEW_LINE_LIMIT })}
|
||||
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
{t('workspace.previewLineLimit', { count: WORKSPACE_PREVIEW_LINE_LIMIT })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -427,7 +353,7 @@ function CodeSurface({ value, language }: { value: string; language: string }) {
|
||||
|
||||
function MarkdownSurface({ value }: { value: string }) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[#fdfdfc]">
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[var(--color-surface)]">
|
||||
<div className="mx-auto w-full max-w-[860px] px-6 py-5">
|
||||
<MarkdownRenderer
|
||||
content={value}
|
||||
@ -439,86 +365,6 @@ function MarkdownSurface({ value }: { value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[#fdfdfc]">
|
||||
<div className="relative min-w-max py-2">
|
||||
<pre
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55] text-[var(--color-code-fg)]"
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={`${index}:${line}`}
|
||||
className={`grid grid-cols-[48px_18px_minmax(0,1fr)] gap-2 px-3 ${
|
||||
isAdded
|
||||
? 'bg-[var(--color-diff-added-bg)]'
|
||||
: isRemoved
|
||||
? 'bg-[var(--color-diff-removed-bg)]'
|
||||
: isHunk
|
||||
? 'bg-[var(--color-diff-highlight-bg)]'
|
||||
: 'hover:bg-[#f4f4f3]'
|
||||
}`}
|
||||
>
|
||||
<span className="select-none text-right text-[11px] text-[#96999d]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={`select-none text-center ${
|
||||
isAdded
|
||||
? 'text-[var(--color-diff-added-text)]'
|
||||
: isRemoved
|
||||
? 'text-[var(--color-diff-removed-text)]'
|
||||
: 'text-[#9aa0a6]'
|
||||
}`}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span
|
||||
className={`whitespace-pre pr-6 ${
|
||||
isFileHeader
|
||||
? 'font-semibold text-[#747a82]'
|
||||
: isHunk
|
||||
? 'font-semibold text-[#8a6f00]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isCodeLine ? (
|
||||
code ? <InlineHighlightedCode value={code} language={language} /> : ' '
|
||||
) : (
|
||||
code || ' '
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
{hiddenLineCount > 0 && (
|
||||
<div className="sticky bottom-0 border-t border-[#e7e7e7] bg-white/95 px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
{t('workspace.previewLineLimit', { count: PREVIEW_LINE_LIMIT })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) {
|
||||
const t = useTranslation()
|
||||
|
||||
@ -532,12 +378,12 @@ function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[#fdfdfc] p-4">
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[var(--color-surface)] p-4">
|
||||
<div className="flex min-h-full items-center justify-center">
|
||||
<img
|
||||
src={tab.dataUrl}
|
||||
alt={tab.path}
|
||||
className="max-h-full max-w-full rounded-[8px] border border-[#e5e5e5] bg-white object-contain shadow-sm"
|
||||
className="max-h-full max-w-full rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] object-contain shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -555,13 +401,13 @@ function ChangedFileRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="mx-2 flex w-[calc(100%-16px)] items-center gap-3 rounded-[7px] px-2 py-2 text-left transition-colors hover:bg-[#f3f3f3]"
|
||||
className="mx-2 flex w-[calc(100%-16px)] items-center gap-3 rounded-[7px] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<FileStatusBadge status={file.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[#282a2d]">{file.path}</div>
|
||||
<div className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">{file.path}</div>
|
||||
{file.oldPath && (
|
||||
<div className="truncate text-[11px] text-[#8b9096]">
|
||||
<div className="truncate text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{file.oldPath}
|
||||
</div>
|
||||
)}
|
||||
@ -603,13 +449,13 @@ function TreeNode({
|
||||
onClick={() => onOpenFile(entry.path)}
|
||||
className={`group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors ${
|
||||
isActive
|
||||
? 'bg-[#f7f7f7] shadow-[inset_0_0_0_1.5px_#0a96ff]'
|
||||
: 'hover:bg-[#f3f3f3]'
|
||||
? 'bg-[var(--color-surface-selected)] shadow-[inset_0_0_0_1.5px_var(--color-border-focus)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
style={{ paddingLeft: indent }}
|
||||
>
|
||||
<FileTypeBadge name={entry.name} subtle={!isActive} />
|
||||
<span className="min-w-0 truncate text-[14px] font-medium text-[#282a2d]">{entry.name}</span>
|
||||
<span className="min-w-0 truncate text-[14px] font-medium text-[var(--color-text-primary)]">{entry.name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -620,13 +466,13 @@ function TreeNode({
|
||||
type="button"
|
||||
onClick={() => onToggle(entry.path)}
|
||||
aria-expanded={isVisuallyExpanded}
|
||||
className="group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors hover:bg-[#f3f3f3]"
|
||||
className="group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ paddingLeft: indent }}
|
||||
>
|
||||
<span className="material-symbols-outlined shrink-0 text-[18px] text-[#858a90] transition-colors group-hover:text-[#34373b]">
|
||||
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-text-primary)]">
|
||||
{isVisuallyExpanded ? 'expand_more' : 'chevron_right'}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[15px] font-medium text-[#24272b]">{entry.name}</span>
|
||||
<span className="min-w-0 truncate text-[15px] font-medium text-[var(--color-text-primary)]">{entry.name}</span>
|
||||
</button>
|
||||
|
||||
{isVisuallyExpanded && (
|
||||
@ -634,7 +480,7 @@ function TreeNode({
|
||||
{depth < 4 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-1 top-1 w-px bg-[#e5e5e5]"
|
||||
className="pointer-events-none absolute bottom-1 top-1 w-px bg-[var(--color-border)]"
|
||||
style={{ left: 28 + depth * 20 }}
|
||||
/>
|
||||
)}
|
||||
@ -695,6 +541,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
const t = useTranslation()
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const [isViewMenuOpen, setIsViewMenuOpen] = useState(false)
|
||||
const [previewTabContextMenu, setPreviewTabContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
||||
const width = useWorkspacePanelStore((state) => state.width)
|
||||
const isOpen = useWorkspacePanelStore((state) => state.isPanelOpen(sessionId))
|
||||
const activeView = useWorkspacePanelStore((state) => state.getActiveView(sessionId))
|
||||
@ -717,6 +564,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
const toggleTreeNode = useWorkspacePanelStore((state) => state.toggleTreeNode)
|
||||
const openPreview = useWorkspacePanelStore((state) => state.openPreview)
|
||||
const closePreview = useWorkspacePanelStore((state) => state.closePreview)
|
||||
const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs)
|
||||
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
|
||||
|
||||
const rootTree = treeByPath['']
|
||||
@ -758,6 +606,13 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
void loadTree(sessionId, '')
|
||||
}, [activeView, isOpen, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewTabContextMenu) return
|
||||
const close = () => setPreviewTabContextMenu(null)
|
||||
document.addEventListener('click', close)
|
||||
return () => document.removeEventListener('click', close)
|
||||
}, [previewTabContextMenu])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const hasPreviewTabs = previewTabs.length > 0
|
||||
@ -785,6 +640,18 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
setIsViewMenuOpen(false)
|
||||
}
|
||||
|
||||
const handlePreviewTabContextMenu = (event: MouseEvent, tabId: string) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setPreviewTabContextMenu({ tabId, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const handleClosePreviewTabs = (scope: WorkspacePreviewCloseScope) => {
|
||||
if (!previewTabContextMenu) return
|
||||
closePreviewTabs(sessionId, previewTabContextMenu.tabId, scope)
|
||||
setPreviewTabContextMenu(null)
|
||||
}
|
||||
|
||||
const renderChangedView = () => {
|
||||
if (statusLoading && !status) {
|
||||
return <PanelMessage icon="progress_activity" message={t('common.loading')} />
|
||||
@ -900,17 +767,17 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[#ededed] bg-white px-3 text-[12px]">
|
||||
<span className="truncate text-[#8b8f95]">{status?.repoName || 'workspace'}</span>
|
||||
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-[12px]">
|
||||
<span className="truncate text-[var(--color-text-tertiary)]">{status?.repoName || 'workspace'}</span>
|
||||
{activePreviewTab.path.split('/').map((segment, index, segments) => (
|
||||
<span key={`${segment}:${index}`} className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-[#b2b5ba]">›</span>
|
||||
<span className={`truncate ${index === segments.length - 1 ? 'font-semibold text-[#25272a]' : 'text-[#7b8087]'}`}>
|
||||
<span className="text-[var(--color-text-tertiary)]">›</span>
|
||||
<span className={`truncate ${index === segments.length - 1 ? 'font-semibold text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
{segment}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto shrink-0 rounded-[5px] border border-[#ececec] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.1em] text-[#8a8f96]">
|
||||
<span className="ml-auto shrink-0 rounded-[5px] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
|
||||
{kindLabel}
|
||||
</span>
|
||||
</div>
|
||||
@ -920,7 +787,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
) : state === 'ok' && activePreviewTab.previewType === 'image' ? (
|
||||
<ImagePreview tab={activePreviewTab} />
|
||||
) : state === 'ok' && activePreviewTab.kind === 'diff' ? (
|
||||
<DiffSurface
|
||||
<WorkspaceDiffSurface
|
||||
value={activePreviewTab.diff ?? ''}
|
||||
path={activePreviewTab.path}
|
||||
/>
|
||||
@ -943,80 +810,134 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
}
|
||||
|
||||
const renderPreviewTabs = () => (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('workspace.previewTabs')}
|
||||
className="flex h-11 shrink-0 items-center gap-1 overflow-x-auto border-b border-[#ececec] bg-white px-3"
|
||||
>
|
||||
{previewTabs.length === 0 ? (
|
||||
<div className="flex items-center gap-2 px-1.5 text-[12px] text-[#8a8f96]">
|
||||
<span className="material-symbols-outlined text-[15px]">docs</span>
|
||||
<span>{t('workspace.preview')}</span>
|
||||
</div>
|
||||
) : (
|
||||
previewTabs.map((tab) => {
|
||||
const kindLabel = getPreviewKindLabel(t, tab.kind)
|
||||
const isActive = tab.id === activePreviewTab?.id
|
||||
<>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('workspace.previewTabs')}
|
||||
className="flex h-11 shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3"
|
||||
>
|
||||
{previewTabs.length === 0 ? (
|
||||
<div className="flex items-center gap-2 px-1.5 text-[12px] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[15px]">docs</span>
|
||||
<span>{t('workspace.preview')}</span>
|
||||
</div>
|
||||
) : (
|
||||
previewTabs.map((tab) => {
|
||||
const kindLabel = getPreviewKindLabel(t, tab.kind)
|
||||
const isActive = tab.id === activePreviewTab?.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`group flex h-8 min-w-[118px] max-w-[210px] shrink-0 items-center gap-2 rounded-[8px] px-2 text-left text-[13px] transition-colors ${
|
||||
isActive
|
||||
? 'bg-[#f0f0f0] text-[#222529]'
|
||||
: 'text-[#777c83] hover:bg-[#f6f6f6] hover:text-[#2f3338]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => {
|
||||
void openPreview(sessionId, tab.path, tab.kind)
|
||||
}}
|
||||
className="min-w-0 flex flex-1 items-center gap-2 text-left"
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
onContextMenu={(event) => handlePreviewTabContextMenu(event, tab.id)}
|
||||
className={`group flex h-8 min-w-[118px] max-w-[210px] shrink-0 items-center gap-2 rounded-[8px] px-2 text-left text-[13px] transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{tab.kind === 'diff' ? (
|
||||
<span className="material-symbols-outlined shrink-0 text-[15px] text-[#8b9096]">difference</span>
|
||||
) : (
|
||||
<FileTypeBadge name={tab.title} subtle={!isActive} />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{tab.title}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('workspace.closeTab')} ${tab.title} ${kindLabel}`}
|
||||
onClick={() => {
|
||||
closePreview(sessionId, tab.id)
|
||||
}}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[#9a9fa6] opacity-0 transition-colors hover:bg-[#e4e4e4] hover:text-[#30343a] group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px] leading-none">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => {
|
||||
void openPreview(sessionId, tab.path, tab.kind)
|
||||
}}
|
||||
className="min-w-0 flex flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
{tab.kind === 'diff' ? (
|
||||
<span className="material-symbols-outlined shrink-0 text-[15px] text-[var(--color-text-tertiary)]">difference</span>
|
||||
) : (
|
||||
<FileTypeBadge name={tab.title} subtle={!isActive} />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{tab.title}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('workspace.closeTab')} ${tab.title} ${kindLabel}`}
|
||||
onClick={() => {
|
||||
closePreview(sessionId, tab.id)
|
||||
}}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)] opacity-0 transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px] leading-none">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewTabContextMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="fixed z-50 min-w-[156px] rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 text-[12px] shadow-[var(--shadow-dropdown)]"
|
||||
style={{ left: previewTabContextMenu.x, top: previewTabContextMenu.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleClosePreviewTabs('current')}
|
||||
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('tabs.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleClosePreviewTabs('others')}
|
||||
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('tabs.closeOthers')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleClosePreviewTabs('left')}
|
||||
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('tabs.closeLeft')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleClosePreviewTabs('right')}
|
||||
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('tabs.closeRight')}
|
||||
</button>
|
||||
<div className="my-1 border-t border-[var(--color-border)]" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleClosePreviewTabs('all')}
|
||||
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('tabs.closeAll')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="workspace-panel"
|
||||
className="flex h-full shrink-0 border-l border-[#e7e7e7] bg-white"
|
||||
className="flex h-full shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
style={{ width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
|
||||
>
|
||||
{hasPreviewTabs && (
|
||||
<div className="flex min-w-0 flex-1 flex-col border-r border-[#e7e7e7] bg-white">
|
||||
<div className="flex min-w-0 flex-1 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{renderPreviewTabs()}
|
||||
{renderPreviewContent()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`${hasPreviewTabs ? 'basis-[32%] min-w-[220px] max-w-[320px]' : 'w-full'} flex h-full shrink-0 flex-col bg-white`}
|
||||
className={`${hasPreviewTabs ? 'basis-[32%] min-w-[220px] max-w-[320px]' : 'w-full'} flex h-full shrink-0 flex-col bg-[var(--color-surface)]`}
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-[#ececec] px-3">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3">
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
@ -1024,17 +945,17 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isViewMenuOpen}
|
||||
onClick={() => setIsViewMenuOpen((open) => !open)}
|
||||
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-[8px] px-2.5 py-1.5 text-[18px] font-semibold leading-none text-[#202327] transition-colors hover:bg-[#f4f4f4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0a96ff]/35"
|
||||
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-[8px] px-2.5 py-1.5 text-[18px] font-semibold leading-none text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<span className="truncate">
|
||||
{activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
|
||||
</span>
|
||||
<span className="material-symbols-outlined shrink-0 text-[18px] font-normal text-[#858a90]">expand_more</span>
|
||||
<span className="material-symbols-outlined shrink-0 text-[18px] font-normal text-[var(--color-text-tertiary)]">expand_more</span>
|
||||
</button>
|
||||
{isViewMenuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-30 min-w-[132px] overflow-hidden rounded-[10px] border border-[#e6e6e6] bg-white py-1 shadow-[0_10px_28px_rgba(20,20,20,0.12)]"
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-30 min-w-[132px] overflow-hidden rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
|
||||
>
|
||||
{(['changed', 'all'] as const).map((view) => {
|
||||
const selected = activeView === view
|
||||
@ -1045,14 +966,14 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
role="menuitem"
|
||||
onClick={() => handleSetActiveView(view)}
|
||||
className={`flex h-8 w-full items-center gap-2 px-3 text-left text-[13px] transition-colors ${
|
||||
selected ? 'bg-[#f4f4f4] text-[#202327]' : 'text-[#4b5056] hover:bg-[#f7f7f7]'
|
||||
selected ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{view === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="material-symbols-outlined text-[15px] text-[#0a96ff]">check</span>
|
||||
<span className="material-symbols-outlined text-[15px] text-[var(--color-brand)]">check</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
@ -640,6 +640,9 @@ export const en = {
|
||||
'chat.turnChangesUndo': 'Undo',
|
||||
'chat.turnChangesUndoing': 'Undoing...',
|
||||
'chat.turnChangesUndoAria': 'Undo current turn changes',
|
||||
'chat.turnChangesConfirmTitle': 'Undo current turn?',
|
||||
'chat.turnChangesConfirmBody': 'This will rewind the latest assistant response and restore tracked files for this turn.',
|
||||
'chat.turnChangesConfirmUndo': 'Undo current turn',
|
||||
'chat.turnChangesShowDiffAria': 'Show diff for {path}',
|
||||
'chat.turnChangesHideDiffAria': 'Hide diff for {path}',
|
||||
'chat.turnChangesDiffLoading': 'Loading diff...',
|
||||
|
||||
@ -642,6 +642,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.turnChangesUndo': '撤销',
|
||||
'chat.turnChangesUndoing': '正在撤销...',
|
||||
'chat.turnChangesUndoAria': '撤销当前轮次变更',
|
||||
'chat.turnChangesConfirmTitle': '撤销当前轮次?',
|
||||
'chat.turnChangesConfirmBody': '这会回滚最近一次助手回复,并恢复这一轮中被跟踪的文件变更。',
|
||||
'chat.turnChangesConfirmUndo': '撤销当前轮次',
|
||||
'chat.turnChangesShowDiffAria': '查看 {path} 的 diff',
|
||||
'chat.turnChangesHideDiffAria': '收起 {path} 的 diff',
|
||||
'chat.turnChangesDiffLoading': '正在加载 diff...',
|
||||
|
||||
@ -301,6 +301,63 @@ describe('workspacePanelStore', () => {
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/a.ts')
|
||||
})
|
||||
|
||||
it('closes preview tabs by context-menu scope', () => {
|
||||
useWorkspacePanelStore.setState((state) => ({
|
||||
...state,
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-preview-scope': [
|
||||
{ id: 'file:a.ts', path: 'a.ts', kind: 'file', title: 'a.ts', state: 'ok', language: 'typescript', size: 1 },
|
||||
{ id: 'file:b.ts', path: 'b.ts', kind: 'file', title: 'b.ts', state: 'ok', language: 'typescript', size: 1 },
|
||||
{ id: 'file:c.ts', path: 'c.ts', kind: 'file', title: 'c.ts', state: 'ok', language: 'typescript', size: 1 },
|
||||
{ id: 'file:d.ts', path: 'd.ts', kind: 'file', title: 'd.ts', state: 'ok', language: 'typescript', size: 1 },
|
||||
],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-preview-scope': 'file:d.ts',
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
previewByTabId: {
|
||||
...state.loading.previewByTabId,
|
||||
'session-preview-scope::file:c.ts': true,
|
||||
'session-preview-scope::file:d.ts': true,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: {
|
||||
...state.errors.previewByTabId,
|
||||
'session-preview-scope::file:c.ts': 'loading',
|
||||
'session-preview-scope::file:d.ts': 'loading',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'right')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-scope']).toMatchObject([
|
||||
{ id: 'file:a.ts' },
|
||||
{ id: 'file:b.ts' },
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBe('file:b.ts')
|
||||
expect(useWorkspacePanelStore.getState().loading.previewByTabId['session-preview-scope::file:c.ts']).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-preview-scope::file:d.ts']).toBeUndefined()
|
||||
|
||||
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'others')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-scope']).toMatchObject([
|
||||
{ id: 'file:b.ts' },
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBe('file:b.ts')
|
||||
|
||||
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'all')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-scope']).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores stale status responses for the same session', async () => {
|
||||
const first = deferred<{
|
||||
state: 'ok'
|
||||
|
||||
@ -13,6 +13,7 @@ export const WORKSPACE_PANEL_MAX_WIDTH = 1120
|
||||
|
||||
export type WorkspacePanelView = 'changed' | 'all'
|
||||
export type WorkspacePreviewKind = 'file' | 'diff'
|
||||
export type WorkspacePreviewCloseScope = 'current' | 'others' | 'left' | 'right' | 'all'
|
||||
export type WorkspacePreviewState =
|
||||
| 'loading'
|
||||
| WorkspaceReadFileResult['state']
|
||||
@ -74,6 +75,7 @@ type WorkspacePanelStore = {
|
||||
toggleTreeNode: (sessionId: string, path: string) => Promise<void>
|
||||
openPreview: (sessionId: string, path: string, kind: WorkspacePreviewKind) => Promise<void>
|
||||
closePreview: (sessionId: string, tabId: string) => void
|
||||
closePreviewTabs: (sessionId: string, tabId: string, scope: WorkspacePreviewCloseScope) => void
|
||||
clearSession: (sessionId: string) => void
|
||||
resetSessionUi: (sessionId: string) => void
|
||||
}
|
||||
@ -145,6 +147,14 @@ function removeRecordKey<T>(record: Record<string, T>, key: string) {
|
||||
return rest
|
||||
}
|
||||
|
||||
function removeRecordKeys<T>(record: Record<string, T>, keys: string[]) {
|
||||
let next = record
|
||||
for (const key of keys) {
|
||||
next = removeRecordKey(next, key)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function invalidateSessionScopedRequests(store: Map<string, number>, sessionId: string) {
|
||||
const prefix = `${sessionId}::`
|
||||
for (const key of store.keys()) {
|
||||
@ -567,13 +577,16 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
},
|
||||
|
||||
closePreview: (sessionId, tabId) => {
|
||||
const requestKey = makePreviewKey(sessionId, tabId)
|
||||
invalidateRequest(previewRequestIds, requestKey)
|
||||
get().closePreviewTabs(sessionId, tabId, 'current')
|
||||
},
|
||||
|
||||
closePreviewTabs: (sessionId, tabId, scope) => {
|
||||
set((state) => {
|
||||
const tabs = state.previewTabsBySession[sessionId] ?? []
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId)
|
||||
if (index < 0) {
|
||||
const requestKey = makePreviewKey(sessionId, tabId)
|
||||
invalidateRequest(previewRequestIds, requestKey)
|
||||
return {
|
||||
loading: {
|
||||
...state.loading,
|
||||
@ -586,12 +599,44 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
}
|
||||
}
|
||||
|
||||
const closingTab = tabs[index]!
|
||||
const nextTabs = tabs.filter((tab) => tab.id !== closingTab.id)
|
||||
let nextTabs: WorkspacePreviewTab[]
|
||||
switch (scope) {
|
||||
case 'others':
|
||||
nextTabs = [tabs[index]!]
|
||||
break
|
||||
case 'left':
|
||||
nextTabs = tabs.slice(index)
|
||||
break
|
||||
case 'right':
|
||||
nextTabs = tabs.slice(0, index + 1)
|
||||
break
|
||||
case 'all':
|
||||
nextTabs = []
|
||||
break
|
||||
case 'current':
|
||||
default:
|
||||
nextTabs = tabs.filter((tab) => tab.id !== tabId)
|
||||
break
|
||||
}
|
||||
|
||||
const nextTabIds = new Set(nextTabs.map((tab) => tab.id))
|
||||
const closingTabIds = tabs.map((tab) => tab.id).filter((id) => !nextTabIds.has(id))
|
||||
const requestKeys = closingTabIds.map((id) => makePreviewKey(sessionId, id))
|
||||
for (const key of requestKeys) {
|
||||
invalidateRequest(previewRequestIds, key)
|
||||
}
|
||||
|
||||
const activeTabId = state.activePreviewTabIdBySession[sessionId] ?? null
|
||||
|
||||
let nextActiveTabId = activeTabId
|
||||
if (activeTabId === closingTab.id) {
|
||||
if (scope === 'all' || nextTabs.length === 0) {
|
||||
nextActiveTabId = null
|
||||
} else if (!activeTabId || !nextTabIds.has(activeTabId)) {
|
||||
const targetTab = nextTabs.find((tab) => tab.id === tabId)
|
||||
nextActiveTabId = targetTab?.id ?? nextTabs[Math.min(index, nextTabs.length - 1)]?.id ?? null
|
||||
} else if (scope === 'others') {
|
||||
nextActiveTabId = tabId
|
||||
} else if (activeTabId === tabId && scope === 'current') {
|
||||
if (nextTabs.length === 0) {
|
||||
nextActiveTabId = null
|
||||
} else if (index >= nextTabs.length) {
|
||||
@ -612,11 +657,11 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
previewByTabId: removeRecordKey(state.loading.previewByTabId, requestKey),
|
||||
previewByTabId: removeRecordKeys(state.loading.previewByTabId, requestKeys),
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey),
|
||||
previewByTabId: removeRecordKeys(state.errors.previewByTabId, requestKeys),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@ -1537,6 +1537,65 @@ describe('Sessions API', () => {
|
||||
expect(remainingMessages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should resolve checkpoint paths from the target prompt cwd', async () => {
|
||||
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-ffffffffffff'
|
||||
const parentDir = path.join(tmpDir, 'nested-cwd-parent')
|
||||
const workDir = path.join(parentDir, 'testbb')
|
||||
const targetFile = path.join(workDir, 'vite.config.js')
|
||||
const userId = crypto.randomUUID()
|
||||
const assistantId = crypto.randomUUID()
|
||||
const laterUserId = crypto.randomUUID()
|
||||
const backupName = 'nested-cwd@v1'
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true })
|
||||
await fs.writeFile(targetFile, "export default 'after'\n", 'utf-8')
|
||||
await writeFileHistoryBackup(sessionId, backupName, "export default 'before'\n")
|
||||
|
||||
await writeSessionFile(sanitizePath(parentDir), sessionId, [
|
||||
makeFileHistorySnapshotEntry(userId, {
|
||||
'testbb/vite.config.js': {
|
||||
backupFileName: backupName,
|
||||
version: 1,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('create a nested project', userId),
|
||||
cwd: parentDir,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
...makeAssistantEntry('DONE', userId),
|
||||
uuid: assistantId,
|
||||
},
|
||||
{
|
||||
...makeUserEntry('latest tool result after cd', laterUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
])
|
||||
|
||||
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 0, dryRun: true }),
|
||||
})
|
||||
expect(previewRes.status).toBe(200)
|
||||
const preview = await previewRes.json() as {
|
||||
code: { available: boolean; filesChanged: string[] }
|
||||
}
|
||||
expect(preview.code.available).toBe(true)
|
||||
expect(preview.code.filesChanged).toEqual([targetFile])
|
||||
|
||||
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 0 }),
|
||||
})
|
||||
expect(executeRes.status).toBe(200)
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe("export default 'before'\n")
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should restore multiple files and remove created files', async () => {
|
||||
const sessionId = 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const workDir = path.join(tmpDir, 'multi-file-fixture')
|
||||
|
||||
@ -15,6 +15,7 @@ import { handleProxyRequest } from './proxy/handler.js'
|
||||
import { ProviderService } from './services/providerService.js'
|
||||
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
|
||||
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
|
||||
import { enableConfigs } from '../utils/config.js'
|
||||
|
||||
function readArgValue(flag: string): string | undefined {
|
||||
const args = process.argv.slice(2)
|
||||
@ -46,6 +47,7 @@ const PORT = SERVER_OPTIONS.port
|
||||
const HOST = SERVER_OPTIONS.host
|
||||
|
||||
export function startServer(port = PORT, host = HOST) {
|
||||
enableConfigs()
|
||||
ProviderService.setServerPort(port)
|
||||
const localConnectHost =
|
||||
host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost'
|
||||
|
||||
@ -284,7 +284,7 @@ async function restoreBackupFile(
|
||||
|
||||
async function buildCodePreview(
|
||||
sessionId: string,
|
||||
workDir: string,
|
||||
checkpointBaseDir: string,
|
||||
targetUserMessageId: string,
|
||||
): Promise<{
|
||||
snapshots: FileHistorySnapshot[] | null
|
||||
@ -332,7 +332,7 @@ async function buildCodePreview(
|
||||
|
||||
if (backupFileName === undefined) continue
|
||||
|
||||
const absolutePath = expandTrackingPath(workDir, trackingPath)
|
||||
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
|
||||
|
||||
if (backupFileName === null) {
|
||||
const currentContent = await readFileOrNull(absolutePath)
|
||||
@ -384,9 +384,12 @@ export async function previewSessionRewind(
|
||||
: null) ||
|
||||
(await sessionService.getSessionWorkDir(sessionId)) ||
|
||||
process.cwd()
|
||||
const checkpointBaseDir =
|
||||
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
|
||||
workDir
|
||||
const { preview } = await buildCodePreview(
|
||||
sessionId,
|
||||
workDir,
|
||||
checkpointBaseDir,
|
||||
target.targetUserMessageId,
|
||||
)
|
||||
|
||||
@ -414,9 +417,12 @@ export async function executeSessionRewind(
|
||||
: null) ||
|
||||
(await sessionService.getSessionWorkDir(sessionId)) ||
|
||||
process.cwd()
|
||||
const checkpointBaseDir =
|
||||
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
|
||||
workDir
|
||||
const { snapshots, preview } = await buildCodePreview(
|
||||
sessionId,
|
||||
workDir,
|
||||
checkpointBaseDir,
|
||||
target.targetUserMessageId,
|
||||
)
|
||||
|
||||
@ -439,7 +445,7 @@ export async function executeSessionRewind(
|
||||
|
||||
if (backupFileName === undefined) continue
|
||||
|
||||
const absolutePath = expandTrackingPath(workDir, trackingPath)
|
||||
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
|
||||
|
||||
if (backupFileName === null) {
|
||||
try {
|
||||
|
||||
@ -1224,6 +1224,18 @@ export class SessionService {
|
||||
return this.resolveWorkDirFromEntries(entries, found.projectDir)
|
||||
}
|
||||
|
||||
async getSessionMessageCwd(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
): Promise<string | null> {
|
||||
const found = await this.findSessionFile(sessionId)
|
||||
if (!found) return null
|
||||
|
||||
const entries = await this.readJsonlFile(found.filePath)
|
||||
const entry = entries.find((candidate) => candidate.uuid === messageId)
|
||||
return typeof entry?.cwd === 'string' && entry.cwd.trim() ? entry.cwd : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect how a session should be launched.
|
||||
* Placeholder desktop-created sessions have zero transcript messages.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user