mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
227 lines
11 KiB
TypeScript
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
|
|
}
|