feat(desktop): render changed files as rich cards (icon/type/open-with pill)

Each file row now shows a material-symbol document-type icon, bold filename,
and a type subtitle (e.g. 文档·MD / 代码·TS) alongside a labeled 「打开方式 ⌄」
pill replacing the old open_in_new icon button; diff-expand is preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 17:38:10 +08:00
parent 4f03b8f6e6
commit 81ed3e2530
7 changed files with 132 additions and 21 deletions

View File

@ -134,6 +134,36 @@ function renderCard(filesChanged: string[]) {
// ──────────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────────
describe('CurrentTurnChangeCard rich file row (icon / name / type)', () => {
beforeEach(() => {
vi.clearAllMocks()
ensureTargetsMock.mockResolvedValue(undefined)
openPreviewSpy.mockResolvedValue(undefined)
})
it('renders the filename (not just full path) for each file', () => {
renderCard(['/w/proj/README.md', '/w/proj/src/index.ts'])
expect(screen.getByText('README.md')).toBeInTheDocument()
expect(screen.getByText('index.ts')).toBeInTheDocument()
})
it('renders the extension badge for a markdown file', () => {
renderCard(['/w/proj/README.md'])
// The type subtitle contains the ext in uppercase: "· MD"
expect(screen.getByText(/MD/)).toBeInTheDocument()
})
it('renders the extension badge for a TypeScript file', () => {
renderCard(['/w/proj/src/main.ts'])
expect(screen.getByText(/TS/)).toBeInTheDocument()
})
it('renders the extension badge for an HTML file', () => {
renderCard(['/w/proj/index.html'])
expect(screen.getByText(/HTML/)).toBeInTheDocument()
})
})
describe('CurrentTurnChangeCard open-with buttons', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -1,10 +1,11 @@
import { useCallback, useMemo, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { ChevronDown } from 'lucide-react'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useTranslation, type TranslationKey } from '../../i18n'
import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface'
import { OpenWithMenu } from '../common/OpenWithMenu'
import { buildOpenWithItems, type OpenWithItem } from '../../lib/openWithItems'
import { buildOpenWithItems, describeFileType, type OpenWithItem } from '../../lib/openWithItems'
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { useOpenTargetStore } from '../../stores/openTargetStore'
@ -173,9 +174,11 @@ export function CurrentTurnChangeCard({
{files.map((fileEntry) => {
const isExpanded = expandedPath === fileEntry.apiPath
const diffState = diffByPath[fileEntry.apiPath]
const fileName = fileEntry.displayPath.split('/').pop() || fileEntry.displayPath
const typeInfo = describeFileType(fileEntry.displayPath)
return (
<div key={fileEntry.apiPath}>
<div className="flex items-center">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => toggleDiff(fileEntry)}
@ -183,22 +186,24 @@ export function CurrentTurnChangeCard({
isExpanded ? 'chat.turnChangesHideDiffAria' : 'chat.turnChangesShowDiffAria',
{ path: fileEntry.displayPath },
)}
className="flex min-h-11 flex-1 items-center gap-3 px-4 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
title={fileEntry.displayPath}
className="flex min-h-[52px] min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">
{fileEntry.displayPath}
<span className="material-symbols-outlined shrink-0 text-[22px] text-[var(--color-text-tertiary)]">{typeInfo.icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{fileName}</span>
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">{`${t(typeInfo.categoryKey as Parameters<typeof t>[0])} · ${typeInfo.ext}`}</span>
</span>
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)]">{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}</span>
</button>
<button
type="button"
aria-label={t('openWith.title')}
onClick={(event) => handleOpenWith(event, fileEntry)}
className="mr-2 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-tertiary)] 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"
className="mr-2 inline-flex h-8 shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] 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]">open_in_new</span>
{t('openWith.title')}
<ChevronDown size={14} strokeWidth={1.9} />
</button>
</div>

View File

@ -3015,9 +3015,9 @@ describe('MessageList nested tool calls', () => {
const cards = await screen.findAllByLabelText('Turn changed files')
expect(cards).toHaveLength(2)
expect(screen.getByText('src/first.ts')).toBeTruthy()
expect(screen.getByText('src/second.ts')).toBeTruthy()
expect(screen.queryByText('src/third.ts')).toBeNull()
expect(screen.getByText('first.ts')).toBeTruthy()
expect(screen.getByText('second.ts')).toBeTruthy()
expect(screen.queryByText('third.ts')).toBeNull()
})
it('expands a historical turn diff through the turn checkpoint diff API', async () => {
@ -3231,7 +3231,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
expect(await screen.findByText('src/live.ts')).toBeTruthy()
expect(await screen.findByText('live.ts')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Show diff for src/live.ts' }))
await screen.findByTestId('workspace-code')
expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith(
@ -3284,7 +3284,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
expect(await screen.findByText('src/blank-response.ts')).toBeTruthy()
expect(await screen.findByText('blank-response.ts')).toBeTruthy()
})
it('keeps historical turn change cards visible while the next turn is running', async () => {
@ -3329,7 +3329,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
expect(await screen.findByText('src/first.ts')).toBeTruthy()
expect(await screen.findByText('first.ts')).toBeTruthy()
act(() => {
useChatStore.setState({
@ -3343,7 +3343,7 @@ describe('MessageList nested tool calls', () => {
})
await waitFor(() => {
expect(screen.getByText('src/first.ts')).toBeTruthy()
expect(screen.getByText('first.ts')).toBeTruthy()
})
})
@ -3452,7 +3452,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
const historicalCard = (await screen.findByText('src/first.ts')).closest('section')
const historicalCard = (await screen.findByText('first.ts')).closest('section')
expect(historicalCard).toBeTruthy()
fireEvent.click(
within(historicalCard as HTMLElement).getByRole('button', {
@ -3553,8 +3553,8 @@ describe('MessageList nested tool calls', () => {
const cards = await screen.findAllByLabelText('Turn changed files')
expect(cards).toHaveLength(1)
expect(screen.getByText('src/first.ts')).toBeTruthy()
expect(screen.queryByText('src/second.ts')).toBeNull()
expect(screen.getByText('first.ts')).toBeTruthy()
expect(screen.queryByText('second.ts')).toBeNull()
})
it('shows raw startup details under translated CLI startup errors', () => {

View File

@ -99,6 +99,11 @@ export const en = {
'openWith.workspacePreview': 'Workspace preview',
'openWith.openInTarget': 'Open in {target}',
'openWith.revealInTarget': 'Reveal in {target}',
'openWith.fileType.document': 'Document',
'openWith.fileType.web': 'Web',
'openWith.fileType.image': 'Image',
'openWith.fileType.code': 'Code',
'openWith.fileType.file': 'File',
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': 'Changed files',

View File

@ -101,6 +101,11 @@ export const zh: Record<TranslationKey, string> = {
'openWith.workspacePreview': '工作台预览',
'openWith.openInTarget': '用 {target} 打开',
'openWith.revealInTarget': '在 {target} 中显示',
'openWith.fileType.document': '文档',
'openWith.fileType.web': '网页',
'openWith.fileType.image': '图片',
'openWith.fileType.code': '代码',
'openWith.fileType.file': '文件',
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': '已更改文件',

View File

@ -1,7 +1,52 @@
import { describe, expect, it, vi } from 'vitest'
import { buildOpenWithItems, type OpenWithContext, type OpenWithDeps } from './openWithItems'
import { buildOpenWithItems, describeFileType, type OpenWithContext, type OpenWithDeps } from './openWithItems'
import type { OpenTarget } from '../stores/openTargetStore'
// ──────────────────────────────────────────────────────────────────────────────
// describeFileType tests
// ──────────────────────────────────────────────────────────────────────────────
describe('describeFileType', () => {
it('markdown → document icon, document categoryKey, uppercased ext', () => {
expect(describeFileType('a.md')).toEqual({
icon: 'description',
categoryKey: 'openWith.fileType.document',
ext: 'MD',
})
})
it('HTML (uppercase path) → web icon, web categoryKey', () => {
expect(describeFileType('x.HTML')).toEqual({
icon: 'html',
categoryKey: 'openWith.fileType.web',
ext: 'HTML',
})
})
it('png → image icon, image categoryKey', () => {
expect(describeFileType('y.png')).toEqual({
icon: 'image',
categoryKey: 'openWith.fileType.image',
ext: 'PNG',
})
})
it('tsx → code icon, code categoryKey', () => {
expect(describeFileType('z.tsx')).toEqual({
icon: 'code',
categoryKey: 'openWith.fileType.code',
ext: 'TSX',
})
})
it('unknown extension → generic file icon, file categoryKey', () => {
expect(describeFileType('w.bin')).toEqual({
icon: 'insert_drive_file',
categoryKey: 'openWith.fileType.file',
ext: 'BIN',
})
})
})
function makeT() {
return (key: string, vars?: Record<string, string>) =>
vars?.target != null ? `${key}:${vars.target}` : key

View File

@ -1,5 +1,26 @@
import type { OpenTarget } from '../stores/openTargetStore'
// ─── File-type description ────────────────────────────────────────────────────
export type FileTypeInfo = { icon: string; categoryKey: string; ext: string }
const FILE_TYPE_RULES: Array<{ re: RegExp; key: string; icon: string }> = [
{ re: /\.(md|markdown|txt|rst)$/i, key: 'document', icon: 'description' },
{ re: /\.(html?|xhtml)$/i, key: 'web', icon: 'html' },
{ re: /\.(png|jpe?g|gif|svg|webp|avif|bmp|ico)$/i, key: 'image', icon: 'image' },
{ re: /\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|less|py|rs|go|java|rb|php|c|cc|cpp|h|hpp|sh|ya?ml|toml)$/i, key: 'code', icon: 'code' },
]
export function describeFileType(path: string): FileTypeInfo {
const ext = (path.split('.').pop() ?? '').toUpperCase()
for (const rule of FILE_TYPE_RULES) {
if (rule.re.test(path)) return { icon: rule.icon, categoryKey: `openWith.fileType.${rule.key}`, ext }
}
return { icon: 'insert_drive_file', categoryKey: 'openWith.fileType.file', ext }
}
// ─── Open-with items ──────────────────────────────────────────────────────────
export type OpenWithIcon = 'in-app-browser' | 'system' | 'ide' | 'file-manager' | 'preview'
export type OpenWithItem = {