Merge main into pr1-large-file-preview
The floating editor toolbar this branch was based on was removed on main (the toolbar is always docked now); the file-view region resolves to main's structure with this branch's virtualizer-bound ScrollableOverlay.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
@@ -27,7 +28,6 @@ export function ArchiveView(): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const open = useUIStore((state) => state.isArchivePageOpen);
|
||||
const setOpen = useUIStore((state) => state.setArchivePageOpen);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
@@ -67,7 +67,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
// while not searching.
|
||||
const filteredSessions = React.useMemo(() => {
|
||||
if (normalizedQuery) {
|
||||
return sortedSessions.filter((session) => (session.title ?? '').toLowerCase().includes(normalizedQuery));
|
||||
return rankByQuery(sortedSessions, normalizedQuery, (session) => [session.title]);
|
||||
}
|
||||
if (selectedDirectory === null) return sortedSessions;
|
||||
return buckets.find((bucket) => bucket.directory === selectedDirectory)?.sessions ?? [];
|
||||
@@ -85,9 +85,8 @@ export function ArchiveView(): React.ReactNode {
|
||||
const openSession = React.useCallback((session: Session) => {
|
||||
const directory = normalizePath(resolveGlobalSessionDirectory(session));
|
||||
setCurrentSession(session.id, directory ?? undefined);
|
||||
setActiveMainTab('chat');
|
||||
setOpen(false);
|
||||
}, [setActiveMainTab, setCurrentSession, setOpen]);
|
||||
}, [setCurrentSession, setOpen]);
|
||||
|
||||
const restoreSession = React.useCallback((session: Session) => {
|
||||
void unarchiveSession(session.id).then((success) => {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { DiagramEditor, type DiagramEditorHandle } from '@/components/diagram';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
export function DiagramView() {
|
||||
const { t } = useI18n();
|
||||
const { files } = useRuntimeAPIs();
|
||||
|
||||
const [filePath, setFilePath] = React.useState<string | null>(null);
|
||||
const [xml, setXml] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const editorRef = React.useRef<DiagramEditorHandle>(null);
|
||||
const pendingDiagramFile = useUIStore((state) => state.pendingDiagramFile);
|
||||
|
||||
const loadFile = React.useCallback(async (path: string) => {
|
||||
setLoading(true);
|
||||
setFilePath(path);
|
||||
try {
|
||||
const result = await files?.readFile?.(path);
|
||||
if (result) {
|
||||
setXml(result.content);
|
||||
}
|
||||
} catch {
|
||||
setXml('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingDiagramFile) {
|
||||
return;
|
||||
}
|
||||
const pending = useUIStore.getState().consumePendingDiagramFile();
|
||||
if (pending) {
|
||||
void loadFile(pending);
|
||||
}
|
||||
}, [loadFile, pendingDiagramFile]);
|
||||
|
||||
const saveDiagram = React.useCallback(async () => {
|
||||
const newXml = editorRef.current?.getXml();
|
||||
if (filePath && files?.writeFile && newXml && newXml !== xml) {
|
||||
await files.writeFile(filePath, newXml);
|
||||
setXml(newXml);
|
||||
}
|
||||
}, [filePath, files, xml]);
|
||||
|
||||
const fileName = filePath ? filePath.split('/').pop() || filePath : '';
|
||||
|
||||
if (!filePath) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="typography-ui text-muted-foreground">
|
||||
{t('filesView.editor.pickFileFromTree')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border/30 px-3 py-1.5">
|
||||
<Icon name="file" className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="typography-ui text-muted-foreground truncate flex-1">{fileName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveDiagram()}
|
||||
className="size-6 flex items-center justify-center rounded-md text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
title={t('filesView.diagram.saveDiagram')}
|
||||
>
|
||||
<Icon name="save-3" className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useUIStore.getState().setActiveMainTab('chat')}
|
||||
className="size-6 flex items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
title={t('filesView.diagram.closeDiagramView')}
|
||||
>
|
||||
<Icon name="close" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<DiagramEditor
|
||||
ref={editorRef}
|
||||
xml={xml}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,13 @@ import React from 'react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
|
||||
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
|
||||
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
|
||||
import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -79,7 +83,7 @@ type DiffData = {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
contextMode?: DiffContextMode;
|
||||
};
|
||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn';
|
||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch';
|
||||
|
||||
type TurnSnapshotDiff = {
|
||||
file?: string;
|
||||
@@ -91,6 +95,17 @@ type TurnSnapshotDiff = {
|
||||
deletions?: number;
|
||||
};
|
||||
|
||||
/** Reservation slot for a branch range diff while its fetch is in flight. */
|
||||
const EMPTY_BRANCH_DIFF_PLACEHOLDER: DiffData = {
|
||||
original: '',
|
||||
modified: '',
|
||||
isBinary: false,
|
||||
contextMode: 'patch',
|
||||
};
|
||||
|
||||
/** Bounded retries for branch metadata in the context diff panel (see effect). */
|
||||
const BRANCH_METADATA_MAX_ATTEMPTS = 3;
|
||||
|
||||
const BinaryDiffPlaceholder = React.memo(() => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -230,11 +245,13 @@ const formatDiffTotals = (
|
||||
};
|
||||
|
||||
interface ChangeScopeSelectorProps {
|
||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>;
|
||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>;
|
||||
workingCount: number;
|
||||
stagedCount: number;
|
||||
turnCount: number;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
||||
branchCount: number | null;
|
||||
showBranchOption: boolean;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
|
||||
}
|
||||
|
||||
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
@@ -242,16 +259,20 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
workingCount,
|
||||
stagedCount,
|
||||
turnCount,
|
||||
branchCount,
|
||||
showBranchOption,
|
||||
onScopeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount;
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : workingCount;
|
||||
const currentLabel = scope === 'staged'
|
||||
? t('diffView.scope.staged')
|
||||
: scope === 'turn'
|
||||
? t('diffView.scope.lastTurn')
|
||||
: t('diffView.scope.changed');
|
||||
: scope === 'branch'
|
||||
? t('diffView.scope.branch')
|
||||
: t('diffView.scope.changed');
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -271,7 +292,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn') {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') {
|
||||
onScopeChange?.(value);
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -295,6 +316,14 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<span className="typography-meta text-muted-foreground">{turnCount}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
{showBranchOption ? (
|
||||
<DropdownMenuRadioItem value="branch">
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span>{t('diffView.scope.branch')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{branchCount ?? '…'}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
) : null}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -574,6 +603,8 @@ interface MultiFileDiffEntryProps {
|
||||
staged?: boolean;
|
||||
loadFullFiles?: boolean;
|
||||
initialDiffData?: DiffData | null;
|
||||
/** Hide stage/unstage/revert actions (read-only scopes like branch diffs). */
|
||||
readOnlyActions?: boolean;
|
||||
}
|
||||
|
||||
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
@@ -593,6 +624,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
staged = false,
|
||||
loadFullFiles = false,
|
||||
initialDiffData = null,
|
||||
readOnlyActions = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { git } = useRuntimeAPIs();
|
||||
@@ -922,13 +954,15 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
|
||||
<div className="pointer-events-auto">
|
||||
<FileDiffActions
|
||||
filePath={file.path}
|
||||
staged={staged}
|
||||
busyAction={fileAction}
|
||||
disabled={fileAction !== null}
|
||||
onAction={handleFileAction}
|
||||
/>
|
||||
{!readOnlyActions ? (
|
||||
<FileDiffActions
|
||||
filePath={file.path}
|
||||
staged={staged}
|
||||
busyAction={fileAction}
|
||||
disabled={fileAction !== null}
|
||||
onAction={handleFileAction}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -945,7 +979,7 @@ interface DiffViewProps {
|
||||
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
||||
showOpenInEditorAction?: boolean;
|
||||
diffScope?: DiffScope;
|
||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
|
||||
targetFilePath?: string | null;
|
||||
/** Render diff content flush with the container edges (no outer padding). */
|
||||
flushContent?: boolean;
|
||||
@@ -974,6 +1008,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
|
||||
const setDiff = useGitStore((state) => state.setDiff);
|
||||
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
|
||||
@@ -1083,7 +1118,213 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return map;
|
||||
}, [lastTurnDiffs]);
|
||||
|
||||
const workingFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
return status.files.filter(isWorkingStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const stagedFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
return status.files.filter(isStagedStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const turnFileCount = lastTurnDiffs.length;
|
||||
|
||||
// ----- Branch scope (all changes on this branch vs its base) -----
|
||||
const currentBranch = status?.current ?? null;
|
||||
const branches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.branches ?? null : null));
|
||||
const isLoadingBranches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.isLoadingBranches ?? false : false));
|
||||
|
||||
// The Branch scope needs defaultBranches metadata that nothing else loads
|
||||
// when only the context diff panel is open (GitView and the composer fetch
|
||||
// it, and their absence must not hide the option), so load it here. A
|
||||
// failed fetch leaves `branches` null and the loading flag settles back to
|
||||
// false; the bounded retry below re-issues it a few times per directory and
|
||||
// reports exhaustion so a dead repository neither loops forever nor spins
|
||||
// the Branch scope on base resolution.
|
||||
const startBranchMetadataFetch = React.useCallback(() => {
|
||||
if (effectiveDirectory) {
|
||||
void fetchBranches(effectiveDirectory, git);
|
||||
}
|
||||
}, [effectiveDirectory, fetchBranches, git]);
|
||||
const branchMetadataExhausted = useBoundedDirectoryRetry(
|
||||
effectiveDirectory ?? null,
|
||||
isGitRepo !== false,
|
||||
isLoadingBranches,
|
||||
Boolean(branches),
|
||||
startBranchMetadataFetch,
|
||||
BRANCH_METADATA_MAX_ATTEMPTS
|
||||
);
|
||||
|
||||
const repositoryDefaultBranch = React.useMemo(() => {
|
||||
const trackingRemote = status?.tracking?.trim().split('/')[0];
|
||||
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
|
||||
?? branches?.defaultBranches?.origin
|
||||
?? null;
|
||||
}, [branches, status?.tracking]);
|
||||
// Offered only while the default branch is known and the current branch is
|
||||
// not it (an unknown default must not flash the option on a guess), and
|
||||
// only outside VS Code (the extension has no context diff panel).
|
||||
const showBranchOption = !isVSCodeRuntime() && isBranchScopeAvailable(currentBranch, repositoryDefaultBranch);
|
||||
// Coercion acts only on CONFIRMED unavailability: the runtime has no branch
|
||||
// scope at all, a settled status has no branch (detached HEAD), the default
|
||||
// branch is known and we are on it, or metadata retries were exhausted.
|
||||
// While status/metadata are still loading a persisted branch scope must
|
||||
// survive instead of being rewritten to working on the first render.
|
||||
// `status !== null` is the settled test: before the first status request
|
||||
// even starts, status is null with loading still false, and that must not
|
||||
// read as "settled without a branch".
|
||||
const isBranchStatusResolved = status !== null;
|
||||
const branchScopeDefinitelyUnavailable = isVSCodeRuntime()
|
||||
|| branchMetadataExhausted
|
||||
|| isBranchScopeDefinitelyUnavailable(
|
||||
currentBranch,
|
||||
repositoryDefaultBranch,
|
||||
isBranchStatusResolved,
|
||||
branches !== null
|
||||
);
|
||||
|
||||
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
|
||||
// Subscribe to the overrides map directly: `getOverride` reads `get()`
|
||||
// imperatively, so a memo over it never recomputes when the store changes
|
||||
// and a freshly picked base would be invisible until an unrelated rerender.
|
||||
// The key includes the current branch: a base picked for one feature branch
|
||||
// is not an answer for another branch of the same repository.
|
||||
const baseOverride = useGitBaseBranchStore(
|
||||
React.useCallback(
|
||||
(state) => (effectiveDirectory && currentBranch
|
||||
? state.overrides[gitBaseBranchEntryKey(effectiveDirectory, currentBranch)] ?? null
|
||||
: null),
|
||||
[currentBranch, effectiveDirectory]
|
||||
)
|
||||
);
|
||||
const [detectedBranchBase, setDetectedBranchBase] = React.useState<string | null>(null);
|
||||
const [isBranchBaseResolved, setIsBranchBaseResolved] = React.useState(false);
|
||||
const [basePickerSearch, setBasePickerSearch] = React.useState('');
|
||||
|
||||
// A context tab persists its scope across branch checkouts and runtime
|
||||
// switches. When the Branch scope is CONFIRMED unavailable (checked out the
|
||||
// known default branch, VS Code runtime), fall back to Working instead of
|
||||
// rendering the base-resolution spinner forever. Persist the coercion so
|
||||
// the tab and the selector agree. Note it keys off confirmed
|
||||
// unavailability, not off `showBranchOption`: while metadata loads the
|
||||
// option is hidden but a persisted branch scope must not be rewritten.
|
||||
React.useEffect(() => {
|
||||
const coercedScope = coerceDiffScope(activeDiffScope, !branchScopeDefinitelyUnavailable);
|
||||
if (coercedScope !== activeDiffScope) {
|
||||
setActiveDiffScope(coercedScope);
|
||||
// The only coercion is 'branch' -> 'working', so the persisted
|
||||
// value always fits the callback domain.
|
||||
if (coercedScope === 'working') {
|
||||
onDiffScopeChange?.('working');
|
||||
}
|
||||
}
|
||||
}, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showBranchOption || !effectiveDirectory || !currentBranch) {
|
||||
setDetectedBranchBase(null);
|
||||
setIsBranchBaseResolved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsBranchBaseResolved(false);
|
||||
getBranchBase(effectiveDirectory, currentBranch)
|
||||
.then((result) => {
|
||||
if (!cancelled) setDetectedBranchBase(result.base);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDetectedBranchBase(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsBranchBaseResolved(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentBranch, effectiveDirectory, showBranchOption]);
|
||||
|
||||
// Explicit user choice outranks the detected source; both are real answers
|
||||
// from git or the user — never a main/master guess.
|
||||
const branchBase = baseOverride ?? detectedBranchBase;
|
||||
|
||||
const [branchFiles, setBranchFiles] = React.useState<GitRangeFileEntry[] | null>(null);
|
||||
const [branchFilesError, setBranchFilesError] = React.useState<string | null>(null);
|
||||
|
||||
// Shared by the scope/base effect and the error-state Retry button; the
|
||||
// fetch id discards completions from a superseded run (base or head
|
||||
// changed, or an earlier retry is still in flight).
|
||||
const branchFilesFetchIdRef = React.useRef(0);
|
||||
const reloadBranchFiles = React.useCallback(() => {
|
||||
if (!effectiveDirectory || !currentBranch || !branchBase) return;
|
||||
const fetchId = branchFilesFetchIdRef.current + 1;
|
||||
branchFilesFetchIdRef.current = fetchId;
|
||||
setBranchFiles(null);
|
||||
setBranchFilesError(null);
|
||||
getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch })
|
||||
.then((files) => {
|
||||
if (branchFilesFetchIdRef.current === fetchId) setBranchFiles(files);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (branchFilesFetchIdRef.current === fetchId) {
|
||||
setBranchFilesError(error instanceof Error ? error.message : t('diffView.branch.loadError'));
|
||||
}
|
||||
});
|
||||
}, [branchBase, currentBranch, effectiveDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeDiffScope === 'branch') {
|
||||
reloadBranchFiles();
|
||||
}
|
||||
}, [activeDiffScope, reloadBranchFiles]);
|
||||
|
||||
// Range diffs are fetched per expanded file: unlike working/staged diffs
|
||||
// there is no per-file cache channel, so patch data lives in a range-keyed
|
||||
// local cache. Stale completions from a previous range cannot write into
|
||||
// the new range's cache (see useRangeKeyedCache).
|
||||
const branchDiffRangeKey = activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase
|
||||
? branchRangeKey(effectiveDirectory, branchBase, currentBranch)
|
||||
: null;
|
||||
const branchDiffPathsKey = React.useMemo(
|
||||
() => (activeDiffScope === 'branch' ? Array.from(expandedFiles).sort().join('\0') : ''),
|
||||
[activeDiffScope, expandedFiles]
|
||||
);
|
||||
|
||||
const fetchBranchDiffEntry = React.useCallback(
|
||||
(filePath: string) => {
|
||||
if (!effectiveDirectory || !branchBase || !currentBranch) {
|
||||
return Promise.reject(new Error('branch range is unavailable'));
|
||||
}
|
||||
return getGitRangeDiff(effectiveDirectory, { base: branchBase, head: currentBranch, path: filePath })
|
||||
.then((response) => createTextDiffDataFromPatch(filePath, response.diff, 'patch'));
|
||||
},
|
||||
[branchBase, currentBranch, effectiveDirectory]
|
||||
);
|
||||
|
||||
const branchDiffData = useRangeKeyedCache<DiffData>(
|
||||
branchDiffRangeKey,
|
||||
branchDiffPathsKey,
|
||||
branchDiffRangeKey ? fetchBranchDiffEntry : null,
|
||||
EMPTY_BRANCH_DIFF_PLACEHOLDER
|
||||
);
|
||||
|
||||
const branchFileCount = branchFiles?.length ?? null;
|
||||
|
||||
const changedFiles: FileEntry[] = React.useMemo(() => {
|
||||
if (activeDiffScope === 'branch') {
|
||||
return (branchFiles ?? [])
|
||||
.map((file) => ({
|
||||
path: file.path,
|
||||
index: '',
|
||||
working_dir: file.status,
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
isNew: file.status === 'A',
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
if (activeDiffScope === 'turn') {
|
||||
return lastTurnDiffs
|
||||
.map((diff) => ({
|
||||
@@ -1115,19 +1356,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
isNew: isNewStatusFile(file),
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [activeDiffScope, lastTurnDiffs, status]);
|
||||
|
||||
const workingFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
return status.files.filter(isWorkingStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const stagedFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
return status.files.filter(isStagedStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const turnFileCount = lastTurnDiffs.length;
|
||||
}, [activeDiffScope, branchFiles, lastTurnDiffs, status]);
|
||||
|
||||
const changedFilePathsKey = React.useMemo(
|
||||
() => changedFiles.map((file) => file.path).join('\0'),
|
||||
@@ -1670,7 +1899,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}}
|
||||
staged={getFileStaged(file.path)}
|
||||
loadFullFiles={loadFullFiles}
|
||||
initialDiffData={activeDiffScope === 'turn' ? lastTurnDiffData.get(file.path) ?? null : null}
|
||||
readOnlyActions={activeDiffScope === 'branch'}
|
||||
initialDiffData={
|
||||
activeDiffScope === 'turn'
|
||||
? lastTurnDiffData.get(file.path) ?? null
|
||||
: activeDiffScope === 'branch'
|
||||
? branchDiffData.get(file.path) ?? null
|
||||
: null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1707,10 +1943,92 @@ 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 eligibleBranches = (branches?.all ?? [])
|
||||
.map((name: string) => name.replace(/^remotes\//, ''))
|
||||
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
|
||||
.sort();
|
||||
const candidateBranches = rankByQuery(eligibleBranches, basePickerSearch, (name) => [name]);
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<Icon name="git-branch" className="size-6 text-muted-foreground" />
|
||||
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.noBaseTitle')}</div>
|
||||
<div className="max-w-sm typography-micro text-muted-foreground">{t('diffView.branch.noBaseDescription')}</div>
|
||||
<input
|
||||
type="text"
|
||||
value={basePickerSearch}
|
||||
onChange={(event) => setBasePickerSearch(event.target.value)}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
aria-label={t('gitView.branch.searchPlaceholder')}
|
||||
className="w-full max-w-sm rounded-md border border-border/60 bg-[var(--surface-elevated)] px-2.5 py-1.5 typography-meta text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
/>
|
||||
<ScrollableOverlay outerClassName="max-h-48 w-full max-w-sm min-h-0" className="px-1 py-1">
|
||||
{candidateBranches.length === 0 ? (
|
||||
<div className="px-2 py-3 typography-meta text-muted-foreground">
|
||||
{t('gitView.branch.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{candidateBranches.map((branch: string) => (
|
||||
<button
|
||||
key={branch}
|
||||
type="button"
|
||||
onClick={() => effectiveDirectory && currentBranch && setBaseOverride(effectiveDirectory, currentBranch, branch)}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
>
|
||||
<Icon name="git-branch" className="size-3.5 text-primary" />
|
||||
<span className="truncate typography-ui-label text-foreground" title={branch}>{branch}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (branchFilesError) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.loadError')}</div>
|
||||
<div className="max-w-sm typography-micro text-muted-foreground">{branchFilesError}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => reloadBranchFiles()}
|
||||
>
|
||||
{t('diffView.actions.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (branchFiles === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
{t('diffView.branch.loadingFiles')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')}
|
||||
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges')
|
||||
: activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchBase })
|
||||
: t('diffView.state.cleanWorkingTree')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1722,12 +2040,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
|
||||
{!isMobile && (
|
||||
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? (
|
||||
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
|
||||
<ChangeScopeSelector
|
||||
scope={activeDiffScope}
|
||||
workingCount={workingFileCount}
|
||||
stagedCount={stagedFileCount}
|
||||
turnCount={turnFileCount}
|
||||
branchCount={branchFileCount}
|
||||
showBranchOption={showBranchOption}
|
||||
onScopeChange={(scope) => {
|
||||
setActiveDiffScope(scope);
|
||||
onDiffScopeChange?.(scope);
|
||||
|
||||
@@ -51,7 +51,7 @@ import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/file
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
|
||||
import { getOutsideFileGrant, resolveOutsideFileReadOptions } from '@/lib/outsideFileGrants';
|
||||
import { subscribeToFileContentInvalidation } from '@/lib/fileContentInvalidation';
|
||||
import { DiagramEditor } from '@/components/diagram';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
@@ -62,7 +62,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments';
|
||||
import { buildCodeMirrorCommentWidgets, FilePreviewCommentMenu, normalizeLineRange, useInlineCommentController } from '@/components/comments';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
@@ -769,8 +769,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const [isFloatingToolbarOpen, setIsFloatingToolbarOpen] = React.useState(false);
|
||||
const floatingToolbarRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const toolbarDropdownOpenCountRef = React.useRef(0);
|
||||
|
||||
const handleToolbarDropdownOpenChange = React.useCallback((open: boolean) => {
|
||||
@@ -780,23 +778,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const isClickInsidePortalledMenu = React.useCallback((target: EventTarget | null) => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return target.closest('[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-item"]') !== null;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isFloatingToolbarOpen) return;
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (toolbarDropdownOpenCountRef.current > 0) return;
|
||||
if (isClickInsidePortalledMenu(event.target)) return;
|
||||
if (floatingToolbarRef.current && !floatingToolbarRef.current.contains(event.target as Node)) {
|
||||
setIsFloatingToolbarOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [isClickInsidePortalledMenu, isFloatingToolbarOpen]);
|
||||
type TextViewMode = 'view' | 'edit';
|
||||
type PreviewViewMode = 'preview' | 'edit';
|
||||
|
||||
@@ -861,6 +842,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
}, [openPaths, selectedPath]);
|
||||
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
|
||||
const selectedFilePath = selectedFile?.path ?? '';
|
||||
const [, setOutsideFileGrantRevision] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!root || !selectedPath) return;
|
||||
@@ -881,6 +863,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
}),
|
||||
[mode, selectedFileIsOutsideWorkspace, selectedOutsideFileGrant, root],
|
||||
);
|
||||
const resolveFileReadOptions = React.useCallback(async (path: string) => {
|
||||
const previousGrant = getOutsideFileGrant(path);
|
||||
const readOptions = await resolveOutsideFileReadOptions(path, root, mode === 'editor-only');
|
||||
if (readOptions.outsideFileGrant && readOptions.outsideFileGrant !== previousGrant) {
|
||||
setOutsideFileGrantRevision((revision) => revision + 1);
|
||||
}
|
||||
return readOptions;
|
||||
}, [mode, root]);
|
||||
|
||||
// Editor tabs horizontal scroll fades
|
||||
const editorTabsScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -949,7 +939,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
||||
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
|
||||
const pendingTabRef = React.useRef<import('@/stores/useUIStore').MainTab | null>(null);
|
||||
const pendingClosePathRef = React.useRef<string | null>(null);
|
||||
const skipDirtyOnceRef = React.useRef(false);
|
||||
const copiedContentTimeoutRef = React.useRef<number | null>(null);
|
||||
@@ -1039,7 +1028,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
|
||||
// Session/config for sending comments
|
||||
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
|
||||
const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation);
|
||||
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
|
||||
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
|
||||
@@ -1048,7 +1036,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap);
|
||||
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
const settingsExpandedEditorToolbar = useUIStore((state) => state.expandedEditorToolbar);
|
||||
|
||||
// Global mouseup to end drag selection
|
||||
React.useEffect(() => {
|
||||
@@ -1081,6 +1068,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return lines.slice(startLine - 1, endLine).join('\n');
|
||||
}, []);
|
||||
|
||||
const markdownPreviewRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const fileCommentController = useInlineCommentController<SelectedLineRange>({
|
||||
source: 'file',
|
||||
fileLabel: selectedFile?.path ?? null,
|
||||
@@ -1106,10 +1095,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
React.useEffect(() => {
|
||||
setLineSelection(null);
|
||||
reset();
|
||||
setMainTabGuard(null);
|
||||
setDraftContent('');
|
||||
setIsSaving(false);
|
||||
}, [selectedFile?.path, reset, setMainTabGuard]);
|
||||
}, [selectedFile?.path, reset]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCommentSelection(lineSelection);
|
||||
@@ -1565,38 +1553,35 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
};
|
||||
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean }): Promise<string> => {
|
||||
const readFile = React.useCallback(async (path: string): Promise<string> => {
|
||||
const options = await resolveFileReadOptions(path);
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path, { ...(options ?? {}), directory: root || undefined });
|
||||
const result = await files.readFile(path, { ...options, directory: root || undefined });
|
||||
return result.content ?? '';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ path });
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
if (options.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
if (options?.outsideFileGrant) {
|
||||
if (options.outsideFileGrant) {
|
||||
params.set('outsideFileGrant', options.outsideFileGrant);
|
||||
}
|
||||
if (options?.optional) {
|
||||
params.set('optional', 'true');
|
||||
}
|
||||
if (root) {
|
||||
params.set('directory', root);
|
||||
}
|
||||
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
|
||||
cache: options?.optional ? 'no-store' : 'default',
|
||||
});
|
||||
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed'));
|
||||
}
|
||||
return response.text();
|
||||
}, [files, root, t]);
|
||||
}, [files, resolveFileReadOptions, root, t]);
|
||||
|
||||
const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): Promise<FileStatSnapshot | null> => {
|
||||
const readFileStat = React.useCallback(async (path: string): Promise<FileStatSnapshot | null> => {
|
||||
if (files.statFile) {
|
||||
const result = await files.statFile(path, { ...(options ?? {}), directory: root || undefined });
|
||||
const options = await resolveFileReadOptions(path);
|
||||
const result = await files.statFile(path, { ...options, directory: root || undefined });
|
||||
return {
|
||||
path: result.path,
|
||||
size: result.size,
|
||||
@@ -1604,7 +1589,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [files, root]);
|
||||
}, [files, resolveFileReadOptions, root]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!root || !files.statFile || openPaths.length === 0) {
|
||||
@@ -1616,7 +1601,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
void Promise.all(paths.map(async (path) => {
|
||||
try {
|
||||
const stat = await files.statFile?.(path, { directory: root || undefined });
|
||||
const options = await resolveFileReadOptions(path);
|
||||
const stat = await files.statFile?.(path, { ...options, directory: root || undefined });
|
||||
if (!cancelled && stat && !stat.isFile) {
|
||||
removeOpenPathsByPrefix(root, path);
|
||||
}
|
||||
@@ -1630,7 +1616,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [files, openPaths, removeOpenPathsByPrefix, root]);
|
||||
}, [files, openPaths, removeOpenPathsByPrefix, resolveFileReadOptions, root]);
|
||||
|
||||
const displayedContent = React.useMemo(() =>
|
||||
fileContent.length > MAX_VIEW_CHARS
|
||||
@@ -1721,32 +1707,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
}, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, root, selectedFile, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDirty) {
|
||||
setMainTabGuard(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const guard = (_nextTab: import('@/stores/useUIStore').MainTab) => {
|
||||
if (skipDirtyOnceRef.current) {
|
||||
skipDirtyOnceRef.current = false;
|
||||
return true;
|
||||
}
|
||||
setConfirmDiscardOpen(true);
|
||||
pendingTabRef.current = _nextTab;
|
||||
return false;
|
||||
};
|
||||
|
||||
setMainTabGuard(guard);
|
||||
|
||||
return () => {
|
||||
const currentGuard = useUIStore.getState().mainTabGuard;
|
||||
if (currentGuard === guard) {
|
||||
setMainTabGuard(null);
|
||||
}
|
||||
};
|
||||
}, [isDirty, setMainTabGuard]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoSaveEnabled) {
|
||||
return;
|
||||
@@ -1843,6 +1803,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
setDesktopImageSrc('');
|
||||
setLoadedFilePath(null);
|
||||
setContentDetectedBinary(false);
|
||||
setFileLoading(true);
|
||||
|
||||
// Prime asset URLs; read and stat resolve again immediately before their calls.
|
||||
await resolveFileReadOptions(node.path);
|
||||
if (!isCurrentLoad()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIsImage = isImageFile(node.path);
|
||||
const isSvg = isSvgFile(node.path);
|
||||
@@ -1857,7 +1824,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
if (runtime.isDesktop && selectedIsImage && !isSvg) {
|
||||
setFileContent('');
|
||||
setDraftContent('');
|
||||
setFileLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1888,15 +1854,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setFileLoading(true);
|
||||
|
||||
const outsideFileGrant = getOutsideFileGrant(node.path);
|
||||
const readOptions = {
|
||||
allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root),
|
||||
outsideFileGrant,
|
||||
};
|
||||
|
||||
await readFile(node.path, readOptions)
|
||||
await readFile(node.path)
|
||||
.then((content) => {
|
||||
if (!isCurrentLoad()) {
|
||||
return;
|
||||
@@ -1917,7 +1875,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
||||
: editorContent);
|
||||
setLoadedFilePath(node.path);
|
||||
void readFileStat(node.path, readOptions)
|
||||
void readFileStat(node.path)
|
||||
.then((stat) => {
|
||||
if (stat && isCurrentLoad()) {
|
||||
lastLoadedFileStatRef.current = stat;
|
||||
@@ -1982,7 +1940,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
setFileLoading(false);
|
||||
}
|
||||
});
|
||||
}, [expandPaths, isMobile, loadDirectory, mode, readFile, readFileStat, removeOpenPathsByPrefix, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
|
||||
}, [expandPaths, isMobile, loadDirectory, readFile, readFileStat, removeOpenPathsByPrefix, resolveFileReadOptions, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
|
||||
|
||||
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
|
||||
if (!root) {
|
||||
@@ -2108,7 +2066,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
void readFileStat(selectedFile.path, selectedFileReadOptions)
|
||||
void readFileStat(selectedFile.path)
|
||||
.then((latestStat) => {
|
||||
if (cancelled || !latestStat) {
|
||||
return;
|
||||
@@ -2144,15 +2102,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [loadedFilePath, readFileStat, selectedFile?.path, selectedFileReadOptions]);
|
||||
}, [loadedFilePath, readFileStat, selectedFile?.path]);
|
||||
|
||||
const discardAndContinue = React.useCallback(() => {
|
||||
const nextFile = pendingSelectFileRef.current;
|
||||
const nextTab = pendingTabRef.current;
|
||||
const closePath = pendingClosePathRef.current;
|
||||
|
||||
pendingSelectFileRef.current = null;
|
||||
pendingTabRef.current = null;
|
||||
pendingClosePathRef.current = null;
|
||||
|
||||
// Allow one guarded navigation (tab/file) without re-opening dialog.
|
||||
@@ -2191,15 +2147,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextTab) {
|
||||
setMainTabGuard(null);
|
||||
useUIStore.getState().setActiveMainTab(nextTab);
|
||||
}
|
||||
}, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setMainTabGuard, setSelectedPath]);
|
||||
}, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setSelectedPath]);
|
||||
|
||||
const saveAndContinue = React.useCallback(async () => {
|
||||
const nextFile = pendingSelectFileRef.current;
|
||||
const nextTab = pendingTabRef.current;
|
||||
const closePath = pendingClosePathRef.current;
|
||||
|
||||
const saved = await saveDraft();
|
||||
@@ -2209,7 +2160,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
|
||||
pendingSelectFileRef.current = null;
|
||||
pendingTabRef.current = null;
|
||||
pendingClosePathRef.current = null;
|
||||
|
||||
// We'll proceed after saving; suppress guard reopening.
|
||||
@@ -2245,11 +2195,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextTab) {
|
||||
setMainTabGuard(null);
|
||||
useUIStore.getState().setActiveMainTab(nextTab);
|
||||
}
|
||||
}, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setMainTabGuard, setSelectedPath]);
|
||||
}, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setSelectedPath]);
|
||||
|
||||
const handleCloseFile = React.useCallback((path: string) => {
|
||||
const isActive = selectedFile?.path === path;
|
||||
@@ -2614,12 +2560,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
diagramXmlRef.current = xml;
|
||||
diagramSavedXmlRef.current = xml;
|
||||
setDraftContent(xml);
|
||||
const stat = await readFileStat(path, selectedFileReadOptions).catch(() => null);
|
||||
const stat = await readFileStat(path).catch(() => null);
|
||||
if (stat) {
|
||||
lastLoadedFileStatRef.current = stat;
|
||||
}
|
||||
return true;
|
||||
}, [files, readFileStat, selectedFileReadOptions, t]);
|
||||
}, [files, readFileStat, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
@@ -3046,7 +2992,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
[lightTheme.metadata.id, darkTheme.metadata.id],
|
||||
);
|
||||
|
||||
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
|
||||
const pdfAssetAuthKey = selectedFile?.path
|
||||
&& isSelectedPdf
|
||||
&& (!selectedFileReadOptions.allowOutsideWorkspace || selectedFileReadOptions.outsideFileGrant)
|
||||
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}|${fileContentRevision}`
|
||||
: '';
|
||||
|
||||
@@ -3069,7 +3017,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
: desktopImageSrc)
|
||||
: '';
|
||||
|
||||
const pdfSrc = selectedFile?.path && isSelectedPdf && pdfAssetAuthReadyKey === pdfAssetAuthKey
|
||||
const pdfSrc = selectedFile?.path && isSelectedPdf && pdfAssetAuthKey && pdfAssetAuthReadyKey === pdfAssetAuthKey
|
||||
? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
|
||||
path: selectedFile.path,
|
||||
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||
@@ -3101,14 +3049,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
setFileError(null);
|
||||
|
||||
const readOptions = await resolveFileReadOptions(selectedFile.path);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const srcPromise = files.readFileBinary
|
||||
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
|
||||
? files.readFileBinary(selectedFile.path, readOptions).then((result) => result.dataUrl)
|
||||
: (async () => {
|
||||
const response = await runtimeFetch('/api/fs/raw', {
|
||||
query: {
|
||||
path: selectedFile.path,
|
||||
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
|
||||
allowOutsideWorkspace: readOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||
outsideFileGrant: readOptions.outsideFileGrant,
|
||||
directory: root || undefined,
|
||||
},
|
||||
});
|
||||
@@ -3154,7 +3107,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [fileContentRevision, files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]);
|
||||
}, [fileContentRevision, files, isSelectedImage, isSelectedSvg, resolveFileReadOptions, root, selectedFile?.path, selectedFileReadOptions, t]);
|
||||
|
||||
const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []);
|
||||
|
||||
@@ -3817,9 +3770,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: Docked editor toolbar (expanded). Desktop opt-in; ALWAYS on
|
||||
for mobile — floating hover controls don't work with touch. */}
|
||||
{(settingsExpandedEditorToolbar || isMobile) && selectedFile ? (
|
||||
{/* Row 2: Docked editor toolbar. */}
|
||||
{selectedFile ? (
|
||||
<div className="flex min-w-0 items-center gap-3 border-t border-border/40 bg-[var(--surface-subtle)] px-3 py-1">
|
||||
{/* Mobile hosts already show the file name in their own header;
|
||||
a truncated duplicate here just eats toolbar width. */}
|
||||
@@ -3840,69 +3792,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 min-w-0 relative">
|
||||
{selectedFile && !isSearchOpen && !(settingsExpandedEditorToolbar || isMobile) && (
|
||||
<div
|
||||
ref={floatingToolbarRef}
|
||||
className="absolute right-3 top-3 z-30"
|
||||
onMouseLeave={() => {
|
||||
if (toolbarDropdownOpenCountRef.current > 0) return;
|
||||
setIsFloatingToolbarOpen(false);
|
||||
}}
|
||||
>
|
||||
{isFloatingToolbarOpen ? (
|
||||
renderFloatingFileControls()
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
{isMarkdown ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
|
||||
className={cn(
|
||||
'size-8 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-0 shadow-sm transition-colors',
|
||||
getMdViewMode() === 'preview'
|
||||
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)] hover:bg-[var(--interactive-selection)]'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-label={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
|
||||
title={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
|
||||
>
|
||||
<Icon name={getMdViewMode() === 'preview' ? 'eye' : 'eye-off'} className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex"
|
||||
onMouseEnter={() => setIsFloatingToolbarOpen(true)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFloatingToolbarOpen(true)}
|
||||
className="size-8 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-0 text-muted-foreground shadow-sm hover:text-foreground"
|
||||
aria-label={t('filesView.editor.showControlsAria')}
|
||||
title={t('filesView.editor.controlsTitle')}
|
||||
>
|
||||
<Icon name="more-2-fill" className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>{t('filesView.editor.controlsTitle')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ScrollableOverlay ref={mainViewVirtualizer.setScroller} outerClassName="h-full min-w-0" className={cn('h-full min-w-0', isLargeFile && '[overflow-anchor:none]')}>
|
||||
{!selectedFile ? (
|
||||
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
|
||||
@@ -3980,7 +3869,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
|
||||
<div className="h-full overflow-auto p-3">
|
||||
<div className="oc-file-preview h-full overflow-auto p-3" ref={markdownPreviewRef}>
|
||||
<FilePreviewCommentMenu
|
||||
containerRef={markdownPreviewRef}
|
||||
filePath={selectedFile.path}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
{fileContent.length > 500 * 1024 && (
|
||||
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
|
||||
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
|
||||
@@ -4346,7 +4240,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
) : null}
|
||||
</div>
|
||||
) : isMarkdown && getMdViewMode() === 'preview' ? (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="oc-file-preview h-full overflow-auto p-4" ref={markdownPreviewRef}>
|
||||
{selectedFile ? (
|
||||
<FilePreviewCommentMenu
|
||||
containerRef={markdownPreviewRef}
|
||||
filePath={selectedFile.path}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
) : null}
|
||||
{fileContent.length > 500 * 1024 && (
|
||||
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
|
||||
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
||||
import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
@@ -2585,7 +2586,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
<DialogHeader className="px-4 pt-4">
|
||||
<DialogTitle>{t('gitView.gitmoji.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Command className="h-[420px]">
|
||||
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
|
||||
<Command className="h-[420px]" shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('gitView.gitmoji.searchPlaceholder')}
|
||||
value={gitmojiSearch}
|
||||
@@ -2594,18 +2596,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('gitView.gitmoji.empty')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(gitmojiEmojis.length === 0
|
||||
? []
|
||||
: gitmojiEmojis.filter((entry) => {
|
||||
const term = gitmojiSearch.trim().toLowerCase();
|
||||
if (!term) return true;
|
||||
return (
|
||||
entry.emoji.includes(term) ||
|
||||
entry.code.toLowerCase().includes(term) ||
|
||||
entry.description.toLowerCase().includes(term)
|
||||
);
|
||||
})
|
||||
).map((entry) => (
|
||||
{rankByQuery(gitmojiEmojis, gitmojiSearch, (entry) => [entry.code, entry.description, entry.emoji]).map((entry) => (
|
||||
<CommandItem
|
||||
key={entry.code}
|
||||
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
|
||||
|
||||
@@ -82,6 +82,27 @@ const PIERRE_RUNTIME_BASE_CSS = `
|
||||
const WEBKIT_SCROLL_FIX_CSS = `
|
||||
${PIERRE_RUNTIME_BASE_CSS}
|
||||
|
||||
/* While a multi-line content drag is being mapped to a line selection the
|
||||
row highlight is the feedback; the native blue text selection on top of
|
||||
it reads as double-selection, so it is painted transparent for the drag's
|
||||
duration only (single-line selections keep the normal look for copying). */
|
||||
:host([data-oc-comment-drag]) {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Gutter "+" comment utility: theme primary, and smaller than Pierre's
|
||||
1lh default, which reads oversized next to our 13px line numbers. */
|
||||
[data-utility-button] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-self: center;
|
||||
margin-right: calc(-16px + 1ch);
|
||||
border-radius: 5px;
|
||||
background-color: var(--primary-base);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
:host {
|
||||
--diffs-bg-separator-override: var(--surface-elevated);
|
||||
}
|
||||
@@ -598,6 +619,187 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
}
|
||||
}, [setSelection]);
|
||||
|
||||
// Multi-line text selection over diff CONTENT highlights the same line
|
||||
// range Pierre paints for number-column selection — without opening the
|
||||
// comment editor. The "+" utility then targets the highlighted range.
|
||||
const contentSelectionRef = useRef<SelectedLineRange | null>(null);
|
||||
const contentSelectionClearTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableComments) return;
|
||||
const root = diffRootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const getShadowRoot = (): ShadowRoot | null => {
|
||||
const host = root.querySelector('diffs-container');
|
||||
return host instanceof HTMLElement ? host.shadowRoot : null;
|
||||
};
|
||||
|
||||
const setDragAttribute = (active: boolean) => {
|
||||
const host = root.querySelector('diffs-container');
|
||||
if (!(host instanceof HTMLElement)) return;
|
||||
if (active) host.setAttribute('data-oc-comment-drag', '');
|
||||
else host.removeAttribute('data-oc-comment-drag');
|
||||
};
|
||||
|
||||
const lineFromPoint = (clientX: number, clientY: number): { line: number; side: AnnotationSide; numberColumn: boolean } | null => {
|
||||
const shadowRoot = getShadowRoot();
|
||||
const element = shadowRoot?.elementFromPoint(clientX, clientY) ?? document.elementFromPoint(clientX, clientY);
|
||||
if (!(element instanceof Element)) return null;
|
||||
const numberColumn = Boolean(element.closest('[data-column-number]'));
|
||||
const row = element.closest('[data-line]');
|
||||
if (!(row instanceof HTMLElement)) return null;
|
||||
const line = Number.parseInt(row.getAttribute('data-line') ?? '', 10);
|
||||
if (!Number.isFinite(line) || line <= 0) return null;
|
||||
const side: AnnotationSide = row.getAttribute('data-line-type') === 'change-deletion'
|
||||
|| row.closest('[data-code][data-deletions]') != null
|
||||
? 'deletions'
|
||||
: 'additions';
|
||||
return { line, side, numberColumn };
|
||||
};
|
||||
|
||||
let anchor: { line: number; side: AnnotationSide } | null = null;
|
||||
let engaged = false;
|
||||
let pointerId: number | null = null;
|
||||
|
||||
const highlight = (range: SelectedLineRange) => {
|
||||
contentSelectionRef.current = range;
|
||||
const instance = diffInstanceRef.current;
|
||||
if (!instance) return;
|
||||
try {
|
||||
isApplyingSelectionRef.current = true;
|
||||
instance.setSelectedLines(range);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
isApplyingSelectionRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || event.pointerType !== 'mouse') return;
|
||||
const hit = lineFromPoint(event.clientX, event.clientY);
|
||||
// Number-column drags belong to Pierre's own selection handling.
|
||||
if (!hit || hit.numberColumn) {
|
||||
anchor = null;
|
||||
return;
|
||||
}
|
||||
anchor = { line: hit.line, side: hit.side };
|
||||
engaged = false;
|
||||
pointerId = event.pointerId;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (anchor == null || event.pointerId !== pointerId) return;
|
||||
const hit = lineFromPoint(event.clientX, event.clientY);
|
||||
if (!hit) return;
|
||||
if (!engaged) {
|
||||
if (hit.line === anchor.line) return;
|
||||
// The drag crossed into another line: from here it is a line
|
||||
// selection, not a text selection. Drop the native selection and
|
||||
// block new one from forming for the rest of the drag.
|
||||
engaged = true;
|
||||
setDragAttribute(true);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
const shadowRoot = getShadowRoot();
|
||||
if (shadowRoot && 'getSelection' in shadowRoot) {
|
||||
// SAFETY: getSelection on ShadowRoot is a Chromium extension absent
|
||||
// from lib.dom; the `in` check gates the call.
|
||||
(shadowRoot as ShadowRoot & { getSelection: () => Selection | null }).getSelection()?.removeAllRanges();
|
||||
}
|
||||
}
|
||||
highlight({
|
||||
start: Math.min(anchor.line, hit.line),
|
||||
end: Math.max(anchor.line, hit.line),
|
||||
side: anchor.side,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (anchor == null || event.pointerId !== pointerId) return;
|
||||
const wasEngaged = engaged;
|
||||
anchor = null;
|
||||
engaged = false;
|
||||
pointerId = null;
|
||||
setDragAttribute(false);
|
||||
if (!wasEngaged) return;
|
||||
const range = contentSelectionRef.current;
|
||||
contentSelectionRef.current = null;
|
||||
if (!range) return;
|
||||
// A half-written comment survives an accidental selection elsewhere.
|
||||
if (selectionRef.current && commentTextRef.current.trim() && !editingDraftIdRef.current) return;
|
||||
applySelection(range);
|
||||
if (!editingDraftIdRef.current) {
|
||||
setCommentText('');
|
||||
}
|
||||
};
|
||||
|
||||
root.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('pointermove', handlePointerMove, { passive: true });
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
return () => {
|
||||
root.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('pointerup', handlePointerUp);
|
||||
setDragAttribute(false);
|
||||
};
|
||||
}, [applySelection, enableComments, setCommentText]);
|
||||
|
||||
// The gutter "+" utility: pressing it (or dragging from it) yields a line
|
||||
// range; select it so the comment editor opens under the lines.
|
||||
const handleGutterUtilityClick = useCallback((range: SelectedLineRange) => {
|
||||
if (!enableComments) return;
|
||||
// A content-drag highlight is the intended target when the pressed line
|
||||
// falls inside it.
|
||||
const highlighted = contentSelectionRef.current;
|
||||
const withinHighlight = highlighted
|
||||
&& range.start >= highlighted.start
|
||||
&& range.end <= highlighted.end
|
||||
&& (range.side == null || range.side === highlighted.side);
|
||||
if (contentSelectionClearTimerRef.current !== null) {
|
||||
window.clearTimeout(contentSelectionClearTimerRef.current);
|
||||
contentSelectionClearTimerRef.current = null;
|
||||
}
|
||||
contentSelectionRef.current = null;
|
||||
applySelection(withinHighlight && highlighted ? highlighted : range);
|
||||
if (!editingDraftIdRef.current) {
|
||||
setCommentText('');
|
||||
}
|
||||
}, [applySelection, enableComments, setCommentText]);
|
||||
|
||||
// Clicking anywhere on a diff line (not only its number cell) toggles a
|
||||
// single-line comment selection, matching the "+" utility's target.
|
||||
const handleLineClick = useCallback((props: { lineNumber: number; annotationSide: AnnotationSide; numberColumn: boolean }) => {
|
||||
if (!enableComments || props.numberColumn) return;
|
||||
// Ignore when the user selected text on the way to this click (copying
|
||||
// code must not pop the comment editor).
|
||||
if (window.getSelection()?.toString().trim()) return;
|
||||
const side: SelectedLineRange['side'] = props.annotationSide;
|
||||
const range: SelectedLineRange = { start: props.lineNumber, end: props.lineNumber, side };
|
||||
const current = selectionRef.current;
|
||||
if (current && current.start === range.start && current.end === range.end && current.side === range.side) {
|
||||
if (!commentTextRef.current.trim()) {
|
||||
setSelection(null);
|
||||
const instance = diffInstanceRef.current;
|
||||
try {
|
||||
isApplyingSelectionRef.current = true;
|
||||
instance?.setSelectedLines(null);
|
||||
} finally {
|
||||
isApplyingSelectionRef.current = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current && commentTextRef.current.trim() && !editingDraftIdRef.current) {
|
||||
// A half-written comment survives an accidental click elsewhere.
|
||||
return;
|
||||
}
|
||||
applySelection(range);
|
||||
if (!editingDraftIdRef.current) {
|
||||
setCommentText('');
|
||||
}
|
||||
}, [applySelection, enableComments, setCommentText, setSelection]);
|
||||
|
||||
const resolveClickedSide = useCallback((numberCell: HTMLElement): AnnotationSide => {
|
||||
const lineType =
|
||||
numberCell.closest('[data-line-type]')?.getAttribute('data-line-type')
|
||||
@@ -758,11 +960,13 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
|
||||
disableFileHeader: true,
|
||||
enableLineSelection: enableComments,
|
||||
enableHoverUtility: false,
|
||||
enableGutterUtility: enableComments,
|
||||
onGutterUtilityClick: enableComments ? handleGutterUtilityClick : undefined,
|
||||
onLineClick: enableComments ? handleLineClick : undefined,
|
||||
onLineSelected: enableComments ? handleSelectionChange : undefined,
|
||||
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
|
||||
renderAnnotation: enableComments ? renderAnnotation : undefined,
|
||||
}), [darkTheme.metadata.id, enableComments, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
|
||||
}), [darkTheme.metadata.id, enableComments, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, handleGutterUtilityClick, handleLineClick, renderAnnotation]);
|
||||
|
||||
|
||||
const lineAnnotations = useMemo(() => {
|
||||
|
||||
@@ -168,7 +168,6 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const gitDirectories = useGitStore((state) => state.directories);
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
@@ -579,10 +578,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
}, []);
|
||||
|
||||
const routeToChat = React.useCallback(() => {
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
onNavigatedToChat?.();
|
||||
}, [onNavigatedToChat, setActiveMainTab, setSessionSwitcherOpen]);
|
||||
}, [onNavigatedToChat, setSessionSwitcherOpen]);
|
||||
|
||||
const handleConfirmPlanSend = React.useCallback(
|
||||
async (execution: TodoSendExecution) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
@@ -254,23 +255,24 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
}, [visiblePages]);
|
||||
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
|
||||
// Load stores when project changes or when a page becomes active.
|
||||
// Load stores when the settings project changes or a page becomes active.
|
||||
React.useEffect(() => {
|
||||
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsSlug === 'agents') {
|
||||
void useAgentsStore.getState().loadAgents();
|
||||
void useAgentsStore.getState().loadAgents(settingsDirectory);
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'commands') {
|
||||
void useCommandsStore.getState().loadCommands();
|
||||
void useCommandsStore.getState().loadCommands(settingsDirectory);
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'mcp') {
|
||||
void useMcpConfigStore.getState().loadMcpConfigs();
|
||||
void useMcpConfigStore.getState().loadMcpConfigs({ directory: settingsDirectory });
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'plugins') {
|
||||
@@ -278,13 +280,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') {
|
||||
void useSkillsStore.getState().loadSkills();
|
||||
void useSkillsStore.getState().loadSkills(settingsDirectory);
|
||||
void useSkillsCatalogStore.getState().loadCatalog();
|
||||
}
|
||||
if (settingsSlug === 'snippets') {
|
||||
void useSnippetsStore.getState().loadSnippets();
|
||||
}
|
||||
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsSlug]);
|
||||
// `activeProjectId` still matters: the settings directory follows the active
|
||||
// project until the user picks another one in the Settings selector.
|
||||
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsDirectory, settingsSlug]);
|
||||
|
||||
const openPage = React.useCallback((slug: SettingsPageSlug) => {
|
||||
setSettingsPage(slug);
|
||||
@@ -928,7 +932,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: <Icon name={iconName!} className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />}
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
|
||||
{page.slug === 'tunnel' && (
|
||||
{(page.slug === 'tunnel' || page.slug === 'integrations') && (
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
|
||||
{t('settings.view.badge.beta')}
|
||||
</span>
|
||||
|
||||
@@ -58,6 +58,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const setActiveTab = useTerminalStore((s) => s.setActiveTab);
|
||||
const closeTab = useTerminalStore((s) => s.closeTab);
|
||||
const setTabSessionId = useTerminalStore((s) => s.setTabSessionId);
|
||||
const adoptServerSessions = useTerminalStore((s) => s.adoptServerSessions);
|
||||
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
|
||||
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
||||
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
||||
@@ -147,9 +148,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
terminalControllerRef.current?.focus();
|
||||
}, [useTouchTerminalInput]);
|
||||
|
||||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||
const isTerminalActive = activeMainTab === 'terminal';
|
||||
const isTerminalVisible = visible ?? isTerminalActive;
|
||||
const isTerminalVisible = visible ?? false;
|
||||
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -176,6 +175,50 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
directoryRef.current = effectiveDirectory;
|
||||
}, [effectiveDirectory]);
|
||||
|
||||
// The tab list is a per-client projection, so ask the server what actually
|
||||
// exists for this directory and adopt sessions no local tab references
|
||||
// (another device, a fresh browser tab, or a reload with cleared storage).
|
||||
// A failed listing changes nothing: adoption is additive only.
|
||||
React.useEffect(() => {
|
||||
if (!terminalHydrated || !effectiveDirectory || !terminal.listSessions) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const directory = effectiveDirectory;
|
||||
void terminal.listSessions(directory)
|
||||
.then((serverSessions) => {
|
||||
if (cancelled || directoryRef.current !== directory) return;
|
||||
adoptServerSessions(directory, serverSessions);
|
||||
})
|
||||
.catch(() => { /* keep local tabs; the next mount or directory switch retries */ });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [terminalHydrated, effectiveDirectory, terminal, adoptServerSessions]);
|
||||
|
||||
// The server reaps terminals with no attached socket after an idle timeout,
|
||||
// but only the active tab holds an attachment. While this client is open,
|
||||
// periodically mark every session its tabs reference as active so
|
||||
// background tabs (and other directories' terminals) are not reaped.
|
||||
React.useEffect(() => {
|
||||
if (!terminal.touchSessions) {
|
||||
return;
|
||||
}
|
||||
const touch = () => {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return;
|
||||
const ids: string[] = [];
|
||||
for (const dirState of useTerminalStore.getState().sessions.values()) {
|
||||
for (const tab of dirState.tabs) {
|
||||
if (tab.terminalSessionId) ids.push(tab.terminalSessionId);
|
||||
}
|
||||
}
|
||||
if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {});
|
||||
};
|
||||
touch();
|
||||
const interval = setInterval(touch, 10 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [terminal]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showQuickKeys && activeModifier !== null) {
|
||||
setActiveModifier(null);
|
||||
@@ -642,7 +685,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
startLine: selection.startLine,
|
||||
endLine: selection.endLine,
|
||||
code: selection.text,
|
||||
language: activeTab.terminalSessionId ?? activeTab.id,
|
||||
language: '',
|
||||
terminalId: activeTab.terminalSessionId ?? activeTab.id,
|
||||
text: '',
|
||||
});
|
||||
}, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -214,13 +215,10 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
||||
|
||||
const MAX_VISIBLE = 5;
|
||||
|
||||
const filteredGroups = React.useMemo(() => {
|
||||
if (!searchQuery.trim()) return groups;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return groups.filter(group =>
|
||||
group.name.toLowerCase().includes(query)
|
||||
);
|
||||
}, [searchQuery, groups]);
|
||||
const filteredGroups = React.useMemo(
|
||||
() => rankByQuery(groups, searchQuery, (group) => [group.name]),
|
||||
[searchQuery, groups],
|
||||
);
|
||||
|
||||
const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE);
|
||||
const remainingCount = filteredGroups.length - MAX_VISIBLE;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -26,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type OperationType = 'merge' | 'rebase';
|
||||
@@ -94,22 +95,19 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
|
||||
// Filter branches based on search
|
||||
const filteredLocal = React.useMemo(() => {
|
||||
const term = branchSearch.toLowerCase();
|
||||
const remoteBranchNames = new Set(
|
||||
remoteBranches
|
||||
.map((branch) => branch.slice(branch.indexOf('/') + 1))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const filtered = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
|
||||
if (!term) return filtered;
|
||||
return filtered.filter((b) => b.toLowerCase().includes(term));
|
||||
const candidates = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
|
||||
return rankByQuery(candidates, branchSearch, (branch) => [branch]);
|
||||
}, [branchSearch, localBranches, currentBranch, remoteBranches]);
|
||||
|
||||
const filteredRemote = React.useMemo(() => {
|
||||
const term = branchSearch.toLowerCase();
|
||||
if (!term) return remoteBranches;
|
||||
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
|
||||
}, [branchSearch, remoteBranches]);
|
||||
const filteredRemote = React.useMemo(
|
||||
() => rankByQuery(remoteBranches, branchSearch, (branch) => [branch]),
|
||||
[branchSearch, remoteBranches]
|
||||
);
|
||||
|
||||
const resolveDefaultBranch = React.useCallback(() => {
|
||||
if (!defaultTargetBranch) return null;
|
||||
@@ -321,7 +319,8 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
sideOffset={6}
|
||||
className="w-[var(--anchor-width)] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden"
|
||||
>
|
||||
<Command className="h-full min-h-0">
|
||||
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
|
||||
<Command className="h-full min-h-0" shouldFilter={false}>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { GitRemote } from '@/lib/api/types';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface BranchInfo {
|
||||
@@ -78,17 +79,15 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
[newBranchName]
|
||||
);
|
||||
|
||||
const filteredLocal = React.useMemo(() => {
|
||||
const term = search.toLowerCase();
|
||||
if (!term) return localBranches;
|
||||
return localBranches.filter((b) => b.toLowerCase().includes(term));
|
||||
}, [search, localBranches]);
|
||||
const filteredLocal = React.useMemo(
|
||||
() => rankByQuery(localBranches, search, (branch) => [branch]),
|
||||
[search, localBranches]
|
||||
);
|
||||
|
||||
const filteredRemote = React.useMemo(() => {
|
||||
const term = search.toLowerCase();
|
||||
if (!term) return remoteBranches;
|
||||
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
|
||||
}, [search, remoteBranches]);
|
||||
const filteredRemote = React.useMemo(
|
||||
() => rankByQuery(remoteBranches, search, (branch) => [branch]),
|
||||
[search, remoteBranches]
|
||||
);
|
||||
|
||||
const handleCheckout = (branch: string) => {
|
||||
if (branch === currentBranch) {
|
||||
@@ -184,7 +183,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenuContent align="start" className="w-72 p-0 max-h-[60vh] flex flex-col">
|
||||
<Command className="h-full min-h-0">
|
||||
{/* Filtering and ordering are owned by rankByQuery above; cmdk's own
|
||||
filter would re-filter and reorder the already-ranked rows. */}
|
||||
<Command className="h-full min-h-0" shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
value={search}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Button } from '@/components/ui/button';
|
||||
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi';
|
||||
@@ -41,7 +40,6 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||
const setPendingSyntheticParts = useInputStore((state) => state.setPendingSyntheticParts);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [conflictDetails, setConflictDetails] = React.useState<MergeConflictDetails | null>(null);
|
||||
@@ -137,7 +135,6 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
]);
|
||||
|
||||
setActiveMainTab('chat');
|
||||
onClearState?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
@@ -159,7 +156,6 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
],
|
||||
});
|
||||
// Navigate to chat tab so user sees the new session
|
||||
setActiveMainTab('chat');
|
||||
onClearState?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { getGitCommitSummaries } from '@/lib/gitApi';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import {
|
||||
@@ -64,10 +64,15 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
||||
const [branchSearch, setBranchSearch] = React.useState('');
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredBranches = React.useMemo(
|
||||
() => rankByQuery(localBranches, branchSearch, (branch) => [branch]),
|
||||
[localBranches, branchSearch]
|
||||
);
|
||||
|
||||
const [targetBranch, setTargetBranch] = React.useState<string>(defaultTargetBranch);
|
||||
React.useEffect(() => {
|
||||
setTargetBranch(defaultTargetBranch);
|
||||
@@ -228,8 +233,6 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
],
|
||||
});
|
||||
// Navigate to chat tab so user sees the new session
|
||||
setActiveMainTab('chat');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,8 +247,7 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
{ text: context.instructionsText, synthetic: true },
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
]);
|
||||
setActiveMainTab('chat');
|
||||
}, [currentSessionId, setActiveMainTab, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts, t]);
|
||||
}, [currentSessionId, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts, t]);
|
||||
|
||||
const handleMove = React.useCallback(async () => {
|
||||
if (ui.kind !== 'ready') return;
|
||||
@@ -380,10 +382,13 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
align="end"
|
||||
className="w-72 p-0 max-h-[var(--available-height)] flex flex-col overflow-hidden"
|
||||
>
|
||||
<Command className="h-full min-h-0">
|
||||
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
|
||||
<Command className="h-full min-h-0" shouldFilter={false}>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
value={branchSearch}
|
||||
onValueChange={setBranchSearch}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<CommandList
|
||||
@@ -393,7 +398,7 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
>
|
||||
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
|
||||
<CommandGroup heading={t('gitView.branch.localBranches')}>
|
||||
{localBranches.map((branch) => (
|
||||
{filteredBranches.map((branch) => (
|
||||
<CommandItem
|
||||
key={branch}
|
||||
value={branch}
|
||||
@@ -401,6 +406,7 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
setTargetBranch(branch);
|
||||
persistTarget(branch);
|
||||
setBranchDropdownOpen(false);
|
||||
setBranchSearch('');
|
||||
}}
|
||||
>
|
||||
{branch}
|
||||
|
||||
@@ -327,7 +327,6 @@ export const PullRequestSection: React.FC<{
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo();
|
||||
@@ -986,14 +985,13 @@ export const PullRequestSection: React.FC<{
|
||||
text: '',
|
||||
});
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error(t('gitView.pr.toast.loadChecksFailed'), { description: message });
|
||||
} finally {
|
||||
setIsAttachingChecks(false);
|
||||
}
|
||||
}, [directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveMainTab, status?.repo, t]);
|
||||
}, [directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t]);
|
||||
|
||||
const sendCommentsToChat = React.useCallback(async () => {
|
||||
if (!github?.prContext) {
|
||||
@@ -1021,14 +1019,13 @@ export const PullRequestSection: React.FC<{
|
||||
for (const comment of timelineComments) {
|
||||
attachCommentDraft(target, comment);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error(t('gitView.pr.toast.loadPrCommentsFailed'), { description: message });
|
||||
} finally {
|
||||
setIsAttachingComments(false);
|
||||
}
|
||||
}, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveMainTab, status?.repo, t, timelineComments]);
|
||||
}, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t, timelineComments]);
|
||||
|
||||
const sendSingleCommentToChat = React.useCallback(async (comment: TimelineCommentItem) => {
|
||||
const target = resolveDraftTarget();
|
||||
@@ -1037,8 +1034,7 @@ export const PullRequestSection: React.FC<{
|
||||
}
|
||||
|
||||
attachCommentDraft(target, comment);
|
||||
setActiveMainTab('chat');
|
||||
}, [attachCommentDraft, resolveDraftTarget, setActiveMainTab]);
|
||||
}, [attachCommentDraft, resolveDraftTarget]);
|
||||
|
||||
const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => {
|
||||
await refreshPrStatus(prStatusKey, options);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -69,11 +70,10 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
||||
};
|
||||
}, [directory, open, stashes]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return stashes;
|
||||
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
|
||||
}, [query, stashes]);
|
||||
const filtered = React.useMemo(
|
||||
() => rankByQuery(stashes, query, (stash) => [stash.message, stash.ref, stash.relativeTime]),
|
||||
[query, stashes],
|
||||
);
|
||||
|
||||
const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
|
||||
await load();
|
||||
|
||||
Reference in New Issue
Block a user