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 { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
|
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 { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { GitStatus } from '@/lib/api/types';
|
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -79,7 +82,7 @@ type DiffData = {
|
|||||||
fileDiff?: FileDiffMetadata;
|
fileDiff?: FileDiffMetadata;
|
||||||
contextMode?: DiffContextMode;
|
contextMode?: DiffContextMode;
|
||||||
};
|
};
|
||||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn';
|
type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch';
|
||||||
|
|
||||||
type TurnSnapshotDiff = {
|
type TurnSnapshotDiff = {
|
||||||
file?: string;
|
file?: string;
|
||||||
@@ -91,6 +94,17 @@ type TurnSnapshotDiff = {
|
|||||||
deletions?: number;
|
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 BinaryDiffPlaceholder = React.memo(() => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
@@ -230,11 +244,13 @@ const formatDiffTotals = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ChangeScopeSelectorProps {
|
interface ChangeScopeSelectorProps {
|
||||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>;
|
scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>;
|
||||||
workingCount: number;
|
workingCount: number;
|
||||||
stagedCount: number;
|
stagedCount: number;
|
||||||
turnCount: 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>(({
|
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||||
@@ -242,16 +258,20 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
|||||||
workingCount,
|
workingCount,
|
||||||
stagedCount,
|
stagedCount,
|
||||||
turnCount,
|
turnCount,
|
||||||
|
branchCount,
|
||||||
|
showBranchOption,
|
||||||
onScopeChange,
|
onScopeChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = React.useState(false);
|
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'
|
const currentLabel = scope === 'staged'
|
||||||
? t('diffView.scope.staged')
|
? t('diffView.scope.staged')
|
||||||
: scope === 'turn'
|
: scope === 'turn'
|
||||||
? t('diffView.scope.lastTurn')
|
? t('diffView.scope.lastTurn')
|
||||||
: t('diffView.scope.changed');
|
: scope === 'branch'
|
||||||
|
? t('diffView.scope.branch')
|
||||||
|
: t('diffView.scope.changed');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
@@ -271,7 +291,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
|||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
value={scope}
|
value={scope}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === 'working' || value === 'staged' || value === 'turn') {
|
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') {
|
||||||
onScopeChange?.(value);
|
onScopeChange?.(value);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
@@ -295,6 +315,14 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
|||||||
<span className="typography-meta text-muted-foreground">{turnCount}</span>
|
<span className="typography-meta text-muted-foreground">{turnCount}</span>
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuRadioItem>
|
</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>
|
</DropdownMenuRadioGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -574,6 +602,8 @@ interface MultiFileDiffEntryProps {
|
|||||||
staged?: boolean;
|
staged?: boolean;
|
||||||
loadFullFiles?: boolean;
|
loadFullFiles?: boolean;
|
||||||
initialDiffData?: DiffData | null;
|
initialDiffData?: DiffData | null;
|
||||||
|
/** Hide stage/unstage/revert actions (read-only scopes like branch diffs). */
|
||||||
|
readOnlyActions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||||
@@ -593,6 +623,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
staged = false,
|
staged = false,
|
||||||
loadFullFiles = false,
|
loadFullFiles = false,
|
||||||
initialDiffData = null,
|
initialDiffData = null,
|
||||||
|
readOnlyActions = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { git } = useRuntimeAPIs();
|
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-none absolute bottom-3 right-3 z-20">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<FileDiffActions
|
{!readOnlyActions ? (
|
||||||
filePath={file.path}
|
<FileDiffActions
|
||||||
staged={staged}
|
filePath={file.path}
|
||||||
busyAction={fileAction}
|
staged={staged}
|
||||||
disabled={fileAction !== null}
|
busyAction={fileAction}
|
||||||
onAction={handleFileAction}
|
disabled={fileAction !== null}
|
||||||
/>
|
onAction={handleFileAction}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -945,7 +978,7 @@ interface DiffViewProps {
|
|||||||
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
||||||
showOpenInEditorAction?: boolean;
|
showOpenInEditorAction?: boolean;
|
||||||
diffScope?: DiffScope;
|
diffScope?: DiffScope;
|
||||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
|
||||||
targetFilePath?: string | null;
|
targetFilePath?: string | null;
|
||||||
/** Render diff content flush with the container edges (no outer padding). */
|
/** Render diff content flush with the container edges (no outer padding). */
|
||||||
flushContent?: boolean;
|
flushContent?: boolean;
|
||||||
@@ -974,6 +1007,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||||
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
||||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||||
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||||
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
|
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
|
||||||
const setDiff = useGitStore((state) => state.setDiff);
|
const setDiff = useGitStore((state) => state.setDiff);
|
||||||
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
|
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
|
||||||
@@ -1083,7 +1117,213 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
return map;
|
return map;
|
||||||
}, [lastTurnDiffs]);
|
}, [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(() => {
|
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') {
|
if (activeDiffScope === 'turn') {
|
||||||
return lastTurnDiffs
|
return lastTurnDiffs
|
||||||
.map((diff) => ({
|
.map((diff) => ({
|
||||||
@@ -1115,19 +1355,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
isNew: isNewStatusFile(file),
|
isNew: isNewStatusFile(file),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.path.localeCompare(b.path));
|
.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
}, [activeDiffScope, lastTurnDiffs, status]);
|
}, [activeDiffScope, branchFiles, 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;
|
|
||||||
|
|
||||||
const changedFilePathsKey = React.useMemo(
|
const changedFilePathsKey = React.useMemo(
|
||||||
() => changedFiles.map((file) => file.path).join('\0'),
|
() => changedFiles.map((file) => file.path).join('\0'),
|
||||||
@@ -1670,7 +1898,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
staged={getFileStaged(file.path)}
|
staged={getFileStaged(file.path)}
|
||||||
loadFullFiles={loadFullFiles}
|
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>
|
</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) {
|
if (changedFiles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1722,12 +2040,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
<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">
|
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? (
|
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
|
||||||
<ChangeScopeSelector
|
<ChangeScopeSelector
|
||||||
scope={activeDiffScope}
|
scope={activeDiffScope}
|
||||||
workingCount={workingFileCount}
|
workingCount={workingFileCount}
|
||||||
stagedCount={stagedFileCount}
|
stagedCount={stagedFileCount}
|
||||||
turnCount={turnFileCount}
|
turnCount={turnFileCount}
|
||||||
|
branchCount={branchFileCount}
|
||||||
|
showBranchOption={showBranchOption}
|
||||||
onScopeChange={(scope) => {
|
onScopeChange={(scope) => {
|
||||||
setActiveDiffScope(scope);
|
setActiveDiffScope(scope);
|
||||||
onDiffScopeChange?.(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;
|
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 {
|
export interface GitFileDiffResponse {
|
||||||
original: string;
|
original: string;
|
||||||
modified: string;
|
modified: string;
|
||||||
@@ -466,6 +482,8 @@ export interface GitAPI {
|
|||||||
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
||||||
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
||||||
getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise<GitDiffResponse>;
|
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>;
|
revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise<void>;
|
||||||
stageGitFile(directory: string, filePath: string): Promise<void>;
|
stageGitFile(directory: string, filePath: string): Promise<void>;
|
||||||
stageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
|
stageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
|
||||||
|
|||||||
@@ -119,6 +119,24 @@ export async function getGitRangeDiff(
|
|||||||
return gitHttp.getGitRangeDiff(directory, options);
|
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(
|
export async function revertGitFile(
|
||||||
directory: string,
|
directory: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
GitDiffResponse,
|
GitDiffResponse,
|
||||||
GetGitDiffOptions,
|
GetGitDiffOptions,
|
||||||
GetGitRangeDiffOptions,
|
GetGitRangeDiffOptions,
|
||||||
|
GetGitRangeFilesOptions,
|
||||||
GitFileDiffResponse,
|
GitFileDiffResponse,
|
||||||
GetGitFileDiffOptions,
|
GetGitFileDiffOptions,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
@@ -248,6 +249,51 @@ export async function getGitRangeDiff(
|
|||||||
return response.json();
|
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> {
|
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
|
||||||
const { path, staged } = options;
|
const { path, staged } = options;
|
||||||
if (!path) {
|
if (!path) {
|
||||||
|
|||||||
@@ -1360,6 +1360,13 @@ export const dict = {
|
|||||||
'diffView.scope.changed': 'Geändert',
|
'diffView.scope.changed': 'Geändert',
|
||||||
'diffView.scope.staged': 'Staged',
|
'diffView.scope.staged': 'Staged',
|
||||||
'diffView.scope.lastTurn': 'Letzter Zug',
|
'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.scope.selectorAria': 'Änderungsmodus auswählen',
|
||||||
'diffView.actions.retry': 'Erneut versuchen',
|
'diffView.actions.retry': 'Erneut versuchen',
|
||||||
'diffView.actions.renderAnyway': 'Trotzdem rendern',
|
'diffView.actions.renderAnyway': 'Trotzdem rendern',
|
||||||
|
|||||||
@@ -1517,6 +1517,13 @@ export const dict = {
|
|||||||
'diffView.scope.changed': 'Changed',
|
'diffView.scope.changed': 'Changed',
|
||||||
'diffView.scope.staged': 'Staged',
|
'diffView.scope.staged': 'Staged',
|
||||||
'diffView.scope.lastTurn': 'Last turn',
|
'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.scope.selectorAria': 'Select change mode',
|
||||||
'diffView.actions.retry': 'Retry',
|
'diffView.actions.retry': 'Retry',
|
||||||
'diffView.actions.renderAnyway': 'Render anyway',
|
'diffView.actions.renderAnyway': 'Render anyway',
|
||||||
|
|||||||
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"diffView.scope.changed": "Cambiados",
|
"diffView.scope.changed": "Cambiados",
|
||||||
"diffView.scope.staged": "Staged",
|
"diffView.scope.staged": "Staged",
|
||||||
"diffView.scope.lastTurn": "Último turno",
|
"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.scope.selectorAria": "Seleccionar modo de cambios",
|
||||||
"diffView.actions.retry": "Volver a intentar",
|
"diffView.actions.retry": "Volver a intentar",
|
||||||
"diffView.actions.renderAnyway": "Renderizar de todos modos",
|
"diffView.actions.renderAnyway": "Renderizar de todos modos",
|
||||||
|
|||||||
@@ -1282,6 +1282,13 @@ export const dict = {
|
|||||||
"diffView.scope.changed": "Modifiés",
|
"diffView.scope.changed": "Modifiés",
|
||||||
"diffView.scope.staged": "Staged",
|
"diffView.scope.staged": "Staged",
|
||||||
"diffView.scope.lastTurn": "Dernier tour",
|
"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.scope.selectorAria": "Sélectionner le mode de changements",
|
||||||
'diffView.actions.retry': 'Réessayer',
|
'diffView.actions.retry': 'Réessayer',
|
||||||
'diffView.actions.renderAnyway': 'Afficher quand même',
|
'diffView.actions.renderAnyway': 'Afficher quand même',
|
||||||
|
|||||||
@@ -1513,6 +1513,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'diffView.scope.changed': '変更済み',
|
'diffView.scope.changed': '変更済み',
|
||||||
'diffView.scope.staged': 'ステージ済み',
|
'diffView.scope.staged': 'ステージ済み',
|
||||||
'diffView.scope.lastTurn': '最後のターン',
|
'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.scope.selectorAria': '変更モードを選択',
|
||||||
'diffView.actions.retry': '再試行',
|
'diffView.actions.retry': '再試行',
|
||||||
'diffView.actions.renderAnyway': 'とにかくレンダリング',
|
'diffView.actions.renderAnyway': 'とにかくレンダリング',
|
||||||
|
|||||||
@@ -1519,6 +1519,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"diffView.scope.changed": "Changed",
|
"diffView.scope.changed": "Changed",
|
||||||
"diffView.scope.staged": "Staged",
|
"diffView.scope.staged": "Staged",
|
||||||
"diffView.scope.lastTurn": "마지막 턴",
|
"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.scope.selectorAria": "변경 모드 선택",
|
||||||
'diffView.actions.retry': '다시 시도',
|
'diffView.actions.retry': '다시 시도',
|
||||||
'diffView.actions.renderAnyway': '그래도 렌더링',
|
'diffView.actions.renderAnyway': '그래도 렌더링',
|
||||||
|
|||||||
@@ -1795,6 +1795,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"diffView.scope.changed": "Zmienione",
|
"diffView.scope.changed": "Zmienione",
|
||||||
"diffView.scope.staged": "Staged",
|
"diffView.scope.staged": "Staged",
|
||||||
"diffView.scope.lastTurn": "Ostatnia tura",
|
"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.scope.selectorAria": "Wybierz tryb zmian",
|
||||||
'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik',
|
'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik',
|
||||||
'directoryExplorerDialog.actions.addProject': 'Dodaj projekt',
|
'directoryExplorerDialog.actions.addProject': 'Dodaj projekt',
|
||||||
|
|||||||
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"diffView.scope.changed": "Alteradas",
|
"diffView.scope.changed": "Alteradas",
|
||||||
"diffView.scope.staged": "Staged",
|
"diffView.scope.staged": "Staged",
|
||||||
"diffView.scope.lastTurn": "Último turno",
|
"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.scope.selectorAria": "Selecionar modo de alterações",
|
||||||
"diffView.actions.retry": "Tentar novamente",
|
"diffView.actions.retry": "Tentar novamente",
|
||||||
"diffView.actions.renderAnyway": "Renderizar mesmo assim",
|
"diffView.actions.renderAnyway": "Renderizar mesmo assim",
|
||||||
|
|||||||
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"diffView.scope.changed": "Змінені",
|
"diffView.scope.changed": "Змінені",
|
||||||
"diffView.scope.staged": "Індексовані",
|
"diffView.scope.staged": "Індексовані",
|
||||||
"diffView.scope.lastTurn": "Останній хід",
|
"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.scope.selectorAria": "Вибрати режим змін",
|
||||||
"diffView.actions.retry": "Повторити спробу",
|
"diffView.actions.retry": "Повторити спробу",
|
||||||
"diffView.actions.renderAnyway": "Все одно відрендерити",
|
"diffView.actions.renderAnyway": "Все одно відрендерити",
|
||||||
|
|||||||
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"diffView.scope.changed": "已更改",
|
"diffView.scope.changed": "已更改",
|
||||||
"diffView.scope.staged": "已暂存",
|
"diffView.scope.staged": "已暂存",
|
||||||
"diffView.scope.lastTurn": "上一轮",
|
"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.scope.selectorAria": "选择更改模式",
|
||||||
'diffView.actions.retry': '重试',
|
'diffView.actions.retry': '重试',
|
||||||
'diffView.actions.renderAnyway': '仍然渲染',
|
'diffView.actions.renderAnyway': '仍然渲染',
|
||||||
|
|||||||
@@ -1493,6 +1493,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"diffView.scope.changed": "已變更",
|
"diffView.scope.changed": "已變更",
|
||||||
"diffView.scope.staged": "已暫存",
|
"diffView.scope.staged": "已暫存",
|
||||||
"diffView.scope.lastTurn": "上一輪",
|
"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.scope.selectorAria": "選擇變更模式",
|
||||||
'diffView.actions.retry': '重試',
|
'diffView.actions.retry': '重試',
|
||||||
'diffView.actions.renderAnyway': '仍然渲染',
|
'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';
|
export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
|
||||||
/** @deprecated Use WorkspaceSurface. */
|
/** @deprecated Use WorkspaceSurface. */
|
||||||
export type MainTab = 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 ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
|
||||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||||
@@ -205,7 +205,7 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
|
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 => {
|
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) => {
|
app.post('/api/git/revert', async (req, res) => {
|
||||||
const { revertFile } = await getGitLibraries();
|
const { revertFile } = await getGitLibraries();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2654,6 +2654,71 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
|||||||
return diff;
|
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 } = {}) {
|
export async function getRangeFiles(directory, { base, head } = {}) {
|
||||||
const { git } = await createRepositoryGitContext(directory);
|
const { git } = await createRepositoryGitContext(directory);
|
||||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||||
@@ -2673,11 +2738,26 @@ export async function getRangeFiles(directory, { base, head } = {}) {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
|
// `-C` (copy detection among changed files only, so cheap) makes copies
|
||||||
return String(raw || '')
|
// surface as C entries instead of plain additions; rename detection is on
|
||||||
.split('\n')
|
// by default.
|
||||||
.map((l) => l.trim())
|
const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]);
|
||||||
.filter(Boolean);
|
// -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'];
|
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
getDiff,
|
getDiff,
|
||||||
getFileDiff,
|
getFileDiff,
|
||||||
validateWorktreeCreate,
|
validateWorktreeCreate,
|
||||||
|
parseBranchCreationSource,
|
||||||
|
getRangeFiles,
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1336,3 +1338,94 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
|
|||||||
expect(diff).toContain('feature.txt');
|
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,
|
getGitDiff: gitApiHttp.getGitDiff,
|
||||||
getGitFileDiff: gitApiHttp.getGitFileDiff,
|
getGitFileDiff: gitApiHttp.getGitFileDiff,
|
||||||
getGitRangeDiff: gitApiHttp.getGitRangeDiff,
|
getGitRangeDiff: gitApiHttp.getGitRangeDiff,
|
||||||
|
getGitRangeFiles: gitApiHttp.getGitRangeFiles,
|
||||||
|
getBranchBase: gitApiHttp.getBranchBase,
|
||||||
revertGitFile: gitApiHttp.revertGitFile,
|
revertGitFile: gitApiHttp.revertGitFile,
|
||||||
stageGitFile: gitApiHttp.stageGitFile,
|
stageGitFile: gitApiHttp.stageGitFile,
|
||||||
stageGitFiles: gitApiHttp.stageGitFiles,
|
stageGitFiles: gitApiHttp.stageGitFiles,
|
||||||
|
|||||||
Reference in New Issue
Block a user