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( () => 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, 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 (
{t('chat.turnChangesTitle', { count: files.length })} +{checkpoint.code.insertions} -{checkpoint.code.deletions}
{subtitle}
{visibleFiles.map((fileEntry) => { const fileName = fileEntry.displayPath.split('/').pop() || fileEntry.displayPath const typeInfo = describeFileType(fileEntry.displayPath) const previewable = isPreviewableChangedFile(fileEntry.displayPath) return (
{previewable && ( )}
) })}
{canCollapse && ( )} {error && (
{error}
)} {openWith && setOpenWith(null)} />}
) } 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 }