mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
4f03b8f6e6
commit
81ed3e2530
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '已更改文件',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user