feat: add last-turn diff view
Adds a Last turn scope to DiffView that renders OpenCode snapshot diffs from the latest user message summary without re-fetching git contents. The view hides Review in that mode and carries the selected diff scope through main and context-panel navigation. Connects latest-turn changed-file chips in chat to the snapshot diff view on desktop and mobile, while keeping older turn chips static/read-only to avoid misleading affordances and extra subscriptions. Updates localized labels and empty states plus changelog. Validation: bun run type-check (packages/ui); bun run lint (packages/ui).
This commit is contained in:
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Diff/Chat: added a Last turn mode to the Diff view, and latest-turn changed-file chips in chat now open that snapshot while older turn chips stay read-only.
|
||||
|
||||
## [1.14.1] - 2026-07-07
|
||||
|
||||
- Chat: finished agent replies can now show a short recap and a suggested next message, with separate settings for each and a Small Model setting for choosing the utility model used for those helpers.
|
||||
|
||||
@@ -752,6 +752,7 @@ const TurnBlock = React.memo(({
|
||||
activityOwnerMessageId,
|
||||
isFirstAssistantInTurn: isFirstAssistant,
|
||||
isLastAssistantInTurn: isLastAssistant,
|
||||
isLatestTurn: isLastTurn,
|
||||
isWorking: isLastTurn && sessionIsWorking && (
|
||||
chatRenderMode === 'sorted'
|
||||
? hasAnchoredActivitySegment
|
||||
|
||||
@@ -4,13 +4,11 @@ import { Popover } from '@base-ui/react/popover';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import {
|
||||
type ChangedFile,
|
||||
type ChangedFileEntry,
|
||||
FILE_EDIT_TOOLS,
|
||||
extractChangedFiles,
|
||||
isGitFile,
|
||||
toRelativePath,
|
||||
} from './changedFiles';
|
||||
import { ChangedFilesList } from './ChangedFilesList';
|
||||
@@ -18,7 +16,6 @@ import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './change
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { TurnActivityRecord } from './lib/turns/types';
|
||||
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
interface TurnChangedFilesDropdownProps {
|
||||
activityParts: TurnActivityRecord[] | undefined;
|
||||
@@ -29,7 +26,6 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
|
||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||
const triggerButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const isGitRepo = useIsGitRepo(currentDirectory);
|
||||
|
||||
const changedFiles = React.useMemo<ChangedFile[]>(() => {
|
||||
@@ -56,24 +52,16 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
|
||||
|
||||
const handleOpenFile = (file: ChangedFileEntry) => {
|
||||
if (!currentDirectory) return;
|
||||
if (isGitFile(file)) return;
|
||||
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, file.path);
|
||||
|
||||
const editor = runtime?.editor;
|
||||
if (editor) {
|
||||
void editor.openFile(absolutePath);
|
||||
setIsExpanded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useUIStore.getState();
|
||||
const relativePath = toRelativePath(file.path, currentDirectory);
|
||||
if (!store.isMobile) {
|
||||
store.openContextFile(currentDirectory, absolutePath);
|
||||
store.openContextDiff(currentDirectory, relativePath, false, 'turn');
|
||||
setIsExpanded(false);
|
||||
return;
|
||||
}
|
||||
store.navigateToDiff(toRelativePath(file.path, currentDirectory));
|
||||
|
||||
store.navigateToDiff(relativePath, false, 'turn');
|
||||
store.setRightSidebarOpen(false);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface TurnGroupingContext {
|
||||
activityOwnerMessageId?: string;
|
||||
isFirstAssistantInTurn: boolean;
|
||||
isLastAssistantInTurn: boolean;
|
||||
isLatestTurn: boolean;
|
||||
summaryBody?: string;
|
||||
activityParts?: TurnActivityRecord[];
|
||||
activityGroupSegments?: TurnActivityGroup[];
|
||||
|
||||
@@ -65,37 +65,88 @@ const getDisplayFileName = (file: string): string => {
|
||||
return segments.at(-1) ?? file;
|
||||
};
|
||||
|
||||
const TurnChangedFilePills = React.memo(({ files }: { files?: TurnChangedFile[] }) => {
|
||||
if (!files || files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const TurnChangedFileChipContent = React.memo(({ file, interactive = false }: { file: TurnChangedFile; interactive?: boolean }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs leading-[1.35] text-muted-foreground',
|
||||
interactive && 'transition-colors hover:border-border/60 hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
|
||||
</span>
|
||||
</span>
|
||||
));
|
||||
|
||||
const TurnChangedFilePillButton = React.memo(({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: TurnChangedFile;
|
||||
onOpen: (file: string) => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 max-w-full cursor-pointer items-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={t('chat.changedFiles.actions.openFileTitle', { path: file.file })}
|
||||
title={file.file}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpen(file.file);
|
||||
}}
|
||||
>
|
||||
<TurnChangedFileChipContent file={file} interactive />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
const StaticTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => (
|
||||
<>
|
||||
{files.map((file) => (
|
||||
<span key={file.file} className="inline-flex h-8 max-w-full items-center" title={file.file}>
|
||||
<TurnChangedFileChipContent file={file} />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
));
|
||||
|
||||
const InteractiveTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => {
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
||||
const openContextDiff = useUIStore((state) => state.openContextDiff);
|
||||
|
||||
const openLastTurnDiff = React.useCallback((file: string) => {
|
||||
if (!isMobile && effectiveDirectory) {
|
||||
openContextDiff(effectiveDirectory, file, false, 'turn');
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToDiff(file, false, 'turn');
|
||||
}, [effectiveDirectory, isMobile, navigateToDiff, openContextDiff]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{files.map((file) => {
|
||||
return (
|
||||
<Tooltip key={file.file}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex h-8 max-w-full items-center">
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs leading-[1.35] text-muted-foreground">
|
||||
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{file.file}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{files.map((file) => (
|
||||
<TurnChangedFilePillButton key={file.file} file={file} onOpen={openLastTurnDiff} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const TurnChangedFilePills = React.memo(({ files, isInteractive }: { files?: TurnChangedFile[]; isInteractive: boolean }) => {
|
||||
if (!files || files.length === 0) return null;
|
||||
|
||||
return isInteractive ? <InteractiveTurnChangedFilePills files={files} /> : <StaticTurnChangedFilePills files={files} />;
|
||||
});
|
||||
|
||||
type SubtaskPartLike = Part & {
|
||||
type: 'subtask';
|
||||
description?: unknown;
|
||||
@@ -2079,7 +2130,10 @@ const AssistantMessageBody = React.memo(({
|
||||
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||
) : null}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilePills files={turnGroupingContext?.changedFiles} />
|
||||
<TurnChangedFilePills
|
||||
files={turnGroupingContext?.changedFiles}
|
||||
isInteractive={turnGroupingContext?.isLatestTurn === true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -280,6 +280,7 @@ export const areRelevantTurnGroupingContextsEqual = (
|
||||
if (left.turnId !== right.turnId) return false;
|
||||
if (left.isFirstAssistantInTurn !== right.isFirstAssistantInTurn) return false;
|
||||
if (left.isLastAssistantInTurn !== right.isLastAssistantInTurn) return false;
|
||||
if (left.isLatestTurn !== right.isLatestTurn) return false;
|
||||
if (left.isWorking !== right.isWorking) return false;
|
||||
if (left.hasTools !== right.hasTools) return false;
|
||||
if (left.hasReasoning !== right.hasReasoning) return false;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore';
|
||||
import { useUIStore, type ContextPanelMode, type PendingDiffScope } from '@/stores/useUIStore';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
@@ -2298,7 +2298,7 @@ export const ContextPanel: React.FC = () => {
|
||||
}
|
||||
}, [tabs]);
|
||||
|
||||
const handleDiffScopeChange = React.useCallback((nextScope: 'working' | 'staged') => {
|
||||
const handleDiffScopeChange = React.useCallback((nextScope: PendingDiffScope) => {
|
||||
if (!directoryKey || activeTab?.mode !== 'diff') {
|
||||
return;
|
||||
}
|
||||
@@ -2307,6 +2307,7 @@ export const ContextPanel: React.FC = () => {
|
||||
mode: 'diff',
|
||||
targetPath: activeTab.targetPath,
|
||||
stagedDiff: nextScope === 'staged',
|
||||
diffScope: nextScope,
|
||||
});
|
||||
}, [activeTab, directoryKey, openContextPanelTab]);
|
||||
|
||||
@@ -2649,7 +2650,7 @@ export const ContextPanel: React.FC = () => {
|
||||
stackedDefaultCollapsedAll
|
||||
pinSelectedFileHeaderToTopOnNavigate
|
||||
showOpenInEditorAction
|
||||
diffScope={tab.stagedDiff ? 'staged' : 'working'}
|
||||
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
|
||||
onDiffScopeChange={handleDiffScopeChange}
|
||||
targetFilePath={tab.targetPath}
|
||||
flushContent
|
||||
|
||||
@@ -39,6 +39,7 @@ import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils';
|
||||
import type { FileDiffMetadata } from '@pierre/diffs';
|
||||
|
||||
@@ -74,7 +75,17 @@ type DiffData = {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
contextMode?: DiffContextMode;
|
||||
};
|
||||
type DiffScope = 'all' | 'staged' | 'working';
|
||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn';
|
||||
|
||||
type TurnSnapshotDiff = {
|
||||
file?: string;
|
||||
status?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
patch?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
};
|
||||
|
||||
const BinaryDiffPlaceholder = React.memo(() => {
|
||||
const { t } = useI18n();
|
||||
@@ -164,6 +175,20 @@ const getFirstChangedModifiedLine = (original: string, modified: string): number
|
||||
const isBinaryPatch = (patch: string): boolean =>
|
||||
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
|
||||
|
||||
const listTurnDiffs = (value: unknown): TurnSnapshotDiff[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((diff): diff is TurnSnapshotDiff => {
|
||||
if (!diff || typeof diff !== 'object') return false;
|
||||
return typeof (diff as TurnSnapshotDiff).file === 'string';
|
||||
});
|
||||
};
|
||||
|
||||
const statusToGitCode = (status?: string): string => {
|
||||
if (status === 'added') return 'A';
|
||||
if (status === 'deleted') return 'D';
|
||||
return 'M';
|
||||
};
|
||||
|
||||
const createTextDiffDataFromPatch = (filePath: string, patch: string, contextMode: DiffContextMode): DiffData => {
|
||||
if (isBinaryPatch(patch)) {
|
||||
return { original: '', modified: '', isBinary: true, patch, contextMode };
|
||||
@@ -201,22 +226,28 @@ const formatDiffTotals = (
|
||||
};
|
||||
|
||||
interface ChangeScopeSelectorProps {
|
||||
scope: Extract<DiffScope, 'working' | 'staged'>;
|
||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>;
|
||||
workingCount: number;
|
||||
stagedCount: number;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged'>) => void;
|
||||
turnCount: number;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
||||
}
|
||||
|
||||
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
scope,
|
||||
workingCount,
|
||||
stagedCount,
|
||||
turnCount,
|
||||
onScopeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const currentCount = scope === 'staged' ? stagedCount : workingCount;
|
||||
const currentLabel = scope === 'staged' ? t('diffView.scope.staged') : t('diffView.scope.changed');
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount;
|
||||
const currentLabel = scope === 'staged'
|
||||
? t('diffView.scope.staged')
|
||||
: scope === 'turn'
|
||||
? t('diffView.scope.lastTurn')
|
||||
: t('diffView.scope.changed');
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -236,7 +267,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'working' || value === 'staged') {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn') {
|
||||
onScopeChange?.(value);
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -254,6 +285,12 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<span className="typography-meta text-muted-foreground">{stagedCount}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="turn">
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span>{t('diffView.scope.lastTurn')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{turnCount}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -532,6 +569,7 @@ interface MultiFileDiffEntryProps {
|
||||
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
|
||||
staged?: boolean;
|
||||
loadFullFiles?: boolean;
|
||||
initialDiffData?: DiffData | null;
|
||||
}
|
||||
|
||||
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
@@ -550,6 +588,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
onOpenInEditor,
|
||||
staged = false,
|
||||
loadFullFiles = false,
|
||||
initialDiffData = null,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { git } = useRuntimeAPIs();
|
||||
@@ -578,11 +617,12 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
const fileStatusKey = `${file.index}:${file.working_dir}:${file.insertions}:${file.deletions}`;
|
||||
|
||||
const diffData = React.useMemo<DiffData | null>(() => {
|
||||
if (initialDiffData) return initialDiffData;
|
||||
if (staged) return stagedDiffData;
|
||||
if (localDiffData) return localDiffData;
|
||||
if (!cachedDiff) return null;
|
||||
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary, contextMode: 'full' };
|
||||
}, [cachedDiff, localDiffData, staged, stagedDiffData]);
|
||||
}, [cachedDiff, initialDiffData, localDiffData, staged, stagedDiffData]);
|
||||
|
||||
const diffDataMatchesContextMode = diffData?.contextMode === desiredContextMode;
|
||||
|
||||
@@ -612,7 +652,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded || !isMounted) return;
|
||||
if (!directory || (diffData && diffDataMatchesContextMode)) {
|
||||
if (!directory || initialDiffData || (diffData && diffDataMatchesContextMode)) {
|
||||
lastDiffRequestRef.current = null;
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -675,7 +715,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
lastDiffRequestRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
|
||||
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
handleOpenChange(!isExpanded);
|
||||
@@ -900,7 +940,7 @@ interface DiffViewProps {
|
||||
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
||||
showOpenInEditorAction?: boolean;
|
||||
diffScope?: DiffScope;
|
||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged'>) => void;
|
||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
||||
targetFilePath?: string | null;
|
||||
/** Render diff content flush with the container edges (no outer padding). */
|
||||
flushContent?: boolean;
|
||||
@@ -937,9 +977,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0);
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false);
|
||||
const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false);
|
||||
const [activeDiffScope, setActiveDiffScope] = React.useState(diffScope);
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveDiffScope(diffScope);
|
||||
}, [diffScope]);
|
||||
|
||||
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
|
||||
const pendingDiffStaged = useUIStore((state) => state.pendingDiffStaged);
|
||||
const pendingDiffScope = useUIStore((state) => state.pendingDiffScope);
|
||||
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
|
||||
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
|
||||
const diffFileLayout = useUIStore((state) => state.diffFileLayout);
|
||||
@@ -948,12 +994,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
|
||||
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory ?? undefined);
|
||||
const diffWrapLines = diffWrapLinesStore;
|
||||
const forcedStaged = diffScope === 'staged' ? true : diffScope === 'working' ? false : null;
|
||||
const forcedStaged = activeDiffScope === 'staged' ? true : activeDiffScope === 'working' ? false : null;
|
||||
const activeDiffStaged = forcedStaged ?? displayFileStaged;
|
||||
|
||||
const isMobileLayout = isMobile || screenWidth <= 768;
|
||||
const showReviewAction = Boolean(currentSessionId) && !isMobileLayout && !isVSCodeRuntime();
|
||||
const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
||||
const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024;
|
||||
const diffScrollRef = React.useRef<HTMLElement | null>(null);
|
||||
const fileSectionRefs = React.useRef(new Map<string, HTMLDivElement | null>());
|
||||
@@ -984,12 +1031,52 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const lastTurnDiffs = React.useMemo(() => {
|
||||
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = sessionMessages[index] as { role?: string; summary?: { diffs?: unknown } };
|
||||
if (message.role !== 'user') continue;
|
||||
return listTurnDiffs(message.summary?.diffs);
|
||||
}
|
||||
return [];
|
||||
}, [sessionMessages]);
|
||||
|
||||
const lastTurnDiffData = React.useMemo(() => {
|
||||
const map = new Map<string, DiffData>();
|
||||
for (const diff of lastTurnDiffs) {
|
||||
if (!diff.file) continue;
|
||||
if (typeof diff.patch === 'string') {
|
||||
map.set(diff.file, createTextDiffDataFromPatch(diff.file, diff.patch, 'patch'));
|
||||
continue;
|
||||
}
|
||||
map.set(diff.file, {
|
||||
original: diff.before ?? '',
|
||||
modified: diff.after ?? '',
|
||||
contextMode: 'full',
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [lastTurnDiffs]);
|
||||
|
||||
const changedFiles: FileEntry[] = React.useMemo(() => {
|
||||
if (activeDiffScope === 'turn') {
|
||||
return lastTurnDiffs
|
||||
.map((diff) => ({
|
||||
path: diff.file ?? '',
|
||||
index: '',
|
||||
working_dir: statusToGitCode(diff.status),
|
||||
insertions: diff.additions ?? 0,
|
||||
deletions: diff.deletions ?? 0,
|
||||
isNew: diff.status === 'added',
|
||||
}))
|
||||
.filter((file) => file.path)
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
if (!status?.files) return [];
|
||||
const diffStats = status.diffStats ?? {};
|
||||
const includeFile = diffScope === 'staged'
|
||||
const includeFile = activeDiffScope === 'staged'
|
||||
? isStagedStatusFile
|
||||
: diffScope === 'working'
|
||||
: activeDiffScope === 'working'
|
||||
? isWorkingStatusFile
|
||||
: () => true;
|
||||
|
||||
@@ -1002,7 +1089,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
isNew: isNewStatusFile(file),
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [diffScope, status]);
|
||||
}, [activeDiffScope, lastTurnDiffs, status]);
|
||||
|
||||
const workingFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
@@ -1014,6 +1101,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return status.files.filter(isStagedStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const turnFileCount = lastTurnDiffs.length;
|
||||
|
||||
const changedFilePathsKey = React.useMemo(
|
||||
() => changedFiles.map((file) => file.path).join('\0'),
|
||||
[changedFiles],
|
||||
@@ -1022,7 +1111,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
React.useEffect(() => {
|
||||
const paths = changedFilePathsKey ? changedFilePathsKey.split('\0') : [];
|
||||
const pathSet = new Set(paths);
|
||||
const scopeKey = `${effectiveDirectory ?? ''}:${diffScope}:${stackedDefaultCollapsedAll ? 'collapsed' : 'default'}`;
|
||||
const scopeKey = `${effectiveDirectory ?? ''}:${activeDiffScope}:${stackedDefaultCollapsedAll ? 'collapsed' : 'default'}`;
|
||||
const shouldInitialize = stackedStateScopeRef.current !== scopeKey;
|
||||
stackedStateScopeRef.current = scopeKey;
|
||||
|
||||
@@ -1062,7 +1151,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}
|
||||
return changed ? next : previous;
|
||||
});
|
||||
}, [changedFilePathsKey, diffScope, effectiveDirectory, stackedDefaultCollapsedAll]);
|
||||
}, [activeDiffScope, changedFilePathsKey, effectiveDirectory, stackedDefaultCollapsedAll]);
|
||||
|
||||
const syncVisibleStackedFiles = React.useCallback(() => {
|
||||
visibleSyncFrameRef.current = null;
|
||||
@@ -1167,23 +1256,26 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
// Handle pending diff file from external navigation
|
||||
React.useEffect(() => {
|
||||
if (diffScope !== 'all') {
|
||||
if (activeDiffScope !== 'all' && !pendingDiffScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingDiffFile) {
|
||||
if (pendingDiffScope) {
|
||||
setActiveDiffScope(pendingDiffScope);
|
||||
}
|
||||
setDisplayFile(pendingDiffFile);
|
||||
setDisplayFileStaged(pendingDiffStaged);
|
||||
setDisplayFileStaged(pendingDiffScope === 'staged' || (!pendingDiffScope && pendingDiffStaged));
|
||||
setPendingDiffFile(null);
|
||||
shouldPinAfterAlignRef.current = true;
|
||||
pendingScrollTargetRef.current = pendingDiffFile;
|
||||
expandStackedFile(pendingDiffFile);
|
||||
setScrollRequestNonce((value) => value + 1);
|
||||
}
|
||||
}, [diffScope, expandStackedFile, pendingDiffFile, pendingDiffStaged, setPendingDiffFile]);
|
||||
}, [activeDiffScope, expandStackedFile, pendingDiffFile, pendingDiffScope, pendingDiffStaged, setPendingDiffFile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (diffScope === 'all') {
|
||||
if (activeDiffScope === 'all') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1193,13 +1285,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}
|
||||
|
||||
setDisplayFile(normalizedTarget);
|
||||
setDisplayFileStaged(diffScope === 'staged');
|
||||
setDisplayFileStaged(activeDiffScope === 'staged');
|
||||
|
||||
shouldPinAfterAlignRef.current = true;
|
||||
pendingScrollTargetRef.current = normalizedTarget;
|
||||
expandStackedFile(normalizedTarget);
|
||||
setScrollRequestNonce((value) => value + 1);
|
||||
}, [diffScope, expandStackedFile, targetFilePath]);
|
||||
}, [activeDiffScope, expandStackedFile, targetFilePath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!displayFile) {
|
||||
@@ -1508,13 +1600,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
onSelect={handleSelectFile}
|
||||
onExpandedChange={handleStackedEntryExpandedChange}
|
||||
registerSectionRef={registerSectionRef}
|
||||
showOpenInEditorAction={showOpenInEditorAction}
|
||||
showOpenInEditorAction={showOpenInEditorAction && activeDiffScope !== 'turn'}
|
||||
isOpeningInEditor={openingEditorFilePath === file.path}
|
||||
onOpenInEditor={(filePath, diffData) => {
|
||||
void openFileInEditorAtChange(filePath, diffData);
|
||||
}}
|
||||
staged={getFileStaged(file.path)}
|
||||
loadFullFiles={loadFullFiles}
|
||||
initialDiffData={activeDiffScope === 'turn' ? lastTurnDiffData.get(file.path) ?? null : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1534,7 +1627,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingStatus && !status) {
|
||||
if (activeDiffScope !== 'turn' && isLoadingStatus && !status) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
@@ -1543,7 +1636,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isGitRepo === false) {
|
||||
if (activeDiffScope !== 'turn' && isGitRepo === false) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t('diffView.state.notGitRepository')}
|
||||
@@ -1554,7 +1647,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
if (changedFiles.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t('diffView.state.cleanWorkingTree')}
|
||||
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1566,12 +1659,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
|
||||
{!isMobile && (
|
||||
diffScope === 'working' || diffScope === 'staged' ? (
|
||||
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? (
|
||||
<ChangeScopeSelector
|
||||
scope={diffScope}
|
||||
scope={activeDiffScope}
|
||||
workingCount={workingFileCount}
|
||||
stagedCount={stagedFileCount}
|
||||
onScopeChange={onDiffScopeChange}
|
||||
turnCount={turnFileCount}
|
||||
onScopeChange={(scope) => {
|
||||
setActiveDiffScope(scope);
|
||||
onDiffScopeChange?.(scope);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded-md px-2 py-1 text-muted-foreground shrink-0">
|
||||
|
||||
@@ -1254,6 +1254,7 @@ export const dict = {
|
||||
'diffView.state.loadingRepositoryStatus': 'Loading repository status...',
|
||||
'diffView.state.notGitRepository': 'Not a git repository. Use the Git tab to initialize or change directories.',
|
||||
'diffView.state.cleanWorkingTree': 'Working tree clean, no changes to display',
|
||||
'diffView.state.noLastTurnChanges': 'No last turn changes to display',
|
||||
'diffView.state.failedToLoadDiff': 'Failed to load diff',
|
||||
'diffView.state.loadingDiff': 'Loading diff...',
|
||||
'diffView.state.loadingChanges': 'Loading changes...',
|
||||
@@ -1263,6 +1264,7 @@ export const dict = {
|
||||
'diffView.summary.changedFilesPlural': '{count} files changed',
|
||||
'diffView.scope.changed': 'Changed',
|
||||
'diffView.scope.staged': 'Staged',
|
||||
'diffView.scope.lastTurn': 'Last turn',
|
||||
'diffView.scope.selectorAria': 'Select change mode',
|
||||
'diffView.actions.retry': 'Retry',
|
||||
'diffView.actions.renderAnyway': 'Render anyway',
|
||||
|
||||
@@ -1220,6 +1220,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.state.loadingRepositoryStatus": "Cargando estado del repositorio...",
|
||||
"diffView.state.notGitRepository": "No es un repositorio Git. Usa la pestaña Git para inicializar o cambiar de directorio.",
|
||||
"diffView.state.cleanWorkingTree": "Worktree limpio, no hay cambios para mostrar",
|
||||
"diffView.state.noLastTurnChanges": "No hay cambios del último turno para mostrar",
|
||||
"diffView.state.failedToLoadDiff": "No se pudo cargar la diferencia",
|
||||
"diffView.state.loadingDiff": "Cargando diff...",
|
||||
"diffView.state.loadingChanges": "Cargando cambios...",
|
||||
@@ -1229,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.summary.changedFilesPlural": "{count} archivos modificados",
|
||||
"diffView.scope.changed": "Cambiados",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "Último turno",
|
||||
"diffView.scope.selectorAria": "Seleccionar modo de cambios",
|
||||
"diffView.actions.retry": "Volver a intentar",
|
||||
"diffView.actions.renderAnyway": "Renderizar de todos modos",
|
||||
|
||||
@@ -1088,6 +1088,7 @@ export const dict = {
|
||||
'diffView.state.loadingRepositoryStatus': 'Chargement de l\'état du dépôt...',
|
||||
'diffView.state.notGitRepository': 'Pas un dépôt git. Utilisez l\'onglet Git pour initialiser ou modifier des répertoires.',
|
||||
'diffView.state.cleanWorkingTree': 'Worktree sans modifications, rien à afficher',
|
||||
'diffView.state.noLastTurnChanges': 'Aucun changement du dernier tour à afficher',
|
||||
'diffView.state.failedToLoadDiff': 'Échec du chargement du différentiel',
|
||||
'diffView.state.loadingDiff': 'Chargement du différentiel...',
|
||||
'diffView.state.loadingChanges': 'Chargement des modifications...',
|
||||
@@ -1097,6 +1098,7 @@ export const dict = {
|
||||
'diffView.summary.changedFilesPlural': 'Fichiers {count} modifiés',
|
||||
"diffView.scope.changed": "Modifiés",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "Dernier tour",
|
||||
"diffView.scope.selectorAria": "Sélectionner le mode de changements",
|
||||
'diffView.actions.retry': 'Réessayer',
|
||||
'diffView.actions.renderAnyway': 'Afficher quand même',
|
||||
|
||||
@@ -1250,6 +1250,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.state.loadingRepositoryStatus': 'リポジトリステータスを読み込み中...',
|
||||
'diffView.state.notGitRepository': 'Gitリポジトリではありません。Gitタブを使用して初期化するかディレクトリを変更してください。',
|
||||
'diffView.state.cleanWorkingTree': 'ワーキングツリーはクリーンで、表示する変更はありません',
|
||||
'diffView.state.noLastTurnChanges': '表示する最後のターンの変更はありません',
|
||||
'diffView.state.failedToLoadDiff': '差分の読み込みに失敗しました',
|
||||
'diffView.state.loadingDiff': '差分を読み込み中...',
|
||||
'diffView.state.loadingChanges': '変更を読み込み中...',
|
||||
@@ -1259,6 +1260,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.summary.changedFilesPlural': '{count}ファイルが変更されました',
|
||||
'diffView.scope.changed': '変更済み',
|
||||
'diffView.scope.staged': 'ステージ済み',
|
||||
'diffView.scope.lastTurn': '最後のターン',
|
||||
'diffView.scope.selectorAria': '変更モードを選択',
|
||||
'diffView.actions.retry': '再試行',
|
||||
'diffView.actions.renderAnyway': 'とにかくレンダリング',
|
||||
|
||||
@@ -1257,6 +1257,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.state.loadingRepositoryStatus': '레포지토리 상태 로드 중…',
|
||||
'diffView.state.notGitRepository': 'Git 레포지토리가 아닙니다. Git 탭에서 초기화하거나 디렉터리를 변경하세요.',
|
||||
'diffView.state.cleanWorkingTree': '워킹 트리가 깨끗합니다. 표시할 변경 사항이 없습니다.',
|
||||
'diffView.state.noLastTurnChanges': '표시할 마지막 턴 변경 사항이 없습니다.',
|
||||
'diffView.state.failedToLoadDiff': '변경사항을 불러오지 못했습니다',
|
||||
'diffView.state.loadingDiff': '변경사항 불러오는 중…',
|
||||
'diffView.state.loadingChanges': '변경 사항 로드 중…',
|
||||
@@ -1266,6 +1267,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.summary.changedFilesPlural': '파일 {count}개 변경됨',
|
||||
"diffView.scope.changed": "Changed",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "마지막 턴",
|
||||
"diffView.scope.selectorAria": "변경 모드 선택",
|
||||
'diffView.actions.retry': '다시 시도',
|
||||
'diffView.actions.renderAnyway': '그래도 렌더링',
|
||||
|
||||
@@ -1537,6 +1537,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.section.files': 'Pliki',
|
||||
'diffView.selector.selectFile': 'Wybierz plik',
|
||||
'diffView.state.cleanWorkingTree': 'Drzewo robocze jest czyste, brak zmian do wyświetlenia',
|
||||
'diffView.state.noLastTurnChanges': 'Brak zmian z ostatniej tury do wyświetlenia',
|
||||
'diffView.state.failedToLoadDiff': 'Nie udało się wczytać diffu',
|
||||
'diffView.state.largeDiff': 'Duży diff ({count} zmienionych linii)',
|
||||
'diffView.state.largeDiffDescription': 'Renderowanie może być wolne. Nadal możesz wyświetlić diff przyciskiem poniżej.',
|
||||
@@ -1548,6 +1549,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.summary.changedFilesPlural': 'Zmieniono {count} plików',
|
||||
"diffView.scope.changed": "Zmienione",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "Ostatnia tura",
|
||||
"diffView.scope.selectorAria": "Wybierz tryb zmian",
|
||||
'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik',
|
||||
'directoryExplorerDialog.actions.addProject': 'Dodaj projekt',
|
||||
|
||||
@@ -1220,6 +1220,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.state.loadingRepositoryStatus": "Carregando status do repositório...",
|
||||
"diffView.state.notGitRepository": "Não é um repositório Git. Use a aba Git para inicializar ou mudar de diretório.",
|
||||
"diffView.state.cleanWorkingTree": "Worktree limpo, não há alterações para mostrar",
|
||||
"diffView.state.noLastTurnChanges": "Não há alterações do último turno para exibir",
|
||||
"diffView.state.failedToLoadDiff": "Não foi possível carregar a diferencia",
|
||||
"diffView.state.loadingDiff": "Carregando diff...",
|
||||
"diffView.state.loadingChanges": "Carregando alterações...",
|
||||
@@ -1229,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.summary.changedFilesPlural": "{count} arquivos modificados",
|
||||
"diffView.scope.changed": "Alteradas",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "Último turno",
|
||||
"diffView.scope.selectorAria": "Selecionar modo de alterações",
|
||||
"diffView.actions.retry": "Tentar novamente",
|
||||
"diffView.actions.renderAnyway": "Renderizar mesmo assim",
|
||||
|
||||
@@ -1220,6 +1220,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.state.loadingRepositoryStatus": "Завантаження статусу сховища...",
|
||||
"diffView.state.notGitRepository": "Це не Git-репозиторій. Скористайтеся вкладкою Git, щоб ініціалізувати або змінити каталог.",
|
||||
"diffView.state.cleanWorkingTree": "Worktree чистий, без змін для відображення",
|
||||
"diffView.state.noLastTurnChanges": "Немає змін останнього ходу для відображення",
|
||||
"diffView.state.failedToLoadDiff": "Не вдалося завантажити diff",
|
||||
"diffView.state.loadingDiff": "Завантаження diff...",
|
||||
"diffView.state.loadingChanges": "Завантаження змін...",
|
||||
@@ -1229,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.summary.changedFilesPlural": "Змінено файлів: {count}",
|
||||
"diffView.scope.changed": "Змінені",
|
||||
"diffView.scope.staged": "Індексовані",
|
||||
"diffView.scope.lastTurn": "Останній хід",
|
||||
"diffView.scope.selectorAria": "Вибрати режим змін",
|
||||
"diffView.actions.retry": "Повторити спробу",
|
||||
"diffView.actions.renderAnyway": "Все одно відрендерити",
|
||||
|
||||
@@ -1220,6 +1220,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.state.loadingRepositoryStatus': '正在加载仓库状态...',
|
||||
'diffView.state.notGitRepository': '当前不是 Git 仓库。请在 Git 标签中初始化或切换目录。',
|
||||
'diffView.state.cleanWorkingTree': '工作区干净,没有可显示的改动',
|
||||
'diffView.state.noLastTurnChanges': '没有可显示的上一轮更改',
|
||||
'diffView.state.failedToLoadDiff': '加载差异失败',
|
||||
'diffView.state.loadingDiff': '正在加载差异...',
|
||||
'diffView.state.loadingChanges': '正在加载变更...',
|
||||
@@ -1229,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.summary.changedFilesPlural': '{count} 个文件已变更',
|
||||
"diffView.scope.changed": "已更改",
|
||||
"diffView.scope.staged": "已暂存",
|
||||
"diffView.scope.lastTurn": "上一轮",
|
||||
"diffView.scope.selectorAria": "选择更改模式",
|
||||
'diffView.actions.retry': '重试',
|
||||
'diffView.actions.renderAnyway': '仍然渲染',
|
||||
|
||||
@@ -1230,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.state.loadingRepositoryStatus': '正在載入儲存庫狀態...',
|
||||
'diffView.state.notGitRepository': '目前不是 Git 儲存庫。請在 Git 標籤中初始化或切換目錄。',
|
||||
'diffView.state.cleanWorkingTree': '工作區乾淨,沒有可顯示的改動',
|
||||
'diffView.state.noLastTurnChanges': '沒有可顯示的上一輪變更',
|
||||
'diffView.state.failedToLoadDiff': '載入差異失敗',
|
||||
'diffView.state.loadingDiff': '正在載入差異...',
|
||||
'diffView.state.loadingChanges': '正在載入變更...',
|
||||
@@ -1239,6 +1240,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.summary.changedFilesPlural': '{count} 個檔案已變更',
|
||||
"diffView.scope.changed": "已變更",
|
||||
"diffView.scope.staged": "已暫存",
|
||||
"diffView.scope.lastTurn": "上一輪",
|
||||
"diffView.scope.selectorAria": "選擇變更模式",
|
||||
'diffView.actions.retry': '重試',
|
||||
'diffView.actions.renderAnyway': '仍然渲染',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobi
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
|
||||
export type PendingDiffScope = 'working' | 'staged' | 'turn';
|
||||
export type RightSidebarTab = 'git' | 'files' | 'context';
|
||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser';
|
||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||
@@ -34,6 +35,7 @@ type ContextPanelTab = {
|
||||
sessionTitleFallback: string | null;
|
||||
readOnly: boolean;
|
||||
stagedDiff: boolean;
|
||||
diffScope: PendingDiffScope | null;
|
||||
touchedAt: number;
|
||||
};
|
||||
|
||||
@@ -45,6 +47,7 @@ type ContextPanelTabDescriptor = {
|
||||
sessionTitleFallback?: string | null;
|
||||
readOnly?: boolean;
|
||||
stagedDiff?: boolean;
|
||||
diffScope?: PendingDiffScope | null;
|
||||
};
|
||||
|
||||
type ContextPanelDirectoryState = {
|
||||
@@ -177,6 +180,10 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
|
||||
: trimmed;
|
||||
};
|
||||
|
||||
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
|
||||
return value === 'working' || value === 'staged' || value === 'turn' ? value : null;
|
||||
};
|
||||
|
||||
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
|
||||
if (mode === 'file') {
|
||||
return targetPath || mode;
|
||||
@@ -228,6 +235,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
|
||||
sessionTitleFallback: normalizeContextTabLabel(descriptor.sessionTitleFallback),
|
||||
readOnly: descriptor.readOnly === true,
|
||||
stagedDiff: descriptor.stagedDiff === true,
|
||||
diffScope: normalizePendingDiffScope(descriptor.diffScope) ?? (descriptor.stagedDiff === true ? 'staged' : 'working'),
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
@@ -269,6 +277,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
sessionTitleFallback?: unknown;
|
||||
readOnly?: unknown;
|
||||
stagedDiff?: unknown;
|
||||
diffScope?: unknown;
|
||||
touchedAt?: unknown;
|
||||
};
|
||||
|
||||
@@ -297,6 +306,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
sessionTitleFallback: normalizeContextTabLabel(typeof candidate.sessionTitleFallback === 'string' ? candidate.sessionTitleFallback : null),
|
||||
readOnly: candidate.readOnly === true,
|
||||
stagedDiff: candidate.stagedDiff === true,
|
||||
diffScope: normalizePendingDiffScope(candidate.diffScope) ?? (candidate.stagedDiff === true ? 'staged' : 'working'),
|
||||
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
||||
? candidate.touchedAt
|
||||
: Date.now(),
|
||||
@@ -357,6 +367,7 @@ const upsertContextPanelTab = (
|
||||
label: nextTab.label,
|
||||
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
|
||||
stagedDiff: nextTab.stagedDiff,
|
||||
diffScope: nextTab.diffScope,
|
||||
readOnly: nextTab.readOnly,
|
||||
touchedAt: Date.now(),
|
||||
}
|
||||
@@ -536,6 +547,7 @@ interface UIStore {
|
||||
sidebarOpenBeforeFullscreenTab: boolean | null;
|
||||
pendingDiffFile: string | null;
|
||||
pendingDiffStaged: boolean;
|
||||
pendingDiffScope: PendingDiffScope | null;
|
||||
pendingDiagramFile: string | null;
|
||||
pendingFileNavigation: PendingFileNavigation | null;
|
||||
pendingFileFocusPath: string | null;
|
||||
@@ -656,7 +668,7 @@ interface UIStore {
|
||||
setRightSidebarWidth: (width: number) => void;
|
||||
setRightSidebarTab: (tab: RightSidebarTab) => void;
|
||||
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
|
||||
openContextDiff: (directory: string, filePath: string, staged?: boolean) => void;
|
||||
openContextDiff: (directory: string, filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
|
||||
openContextFile: (directory: string, filePath: string) => void;
|
||||
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
|
||||
openContextOverview: (directory: string) => void;
|
||||
@@ -682,11 +694,11 @@ interface UIStore {
|
||||
prepareForRuntimeSwitch: (runtimeKey?: string | null) => void;
|
||||
restoreForRuntimeSwitch: (runtimeKey?: string | null) => void;
|
||||
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
||||
setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
|
||||
setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void;
|
||||
setPendingDiagramFile: (filePath: string | null) => void;
|
||||
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
|
||||
setPendingFileFocusPath: (path: string | null) => void;
|
||||
navigateToDiff: (filePath: string, staged?: boolean) => void;
|
||||
navigateToDiff: (filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
|
||||
consumePendingDiffFile: () => string | null;
|
||||
navigateToDiagram: (filePath: string) => void;
|
||||
consumePendingDiagramFile: () => string | null;
|
||||
@@ -833,6 +845,7 @@ export const useUIStore = create<UIStore>()(
|
||||
sidebarOpenBeforeFullscreenTab: null,
|
||||
pendingDiffFile: null,
|
||||
pendingDiffStaged: false,
|
||||
pendingDiffScope: null,
|
||||
pendingDiagramFile: null,
|
||||
pendingFileNavigation: null,
|
||||
pendingFileFocusPath: null,
|
||||
@@ -1042,17 +1055,20 @@ export const useUIStore = create<UIStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
openContextDiff: (directory, filePath, staged = false) => {
|
||||
openContextDiff: (directory, filePath, staged = false, scope = null) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedFilePath = (filePath || '').trim();
|
||||
if (!normalizedDirectory || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diffScope = normalizePendingDiffScope(scope) ?? (staged ? 'staged' : 'working');
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, {
|
||||
mode: 'diff',
|
||||
targetPath: normalizedFilePath,
|
||||
stagedDiff: staged,
|
||||
stagedDiff: diffScope === 'staged',
|
||||
diffScope,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1414,8 +1430,12 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ activeMainTab: restored });
|
||||
},
|
||||
|
||||
setPendingDiffFile: (filePath, staged = false) => {
|
||||
set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
|
||||
setPendingDiffFile: (filePath, staged = false, scope = null) => {
|
||||
set({
|
||||
pendingDiffFile: filePath,
|
||||
pendingDiffStaged: filePath ? staged : false,
|
||||
pendingDiffScope: filePath ? scope : null,
|
||||
});
|
||||
},
|
||||
|
||||
setPendingDiagramFile: (filePath) => {
|
||||
@@ -1430,18 +1450,18 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ pendingFileFocusPath: path });
|
||||
},
|
||||
|
||||
navigateToDiff: (filePath, staged = false) => {
|
||||
navigateToDiff: (filePath, staged = false, scope = null) => {
|
||||
const guard = get().mainTabGuard;
|
||||
if (guard && !guard('diff')) {
|
||||
return;
|
||||
}
|
||||
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, activeMainTab: 'diff' });
|
||||
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeMainTab: 'diff' });
|
||||
},
|
||||
|
||||
consumePendingDiffFile: () => {
|
||||
const { pendingDiffFile } = get();
|
||||
if (pendingDiffFile) {
|
||||
set({ pendingDiffFile: null, pendingDiffStaged: false });
|
||||
set({ pendingDiffFile: null, pendingDiffStaged: false, pendingDiffScope: null });
|
||||
}
|
||||
return pendingDiffFile;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user