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);
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
branchRangeKey,
|
||||
coerceDiffScope,
|
||||
isBranchScopeAvailable,
|
||||
isBranchScopeDefinitelyUnavailable,
|
||||
useRangeKeyedCache,
|
||||
useBoundedDirectoryRetry,
|
||||
} from './branchDiffScope';
|
||||
|
||||
describe('coerceDiffScope', () => {
|
||||
test('keeps the branch scope while it is offered', () => {
|
||||
expect(coerceDiffScope('branch', true)).toBe('branch');
|
||||
});
|
||||
|
||||
test('falls back to working when the branch scope disappears', () => {
|
||||
// Covers a persisted context tab after checking out the default branch
|
||||
// or switching to a runtime without the branch scope: the tab must land
|
||||
// on a renderable scope instead of a permanent spinner.
|
||||
expect(coerceDiffScope('branch', false)).toBe('working');
|
||||
});
|
||||
|
||||
test('leaves every other scope untouched regardless of availability', () => {
|
||||
for (const scope of ['working', 'staged', 'turn', 'all'] as const) {
|
||||
expect(coerceDiffScope(scope, false)).toBe(scope);
|
||||
expect(coerceDiffScope(scope, true)).toBe(scope);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBranchScopeAvailable', () => {
|
||||
test('available when the default branch is known and different', () => {
|
||||
expect(isBranchScopeAvailable('feature-a', 'main')).toBe(true);
|
||||
});
|
||||
|
||||
test('unavailable on the default branch itself', () => {
|
||||
expect(isBranchScopeAvailable('main', 'main')).toBe(false);
|
||||
});
|
||||
|
||||
test('unavailable while the default branch is unknown', () => {
|
||||
// Branch metadata loads asynchronously; an unknown default must not
|
||||
// flash the Branch option on the guess that the branch differs from it.
|
||||
expect(isBranchScopeAvailable('feature-a', null)).toBe(false);
|
||||
});
|
||||
|
||||
test('unavailable without a current branch', () => {
|
||||
expect(isBranchScopeAvailable(null, 'main')).toBe(false);
|
||||
expect(isBranchScopeAvailable(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBranchScopeDefinitelyUnavailable', () => {
|
||||
test('unknown metadata is not confirmed unavailability', () => {
|
||||
// While branch metadata loads the option stays hidden, but this is
|
||||
// "unknown", not "confirmed gone" — coercion must not act on it.
|
||||
expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, false)).toBe(false);
|
||||
expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, false)).toBe(false);
|
||||
});
|
||||
|
||||
test('unresolved status means the branch is unknown, not gone', () => {
|
||||
// During the first status load a null currentBranch is "not loaded
|
||||
// yet"; coercing on it would discard a persisted branch scope before
|
||||
// the answer arrives.
|
||||
expect(isBranchScopeDefinitelyUnavailable(null, 'main', false, false)).toBe(false);
|
||||
expect(isBranchScopeDefinitelyUnavailable(null, null, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
test('detached HEAD after a settled status is confirmed unavailability', () => {
|
||||
// Status finished (or failed) without a branch: the Branch scope is
|
||||
// impossible, so a persisted branch scope must coerce away instead of
|
||||
// spinning on base resolution forever.
|
||||
expect(isBranchScopeDefinitelyUnavailable(null, 'main', true, false)).toBe(true);
|
||||
expect(isBranchScopeDefinitelyUnavailable(null, null, true, true)).toBe(true);
|
||||
});
|
||||
|
||||
test('metadata settled without a default branch is confirmed unavailability', () => {
|
||||
// `getBranches` can succeed while git/remote never reported a default
|
||||
// branch: retries will not change that, the option stays hidden, and a
|
||||
// persisted branch scope must coerce instead of spinning on base
|
||||
// resolution forever.
|
||||
expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, true)).toBe(true);
|
||||
expect(isBranchScopeAvailable('feature-a', null)).toBe(false);
|
||||
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', null, true, true))).toBe('working');
|
||||
});
|
||||
|
||||
test('confirmed when the default branch is known and we are on it', () => {
|
||||
expect(isBranchScopeDefinitelyUnavailable('main', 'main', true, true)).toBe(true);
|
||||
expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true)).toBe(false);
|
||||
});
|
||||
|
||||
test('a persisted branch scope survives loading metadata and is coerced once the answer arrives', () => {
|
||||
// The scenario: a context tab persisted scope='branch' and the panel
|
||||
// reopens while branch metadata is still loading (null default).
|
||||
// First render — option hidden, but NOT coerced away:
|
||||
expect(isBranchScopeAvailable('feature-a', null)).toBe(false);
|
||||
expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, false)).toBe(false);
|
||||
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', null, true, false))).toBe('branch');
|
||||
|
||||
// Metadata arrives and confirms a feature branch — still available:
|
||||
expect(isBranchScopeAvailable('feature-a', 'main')).toBe(true);
|
||||
expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true)).toBe(false);
|
||||
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true))).toBe('branch');
|
||||
|
||||
// User checks out the default branch — now confirmed, coerce:
|
||||
expect(isBranchScopeDefinitelyUnavailable('main', 'main', true, true)).toBe(true);
|
||||
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('main', 'main', true, true))).toBe('working');
|
||||
});
|
||||
|
||||
test('a persisted branch scope coerces after detached HEAD once status settles', () => {
|
||||
// Status still loading with a persisted branch scope — keep it:
|
||||
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, 'main', false, false))).toBe('branch');
|
||||
// Status settles on detached HEAD — coerce:
|
||||
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, 'main', true, false))).toBe('working');
|
||||
});
|
||||
|
||||
test('first render before the status request starts does not read as settled detached HEAD', () => {
|
||||
// Sequence of a fresh mount with a persisted branch scope:
|
||||
// 1. status===null, loading===false (request has not started yet),
|
||||
// 2. loading===true,
|
||||
// 3. settled status object with current===null (true detached HEAD).
|
||||
// Only step 3 may coerce; steps 1-2 are "unknown" and keep the scope.
|
||||
expect(isBranchScopeDefinitelyUnavailable(null, null, false, false)).toBe(false);
|
||||
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, null, false, false))).toBe('branch');
|
||||
expect(isBranchScopeDefinitelyUnavailable(null, null, false, true)).toBe(false);
|
||||
expect(isBranchScopeDefinitelyUnavailable(null, null, true, false)).toBe(true);
|
||||
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, null, true, false))).toBe('working');
|
||||
});
|
||||
});
|
||||
|
||||
describe('branchRangeKey', () => {
|
||||
test('distinguishes bases, heads, and directories for the same path', () => {
|
||||
// The same file path can carry different diff content per range; a cache
|
||||
// keyed by path alone would leak a previous branch's patch.
|
||||
const keys = [
|
||||
branchRangeKey('/repo', 'main', 'feature-a'),
|
||||
branchRangeKey('/repo', 'develop', 'feature-a'),
|
||||
branchRangeKey('/repo', 'main', 'feature-b'),
|
||||
branchRangeKey('/other', 'main', 'feature-a'),
|
||||
];
|
||||
expect(new Set(keys).size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useRangeKeyedCache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((next, decline) => {
|
||||
resolve = next;
|
||||
reject = decline;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: unknown) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ElementStub {}
|
||||
/** Minimal Document surface createRoot touches in these tests. */
|
||||
type DocumentStub = {
|
||||
nodeType: 9;
|
||||
defaultView: typeof globalThis;
|
||||
activeElement: Element | null;
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
documentElement: typeof container;
|
||||
body: typeof container;
|
||||
};
|
||||
const container = {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument: null as DocumentStub | null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
const documentStub: DocumentStub = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
documentElement: container,
|
||||
body: container,
|
||||
};
|
||||
container.ownerDocument = documentStub;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
|
||||
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
|
||||
return {
|
||||
// SAFETY: the container stub implements the Element surface createRoot
|
||||
// touches (nodeType/tagName/listeners); the real Element type is not
|
||||
// constructible without a DOM implementation, so the gap goes through
|
||||
// unknown deliberately.
|
||||
container: container as unknown as Element,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('useRangeKeyedCache', () => {
|
||||
test('a stale completion from the previous range cannot write into the new range', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
// One shared path so the same key would be overwritten if the guard
|
||||
// was missing.
|
||||
const pathsKey = 'src/shared.ts';
|
||||
const rangeA = '["/repo","main","feature-a"]';
|
||||
const rangeB = '["/repo","develop","feature-b"]';
|
||||
const fetchA = deferred<string>();
|
||||
const fetchB = deferred<string>();
|
||||
type CapturedEntries = { entries: ReadonlyMap<string, string> | null };
|
||||
const captured: CapturedEntries = { entries: null };
|
||||
|
||||
let currentFetcher: (path: string) => Promise<string> = () => fetchA.promise;
|
||||
|
||||
const Harness = () => {
|
||||
captured.entries = useRangeKeyedCache<string>(
|
||||
rangeKey,
|
||||
pathsKey,
|
||||
currentFetcher,
|
||||
'placeholder'
|
||||
);
|
||||
return null;
|
||||
};
|
||||
let rangeKey: string = rangeA;
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(captured.entries?.get(pathsKey)).toBe('placeholder');
|
||||
|
||||
// Switch the range while A's fetch is still in flight.
|
||||
rangeKey = rangeB;
|
||||
currentFetcher = () => fetchB.promise;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(captured.entries?.get(pathsKey)).toBe('placeholder');
|
||||
|
||||
// B completes first: its value must land.
|
||||
await act(async () => {
|
||||
fetchB.resolve('diff-from-develop');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop');
|
||||
|
||||
// A completes last: the stale result must be discarded, not written
|
||||
// over range B's entry.
|
||||
await act(async () => {
|
||||
fetchA.resolve('diff-from-main');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a stale rejection from the previous range cannot delete the new range entry', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const pathsKey = 'src/shared.ts';
|
||||
const rangeA = '["/repo","main","feature-a"]';
|
||||
const rangeB = '["/repo","develop","feature-b"]';
|
||||
const fetchA = deferred<string>();
|
||||
const fetchB = deferred<string>();
|
||||
type CapturedEntries = { entries: ReadonlyMap<string, string> | null };
|
||||
const captured: CapturedEntries = { entries: null };
|
||||
|
||||
let currentFetcher: (path: string) => Promise<string> = () => fetchA.promise;
|
||||
let rangeKey: string = rangeA;
|
||||
|
||||
const Harness = () => {
|
||||
captured.entries = useRangeKeyedCache<string>(rangeKey, pathsKey, currentFetcher, 'placeholder');
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
rangeKey = rangeB;
|
||||
currentFetcher = () => fetchB.promise;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await act(async () => {
|
||||
fetchB.resolve('diff-from-develop');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop');
|
||||
|
||||
// The old range's fetch fails after the switch: it must not delete
|
||||
// the new range's completed entry.
|
||||
await act(async () => {
|
||||
fetchA.reject(new Error('stale failure'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('releases reservations for paths that never completed so a later run retries them', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const pathsKey = 'src/first.ts';
|
||||
const stuck = deferred<string>();
|
||||
type CapturedEntries = { entries: ReadonlyMap<string, string> | null };
|
||||
const captured: CapturedEntries = { entries: null };
|
||||
|
||||
let currentPathsKey = pathsKey;
|
||||
const fetched: string[] = [];
|
||||
|
||||
const Harness = () => {
|
||||
captured.entries = useRangeKeyedCache<string>(
|
||||
'range',
|
||||
currentPathsKey,
|
||||
(path) => {
|
||||
fetched.push(path);
|
||||
return currentPathsKey === pathsKey ? stuck.promise : Promise.resolve(`resolved-${path}`);
|
||||
},
|
||||
'placeholder'
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(fetched).toEqual(['src/first.ts']);
|
||||
expect(captured.entries?.get('src/first.ts')).toBe('placeholder');
|
||||
|
||||
// Expand a different set of paths; the stuck reservation for
|
||||
// src/first.ts is released, and a later run fetches it again.
|
||||
currentPathsKey = 'src/first.ts\u0000src/second.ts';
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(fetched).toEqual(['src/first.ts', 'src/first.ts', 'src/second.ts']);
|
||||
expect(captured.entries?.get('src/first.ts')).toBe('resolved-src/first.ts');
|
||||
expect(captured.entries?.get('src/second.ts')).toBe('resolved-src/second.ts');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBoundedDirectoryRetry', () => {
|
||||
test('starts once and reports no exhaustion on success', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const started: string[] = [];
|
||||
let hasResult = false;
|
||||
let latestExhausted: boolean | null = null;
|
||||
|
||||
const Harness = () => {
|
||||
latestExhausted = useBoundedDirectoryRetry(
|
||||
'/repo', true, false, hasResult,
|
||||
() => { started.push('/repo'); }, 3
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(started).toEqual(['/repo']);
|
||||
expect(latestExhausted).toBe(false);
|
||||
|
||||
// Result arrives: no further starts, no exhaustion.
|
||||
hasResult = true;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(started).toEqual(['/repo']);
|
||||
expect(latestExhausted).toBe(false);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('retries bounded times on failure, then reports exhaustion without looping', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const started: string[] = [];
|
||||
let inFlight = false;
|
||||
let latestExhausted: boolean | null = null;
|
||||
|
||||
const Harness = () => {
|
||||
latestExhausted = useBoundedDirectoryRetry(
|
||||
'/repo', true, inFlight, false,
|
||||
() => { started.push('/repo'); }, 3
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
// Each attempt is one in-flight transition: the start flips the
|
||||
// caller's flag up, the failed request settles it back down.
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
inFlight = false;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(started).toHaveLength(attempt);
|
||||
inFlight = true;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
}
|
||||
expect(latestExhausted).toBe(false);
|
||||
|
||||
// Fourth transition: attempts exhausted, no more starts.
|
||||
inFlight = false;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(started).toHaveLength(3);
|
||||
expect(latestExhausted).toBe(true);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('exhaustion does not leak into the next directory on the first render', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const started: string[] = [];
|
||||
let directory: string = '/repo-a';
|
||||
let inFlight = false;
|
||||
let hasResult = false;
|
||||
let latestExhausted: boolean | null = null;
|
||||
|
||||
const Harness = () => {
|
||||
latestExhausted = useBoundedDirectoryRetry(
|
||||
directory, true, inFlight, hasResult,
|
||||
() => { started.push(directory); }, 2
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
// Burn through both retries for /repo-a until exhausted.
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
inFlight = false;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
inFlight = true;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
}
|
||||
inFlight = false;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(latestExhausted).toBe(true);
|
||||
|
||||
// Switch to another directory (a new tab with a persisted branch
|
||||
// scope): exhaustion must reset in the SAME render, before any
|
||||
// effect could rewrite the scope, and retries restart for it.
|
||||
directory = '/repo-b';
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(latestExhausted).toBe(false);
|
||||
expect(started).toEqual(['/repo-a', '/repo-a', '/repo-b']);
|
||||
|
||||
hasResult = true;
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(latestExhausted).toBe(false);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an in-flight request suppresses duplicate starts from another consumer', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const started: string[] = [];
|
||||
|
||||
const Harness = () => {
|
||||
useBoundedDirectoryRetry(
|
||||
'/repo', true, true, false,
|
||||
() => { started.push('/repo'); }, 3
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(started).toEqual([]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Pure helpers backing the "Branch" diff scope in DiffView. Extracted so the
|
||||
* coercion, availability, and range-cache invalidation contracts are testable
|
||||
* without mounting the full diff surface.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The "Branch" scope only exists while the repository's default branch is
|
||||
* known and the current branch differs from it (the caller decides runtime
|
||||
* availability). An unknown default must NOT show the option: the scope is
|
||||
* "this branch is not the default", which cannot be established, and offering
|
||||
* it on a guess flashes the option while branch metadata is still loading.
|
||||
*/
|
||||
export const isBranchScopeAvailable = (
|
||||
currentBranch: string | null,
|
||||
repositoryDefaultBranch: string | null
|
||||
): boolean => (
|
||||
Boolean(currentBranch)
|
||||
&& repositoryDefaultBranch !== null
|
||||
&& currentBranch !== repositoryDefaultBranch
|
||||
);
|
||||
|
||||
/**
|
||||
* Confirmed unavailability of the Branch scope, as opposed to "not (yet)
|
||||
* known". Coercion of a persisted branch scope must wait for this: while
|
||||
* metadata is loading the default branch is unknown, the option stays hidden,
|
||||
* but rewriting the persisted scope to working on that first render would
|
||||
* discard the user's choice the moment metadata arrives and confirms the
|
||||
* branch differs from the default.
|
||||
*
|
||||
* - `isBranchStatusResolved` distinguishes "no branch yet because the first
|
||||
* status load has not settled" (unknown — keep the persisted scope) from
|
||||
* "status finished and there is no branch" (detached HEAD / failed load —
|
||||
* the Branch scope is impossible and the scope must coerce away).
|
||||
* - `isBranchMetadataLoaded` + a null default means the branch list settled
|
||||
* WITHOUT a resolvable default branch (git/remote never reported one): the
|
||||
* Branch scope is impossible in a different way, and must coerce too,
|
||||
* otherwise the persisted scope spins on base resolution forever.
|
||||
*/
|
||||
export const isBranchScopeDefinitelyUnavailable = (
|
||||
currentBranch: string | null,
|
||||
repositoryDefaultBranch: string | null,
|
||||
isBranchStatusResolved: boolean,
|
||||
isBranchMetadataLoaded: boolean
|
||||
): boolean => {
|
||||
if (!isBranchStatusResolved) return false;
|
||||
if (currentBranch === null) return true;
|
||||
if (isBranchMetadataLoaded && repositoryDefaultBranch === null) return true;
|
||||
return repositoryDefaultBranch !== null && currentBranch === repositoryDefaultBranch;
|
||||
};
|
||||
|
||||
/**
|
||||
* A context tab persists its scope across branch checkouts and runtime
|
||||
* switches. When the Branch scope stops being offered (checked out the
|
||||
* default branch, VS Code runtime), fall back to a always-available one instead
|
||||
* of rendering the base-resolution spinner forever.
|
||||
*/
|
||||
export const coerceDiffScope = <T extends string>(
|
||||
scope: T,
|
||||
branchScopeAvailable: boolean
|
||||
): T | 'working' => (scope === 'branch' && !branchScopeAvailable ? 'working' : scope);
|
||||
|
||||
/**
|
||||
* Identity of one `base...head` range in one repository. Range-cache entries
|
||||
* are only valid within a single range: the same file path can carry different
|
||||
* content under a different base or head, so a cache keyed by path alone leaks
|
||||
* stale patches across branch and base switches.
|
||||
*/
|
||||
export const branchRangeKey = (directory: string, base: string, head: string): string =>
|
||||
JSON.stringify([directory, base, head]);
|
||||
|
||||
/**
|
||||
* Bounded per-directory retry for a request whose failure leaves no result and
|
||||
* no signal beyond the in-flight flag settling back to false.
|
||||
*
|
||||
* - State carries its directory: after a directory switch the derived
|
||||
* attempts/exhausted values reset IMMEDIATELY on the first render of the new
|
||||
* directory (no reset effect, so no one-render window where a stale
|
||||
* `exhausted: true` from the previous directory leaks into decisions).
|
||||
* - Retries stop after `maxAttempts` and report exhaustion instead of looping
|
||||
* forever against a dead target.
|
||||
* - An in-flight request (possibly started by another mounted consumer of the
|
||||
* same directory) suppresses duplicate starts.
|
||||
*/
|
||||
export const useBoundedDirectoryRetry = (
|
||||
directory: string | null,
|
||||
isEnabled: boolean,
|
||||
isRequestInFlight: boolean,
|
||||
hasResult: boolean,
|
||||
startRequest: () => void,
|
||||
maxAttempts: number
|
||||
): boolean => {
|
||||
// Attempts live in a ref and the effect's deps deliberately exclude them:
|
||||
// a retry may only be triggered by an EXTERNAL transition (the in-flight
|
||||
// flag settling back to false, a directory switch, a result appearing), never
|
||||
// by the attempt counter itself — otherwise one start cascades into all
|
||||
// remaining attempts in a single commit.
|
||||
const attemptsRef = React.useRef<{ directory: string; attempts: number }>({ directory: '', attempts: 0 });
|
||||
const [exhaustedState, setExhaustedState] = React.useState<{ directory: string; exhausted: boolean }>(
|
||||
() => ({ directory: '', exhausted: false })
|
||||
);
|
||||
// The starter is read through a ref so an inline arrow from the caller
|
||||
// cannot restart the effect in a render loop.
|
||||
const startRequestRef = React.useRef(startRequest);
|
||||
startRequestRef.current = startRequest;
|
||||
|
||||
// A different directory's (or the initial empty) exhaustion state reads as
|
||||
// not exhausted; this derivation is the instant-reset guarantee above.
|
||||
const exhausted = Boolean(directory) && exhaustedState.directory === directory && exhaustedState.exhausted;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!directory || !isEnabled || hasResult || isRequestInFlight) {
|
||||
return;
|
||||
}
|
||||
const attempts = attemptsRef.current.directory === directory ? attemptsRef.current.attempts : 0;
|
||||
if (attempts >= maxAttempts) {
|
||||
if (!(exhaustedState.directory === directory && exhaustedState.exhausted)) {
|
||||
setExhaustedState({ directory, exhausted: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
attemptsRef.current = { directory, attempts: attempts + 1 };
|
||||
startRequestRef.current();
|
||||
}, [directory, exhaustedState, hasResult, isEnabled, isRequestInFlight, maxAttempts]);
|
||||
|
||||
return exhausted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-path cache of lazily fetched values, valid within a single range.
|
||||
*
|
||||
* - Changing `rangeKey` clears every entry (new base/head/directory = new
|
||||
* content for the same paths).
|
||||
* - Each expanded path is reserved with `placeholder` before its fetch starts,
|
||||
* so a re-run does not issue a duplicate request.
|
||||
* - Completions from a previous run can never write into the new range's
|
||||
* cache: every run is cancelled in its cleanup, and its callbacks ignore
|
||||
* results after cancellation. This covers the stale-completion case where an
|
||||
* old `fetchEntry` promise resolves (or rejects) after the range switched.
|
||||
* - Reservations that never completed are released on cleanup so a later run
|
||||
* retries those paths instead of showing the placeholder forever.
|
||||
*/
|
||||
export const useRangeKeyedCache = <T>(
|
||||
rangeKey: string | null,
|
||||
pathsKey: string,
|
||||
fetchEntry: ((path: string) => Promise<T>) | null,
|
||||
placeholder: T
|
||||
): ReadonlyMap<string, T> => {
|
||||
const [entries, setEntries] = React.useState<Map<string, T>>(() => new Map());
|
||||
const entriesRef = React.useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
|
||||
// The fetcher is read through a ref so a caller passing an inline arrow (a
|
||||
// new function every render) cannot restart the fetch effect in a loop.
|
||||
const fetchEntryRef = React.useRef(fetchEntry);
|
||||
fetchEntryRef.current = fetchEntry;
|
||||
|
||||
const writeEntry = React.useCallback((path: string, value: T | null) => {
|
||||
const next = new Map(entriesRef.current);
|
||||
if (value === null) {
|
||||
if (!next.delete(path)) return;
|
||||
} else {
|
||||
next.set(path, value);
|
||||
}
|
||||
entriesRef.current = next;
|
||||
setEntries(next);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!rangeKey) return;
|
||||
entriesRef.current = new Map();
|
||||
setEntries(entriesRef.current);
|
||||
}, [rangeKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetcher = fetchEntryRef.current;
|
||||
if (!rangeKey || !fetcher || !pathsKey) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const pendingReservations = new Set<string>();
|
||||
|
||||
for (const path of pathsKey.split('\0')) {
|
||||
if (entriesRef.current.has(path)) continue;
|
||||
pendingReservations.add(path);
|
||||
writeEntry(path, placeholder);
|
||||
fetcher(path)
|
||||
.then((value) => {
|
||||
if (cancelled) return;
|
||||
pendingReservations.delete(path);
|
||||
writeEntry(path, value);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// Release the reservation so a later run can retry this path.
|
||||
pendingReservations.delete(path);
|
||||
writeEntry(path, null);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const path of pendingReservations) {
|
||||
writeEntry(path, null);
|
||||
}
|
||||
};
|
||||
}, [pathsKey, placeholder, rangeKey, writeEntry]);
|
||||
|
||||
return entries;
|
||||
};
|
||||
@@ -157,6 +157,22 @@ export interface GetGitRangeDiffOptions {
|
||||
contextLines?: number;
|
||||
}
|
||||
|
||||
export interface GetGitRangeFilesOptions {
|
||||
base: string;
|
||||
head: string;
|
||||
}
|
||||
|
||||
/** One changed file in a `base...head` range, with its change letter (A/M/D/R/C). */
|
||||
export interface GitRangeFileEntry {
|
||||
path: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GitBranchBaseResponse {
|
||||
/** Null when git has no authoritative record of where the branch started. */
|
||||
base: string | null;
|
||||
}
|
||||
|
||||
export interface GitFileDiffResponse {
|
||||
original: string;
|
||||
modified: string;
|
||||
@@ -466,6 +482,8 @@ export interface GitAPI {
|
||||
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
||||
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
||||
getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise<GitDiffResponse>;
|
||||
getGitRangeFiles?(directory: string, options: GetGitRangeFilesOptions): Promise<GitRangeFileEntry[]>;
|
||||
getBranchBase?(directory: string, branch: string): Promise<GitBranchBaseResponse>;
|
||||
revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise<void>;
|
||||
stageGitFile(directory: string, filePath: string): Promise<void>;
|
||||
stageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
|
||||
|
||||
@@ -119,6 +119,24 @@ export async function getGitRangeDiff(
|
||||
return gitHttp.getGitRangeDiff(directory, options);
|
||||
}
|
||||
|
||||
export async function getGitRangeFiles(
|
||||
directory: string,
|
||||
options: import('./api/types').GetGitRangeFilesOptions
|
||||
): Promise<import('./api/types').GitRangeFileEntry[]> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.getGitRangeFiles) return runtime.getGitRangeFiles(directory, options);
|
||||
return gitHttp.getGitRangeFiles(directory, options);
|
||||
}
|
||||
|
||||
export async function getBranchBase(
|
||||
directory: string,
|
||||
branch: string
|
||||
): Promise<import('./api/types').GitBranchBaseResponse> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.getBranchBase) return runtime.getBranchBase(directory, branch);
|
||||
return gitHttp.getBranchBase(directory, branch);
|
||||
}
|
||||
|
||||
export async function revertGitFile(
|
||||
directory: string,
|
||||
filePath: string,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
GitDiffResponse,
|
||||
GetGitDiffOptions,
|
||||
GetGitRangeDiffOptions,
|
||||
GetGitRangeFilesOptions,
|
||||
GitFileDiffResponse,
|
||||
GetGitFileDiffOptions,
|
||||
GitBranch,
|
||||
@@ -248,6 +249,51 @@ export async function getGitRangeDiff(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGitRangeFiles(
|
||||
directory: string,
|
||||
options: GetGitRangeFilesOptions
|
||||
): Promise<import('./api/types').GitRangeFileEntry[]> {
|
||||
const { base, head } = options;
|
||||
if (!base || !head) {
|
||||
throw new Error('base and head are required to fetch git range files');
|
||||
}
|
||||
|
||||
const response = await runtimeFetch(
|
||||
buildUrl(`${API_BASE}/range-files`, directory, { base, head })
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git range files: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { files?: unknown };
|
||||
if (!Array.isArray(payload.files)) return [];
|
||||
return payload.files.filter((entry): entry is import('./api/types').GitRangeFileEntry => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const candidate = entry as { path?: unknown; status?: unknown };
|
||||
return typeof candidate.path === 'string' && typeof candidate.status === 'string';
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBranchBase(
|
||||
directory: string,
|
||||
branch: string
|
||||
): Promise<import('./api/types').GitBranchBaseResponse> {
|
||||
if (!branch) {
|
||||
throw new Error('branch is required to get branch base');
|
||||
}
|
||||
|
||||
const response = await runtimeFetch(
|
||||
buildUrl(`${API_BASE}/branch-base`, directory, { branch })
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get branch base: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
|
||||
const { path, staged } = options;
|
||||
if (!path) {
|
||||
|
||||
@@ -1360,6 +1360,13 @@ export const dict = {
|
||||
'diffView.scope.changed': 'Geändert',
|
||||
'diffView.scope.staged': 'Staged',
|
||||
'diffView.scope.lastTurn': 'Letzter Zug',
|
||||
'diffView.scope.branch': 'Branch',
|
||||
'diffView.branch.resolvingBase': 'Basis-Branch wird ermittelt...',
|
||||
'diffView.branch.noBaseTitle': 'Kein Basis-Branch',
|
||||
'diffView.branch.noBaseDescription': 'Git enthält keinen Eintrag, wo dieser Branch entstanden ist. Wähle einen Basis-Branch für den Vergleich.',
|
||||
'diffView.branch.loadError': 'Branch-Änderungen konnten nicht geladen werden',
|
||||
'diffView.branch.loadingFiles': 'Branch-Änderungen werden geladen...',
|
||||
'diffView.branch.empty': 'Keine Änderungen in diesem Branch gegenüber {base}',
|
||||
'diffView.scope.selectorAria': 'Änderungsmodus auswählen',
|
||||
'diffView.actions.retry': 'Erneut versuchen',
|
||||
'diffView.actions.renderAnyway': 'Trotzdem rendern',
|
||||
|
||||
@@ -1517,6 +1517,13 @@ export const dict = {
|
||||
'diffView.scope.changed': 'Changed',
|
||||
'diffView.scope.staged': 'Staged',
|
||||
'diffView.scope.lastTurn': 'Last turn',
|
||||
'diffView.scope.branch': 'Branch',
|
||||
'diffView.branch.resolvingBase': 'Detecting base branch...',
|
||||
'diffView.branch.noBaseTitle': 'No base branch',
|
||||
'diffView.branch.noBaseDescription': 'Git has no record of where this branch started. Choose a base branch to compare against.',
|
||||
'diffView.branch.loadError': 'Failed to load branch changes',
|
||||
'diffView.branch.loadingFiles': 'Loading branch changes...',
|
||||
'diffView.branch.empty': 'No changes on this branch relative to {base}',
|
||||
'diffView.scope.selectorAria': 'Select change mode',
|
||||
'diffView.actions.retry': 'Retry',
|
||||
'diffView.actions.renderAnyway': 'Render anyway',
|
||||
|
||||
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.scope.changed": "Cambiados",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "Último turno",
|
||||
"diffView.scope.branch": "Rama",
|
||||
"diffView.branch.resolvingBase": "Detectando rama base...",
|
||||
"diffView.branch.noBaseTitle": "Sin rama base",
|
||||
"diffView.branch.noBaseDescription": "Git no tiene registro de dónde surgió esta rama. Elige una rama base para comparar.",
|
||||
"diffView.branch.loadError": "No se pudieron cargar los cambios de la rama",
|
||||
"diffView.branch.loadingFiles": "Cargando cambios de la rama...",
|
||||
"diffView.branch.empty": "No hay cambios en esta rama respecto a {base}",
|
||||
"diffView.scope.selectorAria": "Seleccionar modo de cambios",
|
||||
"diffView.actions.retry": "Volver a intentar",
|
||||
"diffView.actions.renderAnyway": "Renderizar de todos modos",
|
||||
|
||||
@@ -1282,6 +1282,13 @@ export const dict = {
|
||||
"diffView.scope.changed": "Modifiés",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "Dernier tour",
|
||||
"diffView.scope.branch": "Branche",
|
||||
"diffView.branch.resolvingBase": "Détection de la branche de base...",
|
||||
"diffView.branch.noBaseTitle": "Aucune branche de base",
|
||||
"diffView.branch.noBaseDescription": "Git ne conserve aucune trace de la branche d’origine de cette branche. Choisissez une branche de base pour la comparaison.",
|
||||
"diffView.branch.loadError": "Échec du chargement des modifications de la branche",
|
||||
"diffView.branch.loadingFiles": "Chargement des modifications de la branche...",
|
||||
"diffView.branch.empty": "Aucune modification sur cette branche par rapport à {base}",
|
||||
"diffView.scope.selectorAria": "Sélectionner le mode de changements",
|
||||
'diffView.actions.retry': 'Réessayer',
|
||||
'diffView.actions.renderAnyway': 'Afficher quand même',
|
||||
|
||||
@@ -1513,6 +1513,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.scope.changed': '変更済み',
|
||||
'diffView.scope.staged': 'ステージ済み',
|
||||
'diffView.scope.lastTurn': '最後のターン',
|
||||
'diffView.scope.branch': 'ブランチ',
|
||||
'diffView.branch.resolvingBase': 'ベースブランチを検出中...',
|
||||
'diffView.branch.noBaseTitle': 'ベースブランチがありません',
|
||||
'diffView.branch.noBaseDescription': 'このブランチがどこから作られたかの記録がGitにありません。比較するベースブランチを選択してください。',
|
||||
'diffView.branch.loadError': 'ブランチの変更を読み込めませんでした',
|
||||
'diffView.branch.loadingFiles': 'ブランチの変更を読み込み中...',
|
||||
'diffView.branch.empty': 'このブランチには{base}に対する変更はありません',
|
||||
'diffView.scope.selectorAria': '変更モードを選択',
|
||||
'diffView.actions.retry': '再試行',
|
||||
'diffView.actions.renderAnyway': 'とにかくレンダリング',
|
||||
|
||||
@@ -1519,6 +1519,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.scope.changed": "Changed",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "마지막 턴",
|
||||
"diffView.scope.branch": "브랜치",
|
||||
"diffView.branch.resolvingBase": "베이스 브랜치 감지 중...",
|
||||
"diffView.branch.noBaseTitle": "베이스 브랜치 없음",
|
||||
"diffView.branch.noBaseDescription": "이 브랜치가 어디서 시작되었는지 Git에 기록이 없습니다. 비교할 베이스 브랜치를 선택하세요.",
|
||||
"diffView.branch.loadError": "브랜치 변경 사항을 불러오지 못했습니다",
|
||||
"diffView.branch.loadingFiles": "브랜치 변경 사항 불러오는 중...",
|
||||
"diffView.branch.empty": "이 브랜치에는 {base}에 대한 변경 사항이 없습니다",
|
||||
"diffView.scope.selectorAria": "변경 모드 선택",
|
||||
'diffView.actions.retry': '다시 시도',
|
||||
'diffView.actions.renderAnyway': '그래도 렌더링',
|
||||
|
||||
@@ -1795,6 +1795,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.scope.changed": "Zmienione",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "Ostatnia tura",
|
||||
"diffView.scope.branch": "Gałąź",
|
||||
"diffView.branch.resolvingBase": "Wykrywanie gałęzi bazowej...",
|
||||
"diffView.branch.noBaseTitle": "Brak gałęzi bazowej",
|
||||
"diffView.branch.noBaseDescription": "Git nie zapisuje, od której gałęzi ta gałąź powstała. Wybierz gałąź bazową do porównania.",
|
||||
"diffView.branch.loadError": "Nie udało się wczytać zmian gałęzi",
|
||||
"diffView.branch.loadingFiles": "Wczytywanie zmian gałęzi...",
|
||||
"diffView.branch.empty": "Brak zmian w tej gałęzi względem {base}",
|
||||
"diffView.scope.selectorAria": "Wybierz tryb zmian",
|
||||
'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik',
|
||||
'directoryExplorerDialog.actions.addProject': 'Dodaj projekt',
|
||||
|
||||
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.scope.changed": "Alteradas",
|
||||
"diffView.scope.staged": "Staged",
|
||||
"diffView.scope.lastTurn": "Último turno",
|
||||
"diffView.scope.branch": "Branch",
|
||||
"diffView.branch.resolvingBase": "Detectando branch base...",
|
||||
"diffView.branch.noBaseTitle": "Sem branch base",
|
||||
"diffView.branch.noBaseDescription": "O Git não tem registro de onde este branch começou. Escolha um branch base para comparar.",
|
||||
"diffView.branch.loadError": "Falha ao carregar as alterações do branch",
|
||||
"diffView.branch.loadingFiles": "Carregando alterações do branch...",
|
||||
"diffView.branch.empty": "Nenhuma alteração neste branch em relação a {base}",
|
||||
"diffView.scope.selectorAria": "Selecionar modo de alterações",
|
||||
"diffView.actions.retry": "Tentar novamente",
|
||||
"diffView.actions.renderAnyway": "Renderizar mesmo assim",
|
||||
|
||||
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.scope.changed": "Змінені",
|
||||
"diffView.scope.staged": "Індексовані",
|
||||
"diffView.scope.lastTurn": "Останній хід",
|
||||
"diffView.scope.branch": "Гілка",
|
||||
"diffView.branch.resolvingBase": "Визначаємо базову гілку...",
|
||||
"diffView.branch.noBaseTitle": "Немає базової гілки",
|
||||
"diffView.branch.noBaseDescription": "Git не зберігає, від якої гілки почалася ця гілка. Виберіть базову гілку для порівняння.",
|
||||
"diffView.branch.loadError": "Не вдалося завантажити зміни гілки",
|
||||
"diffView.branch.loadingFiles": "Завантаження змін гілки...",
|
||||
"diffView.branch.empty": "Немає змін у цій гілці відносно {base}",
|
||||
"diffView.scope.selectorAria": "Вибрати режим змін",
|
||||
"diffView.actions.retry": "Повторити спробу",
|
||||
"diffView.actions.renderAnyway": "Все одно відрендерити",
|
||||
|
||||
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.scope.changed": "已更改",
|
||||
"diffView.scope.staged": "已暂存",
|
||||
"diffView.scope.lastTurn": "上一轮",
|
||||
"diffView.scope.branch": "分支",
|
||||
"diffView.branch.resolvingBase": "正在检测基础分支...",
|
||||
"diffView.branch.noBaseTitle": "没有基础分支",
|
||||
"diffView.branch.noBaseDescription": "Git 中没有记录此分支的起点。请选择一个基础分支进行比较。",
|
||||
"diffView.branch.loadError": "加载分支更改失败",
|
||||
"diffView.branch.loadingFiles": "正在加载分支更改...",
|
||||
"diffView.branch.empty": "此分支相对于 {base} 没有更改",
|
||||
"diffView.scope.selectorAria": "选择更改模式",
|
||||
'diffView.actions.retry': '重试',
|
||||
'diffView.actions.renderAnyway': '仍然渲染',
|
||||
|
||||
@@ -1493,6 +1493,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.scope.changed": "已變更",
|
||||
"diffView.scope.staged": "已暫存",
|
||||
"diffView.scope.lastTurn": "上一輪",
|
||||
"diffView.scope.branch": "分支",
|
||||
"diffView.branch.resolvingBase": "正在偵測基礎分支...",
|
||||
"diffView.branch.noBaseTitle": "沒有基礎分支",
|
||||
"diffView.branch.noBaseDescription": "Git 中沒有記錄此分支的起點。請選擇基礎分支進行比較。",
|
||||
"diffView.branch.loadError": "載入分支變更失敗",
|
||||
"diffView.branch.loadingFiles": "正在載入分支變更...",
|
||||
"diffView.branch.empty": "此分支相對於 {base} 沒有變更",
|
||||
"diffView.scope.selectorAria": "選擇變更模式",
|
||||
'diffView.actions.retry': '重試',
|
||||
'diffView.actions.renderAnyway': '仍然渲染',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let runtimeKey = "runtime-a"
|
||||
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
|
||||
|
||||
const { gitBaseBranchEntryKey, useGitBaseBranchStore } = await import("./useGitBaseBranchStore")
|
||||
|
||||
describe("git base branch overrides", () => {
|
||||
beforeEach(() => {
|
||||
runtimeKey = "runtime-a"
|
||||
useGitBaseBranchStore.setState({ overrides: {} })
|
||||
})
|
||||
|
||||
test("keys the same repository per branch and runtime", () => {
|
||||
const featureA = gitBaseBranchEntryKey("/repo", "feature-a")
|
||||
const featureB = gitBaseBranchEntryKey("/repo", "feature-b")
|
||||
runtimeKey = "runtime-b"
|
||||
const featureARemote = gitBaseBranchEntryKey("/repo", "feature-a")
|
||||
|
||||
expect(new Set([featureA, featureB, featureARemote]).size).toBe(3)
|
||||
})
|
||||
|
||||
test("a base picked for one branch does not apply to another branch", () => {
|
||||
const store = useGitBaseBranchStore.getState()
|
||||
store.setOverride("/repo", "feature-a", "main")
|
||||
|
||||
expect(store.getOverride("/repo", "feature-a")).toBe("main")
|
||||
// feature-b must fall back to its own detection, not feature-a's choice.
|
||||
expect(store.getOverride("/repo", "feature-b")).toBeNull()
|
||||
})
|
||||
|
||||
test("different branches of one repository keep independent bases", () => {
|
||||
const store = useGitBaseBranchStore.getState()
|
||||
store.setOverride("/repo", "feature-a", "main")
|
||||
store.setOverride("/repo", "feature-b", "develop")
|
||||
|
||||
expect(store.getOverride("/repo", "feature-a")).toBe("main")
|
||||
expect(store.getOverride("/repo", "feature-b")).toBe("develop")
|
||||
})
|
||||
|
||||
test("clearOverride removes only the targeted branch's choice", () => {
|
||||
const store = useGitBaseBranchStore.getState()
|
||||
store.setOverride("/repo", "feature-a", "main")
|
||||
store.setOverride("/repo", "feature-b", "develop")
|
||||
store.clearOverride("/repo", "feature-a")
|
||||
|
||||
expect(store.getOverride("/repo", "feature-a")).toBeNull()
|
||||
expect(store.getOverride("/repo", "feature-b")).toBe("develop")
|
||||
})
|
||||
|
||||
test("rejects empty directory, branch, or base", () => {
|
||||
const store = useGitBaseBranchStore.getState()
|
||||
store.setOverride("", "feature-a", "main")
|
||||
store.setOverride("/repo", "", "main")
|
||||
store.setOverride("/repo", "feature-a", "")
|
||||
store.clearOverride("", "feature-a")
|
||||
|
||||
expect(useGitBaseBranchStore.getState().overrides).toEqual({})
|
||||
expect(store.getOverride("", "feature-a")).toBeNull()
|
||||
expect(store.getOverride("/repo", "")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
|
||||
const GIT_BASE_BRANCH_STORAGE_KEY = 'openchamber.git-base-branch';
|
||||
const MAX_BASE_BRANCH_ENTRIES = 100;
|
||||
|
||||
/**
|
||||
* Build the persisted override key for one branch of one repository.
|
||||
*
|
||||
* The branch is part of the identity on purpose: a base picked for one feature
|
||||
* branch is not an answer for a different branch of the same repository, and a
|
||||
* directory-only key would silently shadow reflog detection after checkout.
|
||||
* Keys include the runtime identity so a remote runtime's paths never shadow
|
||||
* local ones.
|
||||
*/
|
||||
export const gitBaseBranchEntryKey = (directory: string, branch: string): string =>
|
||||
JSON.stringify([getRuntimeKey(), directory, branch]);
|
||||
|
||||
type GitBaseBranchState = {
|
||||
overrides: Record<string, string>;
|
||||
getOverride: (directory: string, branch: string) => string | null;
|
||||
setOverride: (directory: string, branch: string, base: string) => void;
|
||||
clearOverride: (directory: string, branch: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicit per-branch base choices for the "Branch" diff scope.
|
||||
*
|
||||
* Git does not record a parent branch for every branch (clones, detached
|
||||
* starts). When no authoritative source exists, the user picks a base once and
|
||||
* the choice is remembered for that branch.
|
||||
*/
|
||||
export const useGitBaseBranchStore = create<GitBaseBranchState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
overrides: {},
|
||||
getOverride: (directory, branch) => {
|
||||
if (!directory || !branch) return null;
|
||||
return get().overrides[gitBaseBranchEntryKey(directory, branch)] ?? null;
|
||||
},
|
||||
setOverride: (directory, branch, base) => {
|
||||
if (!directory || !branch || !base) return;
|
||||
set((state) => {
|
||||
const key = gitBaseBranchEntryKey(directory, branch);
|
||||
const entries = Object.entries({ ...state.overrides, [key]: base });
|
||||
while (entries.length > MAX_BASE_BRANCH_ENTRIES) {
|
||||
entries.shift();
|
||||
}
|
||||
return { overrides: Object.fromEntries(entries) };
|
||||
});
|
||||
},
|
||||
clearOverride: (directory, branch) => {
|
||||
if (!directory || !branch) return;
|
||||
set((state) => {
|
||||
const key = gitBaseBranchEntryKey(directory, branch);
|
||||
if (!(key in state.overrides)) return state;
|
||||
const next = { ...state.overrides };
|
||||
delete next[key];
|
||||
return { overrides: next };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: GIT_BASE_BRANCH_STORAGE_KEY,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({ overrides: state.overrides }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -20,7 +20,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
|
||||
/** @deprecated Use WorkspaceSurface. */
|
||||
export type MainTab = WorkspaceSurface;
|
||||
export type PendingDiffScope = 'working' | 'staged' | 'turn';
|
||||
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
|
||||
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
|
||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
@@ -205,7 +205,7 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
|
||||
};
|
||||
|
||||
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
|
||||
return value === 'working' || value === 'staged' || value === 'turn' ? value : null;
|
||||
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null;
|
||||
};
|
||||
|
||||
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
|
||||
|
||||
@@ -428,6 +428,49 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/branch-base', async (req, res) => {
|
||||
const { getBranchBase } = await getGitLibraries();
|
||||
try {
|
||||
const directory = resolveDirectoryQuery(req.query.directory);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const branch = resolveDirectoryQuery(req.query.branch);
|
||||
if (!branch) {
|
||||
return res.status(400).json({ error: 'branch parameter is required' });
|
||||
}
|
||||
|
||||
const result = await getBranchBase(directory, branch);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to get branch base:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get branch base' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/range-files', async (req, res) => {
|
||||
const { getRangeFiles } = await getGitLibraries();
|
||||
try {
|
||||
const directory = resolveDirectoryQuery(req.query.directory);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const base = resolveDirectoryQuery(req.query.base);
|
||||
const head = resolveDirectoryQuery(req.query.head);
|
||||
if (!base || !head) {
|
||||
return res.status(400).json({ error: 'base and head parameters are required' });
|
||||
}
|
||||
|
||||
const files = await getRangeFiles(directory, { base, head });
|
||||
res.json({ files });
|
||||
} catch (error) {
|
||||
console.error('Failed to get git range files:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get git range files' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/revert', async (req, res) => {
|
||||
const { revertFile } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -2654,6 +2654,71 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
||||
return diff;
|
||||
}
|
||||
|
||||
const BRANCH_CREATION_SOURCE_RE = /^branch: Created from (.+)$/;
|
||||
|
||||
/**
|
||||
* Parse a branch reflog (`git reflog show --format=%gs <branch>`) and return the
|
||||
* ref the branch was created from, when that source is itself a named ref.
|
||||
*
|
||||
* Returns null when the branch was created from `HEAD@{...}` or a raw commit
|
||||
* (detached start): the original branch name is not recorded anywhere in that
|
||||
* case, and guessing a base from commit topology would be a heuristic, not an
|
||||
* answer. Callers should ask the user to pick a base instead.
|
||||
*/
|
||||
export function parseBranchCreationSource(reflogText) {
|
||||
const lines = String(reflogText || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
// Reflog lists newest entries first; the creation entry is the oldest one.
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const match = lines[index].match(BRANCH_CREATION_SOURCE_RE);
|
||||
if (!match) continue;
|
||||
const source = match[1].trim();
|
||||
if (!source || /^HEAD@/.test(source) || /^[0-9a-f]{7,40}$/i.test(source)) {
|
||||
return null;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the branch the given branch was created from, from its reflog.
|
||||
* Returns { base: null } when git has no authoritative record (clone, detached
|
||||
* start, reflog expired) — callers must not fall back to main/master.
|
||||
*/
|
||||
export async function getBranchBase(directory, branch) {
|
||||
const branchName = String(branch || '').trim();
|
||||
if (!branchName) {
|
||||
throw new Error('branch is required');
|
||||
}
|
||||
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
|
||||
let reflog = '';
|
||||
try {
|
||||
reflog = await git.raw(['reflog', 'show', '--format=%gs', branchName]);
|
||||
} catch {
|
||||
return { base: null };
|
||||
}
|
||||
|
||||
const source = parseBranchCreationSource(reflog);
|
||||
if (!source || source === branchName) {
|
||||
return { base: null };
|
||||
}
|
||||
|
||||
const resolves = await git
|
||||
.raw(['rev-parse', '--verify', '--quiet', source])
|
||||
.then((value) => Boolean(String(value || '').trim()))
|
||||
.catch(() => false);
|
||||
if (!resolves) {
|
||||
return { base: null };
|
||||
}
|
||||
|
||||
return { base: source };
|
||||
}
|
||||
|
||||
export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
@@ -2673,11 +2738,26 @@ export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
|
||||
return String(raw || '')
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
// `-C` (copy detection among changed files only, so cheap) makes copies
|
||||
// surface as C entries instead of plain additions; rename detection is on
|
||||
// by default.
|
||||
const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]);
|
||||
// -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries
|
||||
// (`R100`, `C75`) the first path token is the ORIGINAL path and the second
|
||||
// is the DESTINATION — the diff (and the UI) must address the destination.
|
||||
const tokens = String(raw || '').split('\0');
|
||||
const files = [];
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const status = (tokens[index] || '').trim();
|
||||
if (!status) continue;
|
||||
const isRenameOrCopy = status.startsWith('R') || status.startsWith('C');
|
||||
const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim();
|
||||
index += isRenameOrCopy ? 2 : 1;
|
||||
if (path) {
|
||||
files.push({ path, status: status.charAt(0) });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
getDiff,
|
||||
getFileDiff,
|
||||
validateWorktreeCreate,
|
||||
parseBranchCreationSource,
|
||||
getRangeFiles,
|
||||
} from './service.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1336,3 +1338,94 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
expect(diff).toContain('feature.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBranchCreationSource', () => {
|
||||
it('returns the source ref from the oldest creation entry', () => {
|
||||
// Reflog lists newest entries first; creation is the last line.
|
||||
const reflog = [
|
||||
'commit: abc123',
|
||||
'branch: Created from origin/main',
|
||||
].join('\n');
|
||||
expect(parseBranchCreationSource(reflog)).toBe('origin/main');
|
||||
});
|
||||
|
||||
it('returns null when the branch was created from a detached HEAD pointer', () => {
|
||||
const reflog = 'branch: Created from HEAD@{0}';
|
||||
expect(parseBranchCreationSource(reflog)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the branch was created from a raw commit', () => {
|
||||
const reflog = 'branch: Created from 9a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b';
|
||||
expect(parseBranchCreationSource(reflog)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no creation entry', () => {
|
||||
const reflog = ['commit: abc123', 'reset: moving to HEAD'].join('\n');
|
||||
expect(parseBranchCreationSource(reflog)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty input', () => {
|
||||
expect(parseBranchCreationSource('')).toBeNull();
|
||||
expect(parseBranchCreationSource(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
it('returns added and modified paths with their status letters', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'added.txt'), 'new\n');
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\nchanged\n');
|
||||
runGit(repository, ['add', 'added.txt', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'changes']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
|
||||
expect(files).toEqual(expect.arrayContaining([
|
||||
{ path: 'added.txt', status: 'A' },
|
||||
{ path: 'README.md', status: 'M' },
|
||||
]));
|
||||
});
|
||||
|
||||
it('reports the destination path for renamed files, including spaces', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
// The original file must exist in the base: rename detection pairs a
|
||||
// deletion against an addition relative to base, not within the branch.
|
||||
fs.writeFileSync(path.join(repository, 'old name with spaces.md'), '# Test\n');
|
||||
runGit(repository, ['add', 'old name with spaces.md']);
|
||||
runGit(repository, ['commit', '-m', 'add file to rename']);
|
||||
runGit(repository, ['push', 'origin', 'HEAD:react']);
|
||||
// Spaces in filenames exercise the -z token split: a newline split would
|
||||
// mangle these paths long before status letters matter.
|
||||
fs.renameSync(path.join(repository, 'old name with spaces.md'), path.join(repository, 'new name with spaces.md'));
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'rename']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
|
||||
const renameEntry = files.find((file) => file.status === 'R');
|
||||
expect(renameEntry).toBeDefined();
|
||||
expect(renameEntry.path).toBe('new name with spaces.md');
|
||||
expect(files.some((file) => file.path === 'old name with spaces.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports the destination path for copied files', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
// The source must exist in the base. Copy detection needs the repository's
|
||||
// own `diff.renames=copies` setting on top of the service's -C flag; the
|
||||
// parser must survive whatever C entries git emits.
|
||||
runGit(repository, ['config', 'diff.renames', 'copies']);
|
||||
fs.writeFileSync(path.join(repository, 'copied source.md'), '# Copy me\n');
|
||||
runGit(repository, ['add', 'copied source.md']);
|
||||
runGit(repository, ['commit', '-m', 'add source']);
|
||||
runGit(repository, ['push', 'origin', 'HEAD:react']);
|
||||
fs.copyFileSync(path.join(repository, 'copied source.md'), path.join(repository, 'copied destination.md'));
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'copy']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
|
||||
const copyEntry = files.find((file) => file.status === 'C');
|
||||
expect(copyEntry).toBeDefined();
|
||||
expect(copyEntry.path).toBe('copied destination.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
getGitDiff: gitApiHttp.getGitDiff,
|
||||
getGitFileDiff: gitApiHttp.getGitFileDiff,
|
||||
getGitRangeDiff: gitApiHttp.getGitRangeDiff,
|
||||
getGitRangeFiles: gitApiHttp.getGitRangeFiles,
|
||||
getBranchBase: gitApiHttp.getBranchBase,
|
||||
revertGitFile: gitApiHttp.revertGitFile,
|
||||
stageGitFile: gitApiHttp.stageGitFile,
|
||||
stageGitFiles: gitApiHttp.stageGitFiles,
|
||||
|
||||
Reference in New Issue
Block a user