feat(diff): add branch scope to context panel diff view
Show every change on the current branch relative to its base in the Changed/Staged/Last turn dropdown. The base comes from the branch's reflog record or an explicit per-branch user choice (persisted), never a main/master guess; when git has no record the user picks a base once from a searchable branch list. - server: GET /api/git/branch-base (reflog-derived base), GET /api/git/range-files (name-status -z with rename/copy destination paths and -C copy detection) - shared UI: optional getBranchBase/getGitRangeFiles runtime APIs with boundary parsing; persisted per-branch overrides keyed by runtime+directory+branch - DiffView: branch scope with confirmed-unavailability coercion of persisted tabs (detached HEAD, default-branch checkout, metadata settled without a default), range-invalidated diff cache guarded against stale completions, bounded branch-metadata retry, read-only diff actions in branch scope; hidden in VS Code - helper module branchDiffScope.ts with tests for coercion, availability, race conditions, and retry exhaustion
This commit is contained in:
@@ -3,9 +3,12 @@ import React from 'react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
|
||||
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
|
||||
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
|
||||
import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -79,7 +82,7 @@ type DiffData = {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
contextMode?: DiffContextMode;
|
||||
};
|
||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn';
|
||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch';
|
||||
|
||||
type TurnSnapshotDiff = {
|
||||
file?: string;
|
||||
@@ -91,6 +94,17 @@ type TurnSnapshotDiff = {
|
||||
deletions?: number;
|
||||
};
|
||||
|
||||
/** Reservation slot for a branch range diff while its fetch is in flight. */
|
||||
const EMPTY_BRANCH_DIFF_PLACEHOLDER: DiffData = {
|
||||
original: '',
|
||||
modified: '',
|
||||
isBinary: false,
|
||||
contextMode: 'patch',
|
||||
};
|
||||
|
||||
/** Bounded retries for branch metadata in the context diff panel (see effect). */
|
||||
const BRANCH_METADATA_MAX_ATTEMPTS = 3;
|
||||
|
||||
const BinaryDiffPlaceholder = React.memo(() => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -230,11 +244,13 @@ const formatDiffTotals = (
|
||||
};
|
||||
|
||||
interface ChangeScopeSelectorProps {
|
||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>;
|
||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>;
|
||||
workingCount: number;
|
||||
stagedCount: number;
|
||||
turnCount: number;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
||||
branchCount: number | null;
|
||||
showBranchOption: boolean;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
|
||||
}
|
||||
|
||||
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
@@ -242,16 +258,20 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
workingCount,
|
||||
stagedCount,
|
||||
turnCount,
|
||||
branchCount,
|
||||
showBranchOption,
|
||||
onScopeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount;
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : workingCount;
|
||||
const currentLabel = scope === 'staged'
|
||||
? t('diffView.scope.staged')
|
||||
: scope === 'turn'
|
||||
? t('diffView.scope.lastTurn')
|
||||
: t('diffView.scope.changed');
|
||||
: scope === 'branch'
|
||||
? t('diffView.scope.branch')
|
||||
: t('diffView.scope.changed');
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -271,7 +291,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn') {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') {
|
||||
onScopeChange?.(value);
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -295,6 +315,14 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<span className="typography-meta text-muted-foreground">{turnCount}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
{showBranchOption ? (
|
||||
<DropdownMenuRadioItem value="branch">
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span>{t('diffView.scope.branch')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{branchCount ?? '…'}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
) : null}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -574,6 +602,8 @@ interface MultiFileDiffEntryProps {
|
||||
staged?: boolean;
|
||||
loadFullFiles?: boolean;
|
||||
initialDiffData?: DiffData | null;
|
||||
/** Hide stage/unstage/revert actions (read-only scopes like branch diffs). */
|
||||
readOnlyActions?: boolean;
|
||||
}
|
||||
|
||||
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
@@ -593,6 +623,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
staged = false,
|
||||
loadFullFiles = false,
|
||||
initialDiffData = null,
|
||||
readOnlyActions = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { git } = useRuntimeAPIs();
|
||||
@@ -922,13 +953,15 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
|
||||
<div className="pointer-events-auto">
|
||||
<FileDiffActions
|
||||
filePath={file.path}
|
||||
staged={staged}
|
||||
busyAction={fileAction}
|
||||
disabled={fileAction !== null}
|
||||
onAction={handleFileAction}
|
||||
/>
|
||||
{!readOnlyActions ? (
|
||||
<FileDiffActions
|
||||
filePath={file.path}
|
||||
staged={staged}
|
||||
busyAction={fileAction}
|
||||
disabled={fileAction !== null}
|
||||
onAction={handleFileAction}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -945,7 +978,7 @@ interface DiffViewProps {
|
||||
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
||||
showOpenInEditorAction?: boolean;
|
||||
diffScope?: DiffScope;
|
||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
|
||||
targetFilePath?: string | null;
|
||||
/** Render diff content flush with the container edges (no outer padding). */
|
||||
flushContent?: boolean;
|
||||
@@ -974,6 +1007,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
|
||||
const setDiff = useGitStore((state) => state.setDiff);
|
||||
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
|
||||
@@ -1083,7 +1117,213 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return map;
|
||||
}, [lastTurnDiffs]);
|
||||
|
||||
const workingFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
return status.files.filter(isWorkingStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const stagedFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
return status.files.filter(isStagedStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const turnFileCount = lastTurnDiffs.length;
|
||||
|
||||
// ----- Branch scope (all changes on this branch vs its base) -----
|
||||
const currentBranch = status?.current ?? null;
|
||||
const branches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.branches ?? null : null));
|
||||
const isLoadingBranches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.isLoadingBranches ?? false : false));
|
||||
|
||||
// The Branch scope needs defaultBranches metadata that nothing else loads
|
||||
// when only the context diff panel is open (GitView and the composer fetch
|
||||
// it, and their absence must not hide the option), so load it here. A
|
||||
// failed fetch leaves `branches` null and the loading flag settles back to
|
||||
// false; the bounded retry below re-issues it a few times per directory and
|
||||
// reports exhaustion so a dead repository neither loops forever nor spins
|
||||
// the Branch scope on base resolution.
|
||||
const startBranchMetadataFetch = React.useCallback(() => {
|
||||
if (effectiveDirectory) {
|
||||
void fetchBranches(effectiveDirectory, git);
|
||||
}
|
||||
}, [effectiveDirectory, fetchBranches, git]);
|
||||
const branchMetadataExhausted = useBoundedDirectoryRetry(
|
||||
effectiveDirectory ?? null,
|
||||
isGitRepo !== false,
|
||||
isLoadingBranches,
|
||||
Boolean(branches),
|
||||
startBranchMetadataFetch,
|
||||
BRANCH_METADATA_MAX_ATTEMPTS
|
||||
);
|
||||
|
||||
const repositoryDefaultBranch = React.useMemo(() => {
|
||||
const trackingRemote = status?.tracking?.trim().split('/')[0];
|
||||
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
|
||||
?? branches?.defaultBranches?.origin
|
||||
?? null;
|
||||
}, [branches, status?.tracking]);
|
||||
// Offered only while the default branch is known and the current branch is
|
||||
// not it (an unknown default must not flash the option on a guess), and
|
||||
// only outside VS Code (the extension has no context diff panel).
|
||||
const showBranchOption = !isVSCodeRuntime() && isBranchScopeAvailable(currentBranch, repositoryDefaultBranch);
|
||||
// Coercion acts only on CONFIRMED unavailability: the runtime has no branch
|
||||
// scope at all, a settled status has no branch (detached HEAD), the default
|
||||
// branch is known and we are on it, or metadata retries were exhausted.
|
||||
// While status/metadata are still loading a persisted branch scope must
|
||||
// survive instead of being rewritten to working on the first render.
|
||||
// `status !== null` is the settled test: before the first status request
|
||||
// even starts, status is null with loading still false, and that must not
|
||||
// read as "settled without a branch".
|
||||
const isBranchStatusResolved = status !== null;
|
||||
const branchScopeDefinitelyUnavailable = isVSCodeRuntime()
|
||||
|| branchMetadataExhausted
|
||||
|| isBranchScopeDefinitelyUnavailable(
|
||||
currentBranch,
|
||||
repositoryDefaultBranch,
|
||||
isBranchStatusResolved,
|
||||
branches !== null
|
||||
);
|
||||
|
||||
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
|
||||
// Subscribe to the overrides map directly: `getOverride` reads `get()`
|
||||
// imperatively, so a memo over it never recomputes when the store changes
|
||||
// and a freshly picked base would be invisible until an unrelated rerender.
|
||||
// The key includes the current branch: a base picked for one feature branch
|
||||
// is not an answer for another branch of the same repository.
|
||||
const baseOverride = useGitBaseBranchStore(
|
||||
React.useCallback(
|
||||
(state) => (effectiveDirectory && currentBranch
|
||||
? state.overrides[gitBaseBranchEntryKey(effectiveDirectory, currentBranch)] ?? null
|
||||
: null),
|
||||
[currentBranch, effectiveDirectory]
|
||||
)
|
||||
);
|
||||
const [detectedBranchBase, setDetectedBranchBase] = React.useState<string | null>(null);
|
||||
const [isBranchBaseResolved, setIsBranchBaseResolved] = React.useState(false);
|
||||
const [basePickerSearch, setBasePickerSearch] = React.useState('');
|
||||
|
||||
// A context tab persists its scope across branch checkouts and runtime
|
||||
// switches. When the Branch scope is CONFIRMED unavailable (checked out the
|
||||
// known default branch, VS Code runtime), fall back to Working instead of
|
||||
// rendering the base-resolution spinner forever. Persist the coercion so
|
||||
// the tab and the selector agree. Note it keys off confirmed
|
||||
// unavailability, not off `showBranchOption`: while metadata loads the
|
||||
// option is hidden but a persisted branch scope must not be rewritten.
|
||||
React.useEffect(() => {
|
||||
const coercedScope = coerceDiffScope(activeDiffScope, !branchScopeDefinitelyUnavailable);
|
||||
if (coercedScope !== activeDiffScope) {
|
||||
setActiveDiffScope(coercedScope);
|
||||
// The only coercion is 'branch' -> 'working', so the persisted
|
||||
// value always fits the callback domain.
|
||||
if (coercedScope === 'working') {
|
||||
onDiffScopeChange?.('working');
|
||||
}
|
||||
}
|
||||
}, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showBranchOption || !effectiveDirectory || !currentBranch) {
|
||||
setDetectedBranchBase(null);
|
||||
setIsBranchBaseResolved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsBranchBaseResolved(false);
|
||||
getBranchBase(effectiveDirectory, currentBranch)
|
||||
.then((result) => {
|
||||
if (!cancelled) setDetectedBranchBase(result.base);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDetectedBranchBase(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsBranchBaseResolved(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentBranch, effectiveDirectory, showBranchOption]);
|
||||
|
||||
// Explicit user choice outranks the detected source; both are real answers
|
||||
// from git or the user — never a main/master guess.
|
||||
const branchBase = baseOverride ?? detectedBranchBase;
|
||||
|
||||
const [branchFiles, setBranchFiles] = React.useState<GitRangeFileEntry[] | null>(null);
|
||||
const [branchFilesError, setBranchFilesError] = React.useState<string | null>(null);
|
||||
|
||||
// Shared by the scope/base effect and the error-state Retry button; the
|
||||
// fetch id discards completions from a superseded run (base or head
|
||||
// changed, or an earlier retry is still in flight).
|
||||
const branchFilesFetchIdRef = React.useRef(0);
|
||||
const reloadBranchFiles = React.useCallback(() => {
|
||||
if (!effectiveDirectory || !currentBranch || !branchBase) return;
|
||||
const fetchId = branchFilesFetchIdRef.current + 1;
|
||||
branchFilesFetchIdRef.current = fetchId;
|
||||
setBranchFiles(null);
|
||||
setBranchFilesError(null);
|
||||
getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch })
|
||||
.then((files) => {
|
||||
if (branchFilesFetchIdRef.current === fetchId) setBranchFiles(files);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (branchFilesFetchIdRef.current === fetchId) {
|
||||
setBranchFilesError(error instanceof Error ? error.message : t('diffView.branch.loadError'));
|
||||
}
|
||||
});
|
||||
}, [branchBase, currentBranch, effectiveDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeDiffScope === 'branch') {
|
||||
reloadBranchFiles();
|
||||
}
|
||||
}, [activeDiffScope, reloadBranchFiles]);
|
||||
|
||||
// Range diffs are fetched per expanded file: unlike working/staged diffs
|
||||
// there is no per-file cache channel, so patch data lives in a range-keyed
|
||||
// local cache. Stale completions from a previous range cannot write into
|
||||
// the new range's cache (see useRangeKeyedCache).
|
||||
const branchDiffRangeKey = activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase
|
||||
? branchRangeKey(effectiveDirectory, branchBase, currentBranch)
|
||||
: null;
|
||||
const branchDiffPathsKey = React.useMemo(
|
||||
() => (activeDiffScope === 'branch' ? Array.from(expandedFiles).sort().join('\0') : ''),
|
||||
[activeDiffScope, expandedFiles]
|
||||
);
|
||||
|
||||
const fetchBranchDiffEntry = React.useCallback(
|
||||
(filePath: string) => {
|
||||
if (!effectiveDirectory || !branchBase || !currentBranch) {
|
||||
return Promise.reject(new Error('branch range is unavailable'));
|
||||
}
|
||||
return getGitRangeDiff(effectiveDirectory, { base: branchBase, head: currentBranch, path: filePath })
|
||||
.then((response) => createTextDiffDataFromPatch(filePath, response.diff, 'patch'));
|
||||
},
|
||||
[branchBase, currentBranch, effectiveDirectory]
|
||||
);
|
||||
|
||||
const branchDiffData = useRangeKeyedCache<DiffData>(
|
||||
branchDiffRangeKey,
|
||||
branchDiffPathsKey,
|
||||
branchDiffRangeKey ? fetchBranchDiffEntry : null,
|
||||
EMPTY_BRANCH_DIFF_PLACEHOLDER
|
||||
);
|
||||
|
||||
const branchFileCount = branchFiles?.length ?? null;
|
||||
|
||||
const changedFiles: FileEntry[] = React.useMemo(() => {
|
||||
if (activeDiffScope === 'branch') {
|
||||
return (branchFiles ?? [])
|
||||
.map((file) => ({
|
||||
path: file.path,
|
||||
index: '',
|
||||
working_dir: file.status,
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
isNew: file.status === 'A',
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
if (activeDiffScope === 'turn') {
|
||||
return lastTurnDiffs
|
||||
.map((diff) => ({
|
||||
@@ -1115,19 +1355,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
isNew: isNewStatusFile(file),
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [activeDiffScope, lastTurnDiffs, status]);
|
||||
|
||||
const workingFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
return status.files.filter(isWorkingStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const stagedFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
return status.files.filter(isStagedStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const turnFileCount = lastTurnDiffs.length;
|
||||
}, [activeDiffScope, branchFiles, lastTurnDiffs, status]);
|
||||
|
||||
const changedFilePathsKey = React.useMemo(
|
||||
() => changedFiles.map((file) => file.path).join('\0'),
|
||||
@@ -1670,7 +1898,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}}
|
||||
staged={getFileStaged(file.path)}
|
||||
loadFullFiles={loadFullFiles}
|
||||
initialDiffData={activeDiffScope === 'turn' ? lastTurnDiffData.get(file.path) ?? null : null}
|
||||
readOnlyActions={activeDiffScope === 'branch'}
|
||||
initialDiffData={
|
||||
activeDiffScope === 'turn'
|
||||
? lastTurnDiffData.get(file.path) ?? null
|
||||
: activeDiffScope === 'branch'
|
||||
? branchDiffData.get(file.path) ?? null
|
||||
: null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1707,10 +1942,93 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (activeDiffScope === 'branch') {
|
||||
if (!isBranchBaseResolved) {
|
||||
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" />
|
||||
{t('diffView.branch.resolvingBase')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!branchBase) {
|
||||
const searchTerm = basePickerSearch.trim().toLowerCase();
|
||||
const candidateBranches = (branches?.all ?? [])
|
||||
.map((name: string) => name.replace(/^remotes\//, ''))
|
||||
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
|
||||
.filter((name: string) => !searchTerm || name.toLowerCase().includes(searchTerm))
|
||||
.sort();
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<Icon name="git-branch" className="size-6 text-muted-foreground" />
|
||||
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.noBaseTitle')}</div>
|
||||
<div className="max-w-sm typography-micro text-muted-foreground">{t('diffView.branch.noBaseDescription')}</div>
|
||||
<input
|
||||
type="text"
|
||||
value={basePickerSearch}
|
||||
onChange={(event) => setBasePickerSearch(event.target.value)}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
aria-label={t('gitView.branch.searchPlaceholder')}
|
||||
className="w-full max-w-sm rounded-md border border-border/60 bg-[var(--surface-elevated)] px-2.5 py-1.5 typography-meta text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
/>
|
||||
<ScrollableOverlay outerClassName="max-h-48 w-full max-w-sm min-h-0" className="px-1 py-1">
|
||||
{candidateBranches.length === 0 ? (
|
||||
<div className="px-2 py-3 typography-meta text-muted-foreground">
|
||||
{t('gitView.branch.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{candidateBranches.map((branch: string) => (
|
||||
<button
|
||||
key={branch}
|
||||
type="button"
|
||||
onClick={() => effectiveDirectory && currentBranch && setBaseOverride(effectiveDirectory, currentBranch, branch)}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
>
|
||||
<Icon name="git-branch" className="size-3.5 text-primary" />
|
||||
<span className="truncate typography-ui-label text-foreground" title={branch}>{branch}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (branchFilesError) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.loadError')}</div>
|
||||
<div className="max-w-sm typography-micro text-muted-foreground">{branchFilesError}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => reloadBranchFiles()}
|
||||
>
|
||||
{t('diffView.actions.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (branchFiles === null) {
|
||||
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" />
|
||||
{t('diffView.branch.loadingFiles')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')}
|
||||
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges')
|
||||
: activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchBase })
|
||||
: t('diffView.state.cleanWorkingTree')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1722,12 +2040,14 @@ 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 && (
|
||||
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? (
|
||||
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
|
||||
<ChangeScopeSelector
|
||||
scope={activeDiffScope}
|
||||
workingCount={workingFileCount}
|
||||
stagedCount={stagedFileCount}
|
||||
turnCount={turnFileCount}
|
||||
branchCount={branchFileCount}
|
||||
showBranchOption={showBranchOption}
|
||||
onScopeChange={(scope) => {
|
||||
setActiveDiffScope(scope);
|
||||
onDiffScopeChange?.(scope);
|
||||
|
||||
Reference in New Issue
Block a user