cc-haha/desktop/src/components/chat/CurrentTurnChangeCard.tsx
程序员阿江(Relakkes) 7ca702a253 fix(desktop): simplify changed-file open-with menus
Changed-file rows are easier to scan when previewable files are grouped before source-only files, and the file open-with menu should only expose useful preview, editor, and reveal actions. Output target cards also no longer need a separate copy control now that open/open-with are the primary actions.

Constraint: Keep the existing open-with API and menu components shared across changed files, workspace context menus, and output target cards.
Rejected: Keep system-default open for files | it duplicated less predictable platform behavior and added a low-value final menu item.
Confidence: high
Scope-risk: narrow
Directive: File open-with menus should stay ordered by immediate preview/open usefulness before editor and reveal actions.
Tested: cd desktop && bun run test src/lib/openWithItems.test.ts src/components/workspace/WorkspaceFileOpenWith.test.tsx src/components/chat/AssistantOutputTargetCard.test.tsx src/components/chat/CurrentTurnChangeCard.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: git diff --check
2026-06-01 01:43:36 +08:00

227 lines
11 KiB
TypeScript

import { useCallback, useMemo, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import type { SessionTurnCheckpoint } from '../../api/sessions'
import { useTranslation, type TranslationKey } from '../../i18n'
import { OpenWithMenu } from '../common/OpenWithMenu'
import { buildOpenWithItems, describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems'
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
type CurrentTurnChangeCardProps = {
sessionId: string
checkpoint: SessionTurnCheckpoint
workDir: string | null
error: string | null
isUndoing: boolean
isLatest: boolean
onUndo: () => void
}
type ChangedFileEntry = {
apiPath: string
displayPath: string
}
const COLLAPSED_COUNT = 5
export function CurrentTurnChangeCard({
sessionId,
checkpoint,
workDir,
error,
isUndoing,
isLatest,
onUndo,
}: CurrentTurnChangeCardProps) {
const t = useTranslation()
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect; triggerEl: HTMLElement } | null>(null)
const [showAllFiles, setShowAllFiles] = useState(false)
const files = useMemo<ChangedFileEntry[]>(
() => checkpoint.code.filesChanged
.map((filePath) => ({
apiPath: filePath,
displayPath: relativizeWorkspacePath(filePath, workDir),
}))
.sort((a, b) => Number(isPreviewableChangedFile(b.displayPath)) - Number(isPreviewableChangedFile(a.displayPath))),
[checkpoint.code.filesChanged, workDir],
)
const canCollapse = files.length > COLLAPSED_COUNT
const visibleFiles = canCollapse && !showAllFiles
? files.slice(0, COLLAPSED_COUNT)
: files
const openDiffInWorkspace = useCallback((fileEntry: ChangedFileEntry) => {
// Jump to the right-side workspace and open a diff tab. We pass the workDir-relative
// path (same format the workspace file tree passes to openPreview), so the diff tab
// is keyed/fetched identically to the tree-driven one.
void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'diff')
}, [sessionId])
const handleOpenWith = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
event.stopPropagation()
// Toggle: if the menu is already open, a second click on the trigger closes it
// (the OpenWithMenu's outside-mousedown handler excludes the trigger, so its
// own click is the only thing that can close it on re-click).
if (openWith) {
setOpenWith(null)
return
}
const triggerEl = event.currentTarget
const rect = triggerEl.getBoundingClientRect()
void (async () => {
await useOpenTargetStore.getState().ensureTargets()
const targets = useOpenTargetStore.getState().targets
const ctx = openWithContextForWorkspaceFile(fileEntry.displayPath, fileEntry.apiPath, {
sessionId,
serverBaseUrl: getServerBaseUrl(),
})
const items = buildOpenWithItems(ctx, targets, {
openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url),
openSystem: (p) => { void import('@tauri-apps/plugin-shell').then((m) => m.open(p)).catch(() => {}) },
openWorkspacePreview: (rel) => { void useWorkspacePanelStore.getState().openPreview(sessionId, rel, 'file') },
openTarget: (id, abs) => { void useOpenTargetStore.getState().openTarget(id, abs) },
t: (k, v) => t(k as TranslationKey, v),
})
setOpenWith({ items, anchor: rect, triggerEl })
})()
}, [openWith, sessionId, t])
const cardLabel = isLatest
? t('chat.turnChangesLatestCardLabel')
: t('chat.turnChangesHistoricalCardLabel')
const subtitle = isLatest
? t('chat.turnChangesLatestSubtitle')
: t('chat.turnChangesHistoricalSubtitle')
const undoLabel = isLatest
? t('chat.turnChangesLatestUndo')
: t('chat.turnChangesHistoricalUndo')
const undoAria = isLatest
? t('chat.turnChangesLatestUndoAria')
: t('chat.turnChangesHistoricalUndoAria')
return (
<section
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={cardLabel}
>
<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">
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('chat.turnChangesTitle', { count: files.length })}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-success)]">
+{checkpoint.code.insertions}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-error)]">
-{checkpoint.code.deletions}
</span>
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
{subtitle}
</div>
</div>
<button
type="button"
onClick={onUndo}
disabled={isUndoing}
aria-label={undoAria}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/40 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[15px]">undo</span>
{isUndoing ? t('chat.turnChangesUndoing') : undoLabel}
</button>
</div>
<div className="divide-y divide-[var(--color-border)]">
{visibleFiles.map((fileEntry) => {
const fileName = fileEntry.displayPath.split('/').pop() || fileEntry.displayPath
const typeInfo = describeFileType(fileEntry.displayPath)
const previewable = isPreviewableChangedFile(fileEntry.displayPath)
return (
<div key={fileEntry.apiPath} className="flex items-center gap-2">
<button
type="button"
onClick={() => openDiffInWorkspace(fileEntry)}
aria-label={t('chat.turnChangesOpenInWorkspaceAria', { path: fileEntry.displayPath })}
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-[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>
{!previewable && (
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)]">chevron_right</span>
)}
</button>
{previewable && (
<button
type="button"
aria-label={t('openWith.title')}
onClick={(event) => handleOpenWith(event, fileEntry)}
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"
>
{t('openWith.title')}
<ChevronDown size={14} strokeWidth={1.9} />
</button>
)}
</div>
)
})}
</div>
{canCollapse && (
<button
type="button"
onClick={() => setShowAllFiles((current) => !current)}
className="flex w-full items-center justify-center gap-1 border-t border-[var(--color-border)] px-4 py-2 text-xs 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-inset focus-visible:ring-[var(--color-brand)]/35"
>
{showAllFiles ? (
<>
{t('chat.turnChangesShowLess')}
<ChevronUp size={14} strokeWidth={1.9} />
</>
) : (
<>
{t('chat.turnChangesShowMore', { count: String(files.length - COLLAPSED_COUNT) })}
<ChevronDown size={14} strokeWidth={1.9} />
</>
)}
</button>
)}
{error && (
<div className="border-t border-[var(--color-error)]/20 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{error}
</div>
)}
{openWith && <OpenWithMenu items={openWith.items} anchor={openWith.anchor} triggerEl={openWith.triggerEl} onClose={() => setOpenWith(null)} />}
</section>
)
}
export function relativizeWorkspacePath(filePath: string, workDir: string | null): string {
const normalizedPath = filePath.replace(/\\/g, '/')
const isAbsolute = normalizedPath.startsWith('/') || /^[a-zA-Z]:\//.test(normalizedPath)
if (!workDir || !isAbsolute) return normalizedPath
const normalizedWorkDir = workDir.replace(/\\/g, '/').replace(/\/+$/, '')
const comparablePath = normalizedPath.toLowerCase()
const comparableWorkDir = normalizedWorkDir.toLowerCase()
if (comparablePath === comparableWorkDir) return ''
if (comparablePath.startsWith(`${comparableWorkDir}/`)) {
return normalizedPath.slice(normalizedWorkDir.length + 1)
}
return normalizedPath
}