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:
Bohdan Triapitsyn
2026-08-26 02:26:20 +03:00
460 changed files with 26402 additions and 10976 deletions
@@ -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>
);
}
+351 -31
View File
@@ -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);
+73 -172
View File
@@ -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) })}
+4 -13
View File
@@ -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();