feat(ui): add in-document search to the Markdown file preview
Ctrl/Cmd+F (and a toolbar button) opens a compact find bar over the rendered Markdown preview, with match highlighting, a live count, and next/previous navigation that scrolls the current match into view. Escape closes the bar and returns focus where it was. Merge follow-ups on top of the contribution: mount the bar in the fullscreen viewer as well as the inline preview, combine it with the FilePreviewCommentMenu wrapper that landed on main, debounce the highlight pass so typing does not re-walk the whole document on every keystroke, use the status-warning theme utilities instead of raw CSS variables for the highlights, and add the Turkish strings for the locale added after the branch was cut. Closes #2401
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
@@ -27,7 +28,6 @@ export function ArchiveView(): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const open = useUIStore((state) => state.isArchivePageOpen);
|
||||
const setOpen = useUIStore((state) => state.setArchivePageOpen);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
@@ -67,7 +67,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
// while not searching.
|
||||
const filteredSessions = React.useMemo(() => {
|
||||
if (normalizedQuery) {
|
||||
return sortedSessions.filter((session) => (session.title ?? '').toLowerCase().includes(normalizedQuery));
|
||||
return rankByQuery(sortedSessions, normalizedQuery, (session) => [session.title]);
|
||||
}
|
||||
if (selectedDirectory === null) return sortedSessions;
|
||||
return buckets.find((bucket) => bucket.directory === selectedDirectory)?.sessions ?? [];
|
||||
@@ -85,9 +85,8 @@ export function ArchiveView(): React.ReactNode {
|
||||
const openSession = React.useCallback((session: Session) => {
|
||||
const directory = normalizePath(resolveGlobalSessionDirectory(session));
|
||||
setCurrentSession(session.id, directory ?? undefined);
|
||||
setActiveMainTab('chat');
|
||||
setOpen(false);
|
||||
}, [setActiveMainTab, setCurrentSession, setOpen]);
|
||||
}, [setCurrentSession, setOpen]);
|
||||
|
||||
const restoreSession = React.useCallback((session: Session) => {
|
||||
void unarchiveSession(session.id).then((success) => {
|
||||
|
||||
@@ -5,15 +5,32 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type ChatViewProps = {
|
||||
active?: boolean;
|
||||
/**
|
||||
* Controls message-history subscription independently of `active`.
|
||||
* Embedded session-chat panels keep this true so history stays visible
|
||||
* while composer focus / background work remain gated by visibility.
|
||||
*/
|
||||
messagesEnabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
initialAllowPromptingSubagentSessions?: boolean;
|
||||
};
|
||||
|
||||
export const ChatView: React.FC<ChatViewProps> = ({ active = true, readOnly = false }) => {
|
||||
export const ChatView: React.FC<ChatViewProps> = ({
|
||||
active = true,
|
||||
messagesEnabled,
|
||||
readOnly = false,
|
||||
initialAllowPromptingSubagentSessions,
|
||||
}) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
|
||||
return (
|
||||
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
|
||||
<ChatContainer active={active} readOnly={readOnly} />
|
||||
<ChatContainer
|
||||
active={active}
|
||||
messagesEnabled={messagesEnabled}
|
||||
readOnly={readOnly}
|
||||
initialAllowPromptingSubagentSessions={initialAllowPromptingSubagentSessions}
|
||||
/>
|
||||
</ChatErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { DiagramEditor, type DiagramEditorHandle } from '@/components/diagram';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
export function DiagramView() {
|
||||
const { t } = useI18n();
|
||||
const { files } = useRuntimeAPIs();
|
||||
|
||||
const [filePath, setFilePath] = React.useState<string | null>(null);
|
||||
const [xml, setXml] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const editorRef = React.useRef<DiagramEditorHandle>(null);
|
||||
const pendingDiagramFile = useUIStore((state) => state.pendingDiagramFile);
|
||||
|
||||
const loadFile = React.useCallback(async (path: string) => {
|
||||
setLoading(true);
|
||||
setFilePath(path);
|
||||
try {
|
||||
const result = await files?.readFile?.(path);
|
||||
if (result) {
|
||||
setXml(result.content);
|
||||
}
|
||||
} catch {
|
||||
setXml('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingDiagramFile) {
|
||||
return;
|
||||
}
|
||||
const pending = useUIStore.getState().consumePendingDiagramFile();
|
||||
if (pending) {
|
||||
void loadFile(pending);
|
||||
}
|
||||
}, [loadFile, pendingDiagramFile]);
|
||||
|
||||
const saveDiagram = React.useCallback(async () => {
|
||||
const newXml = editorRef.current?.getXml();
|
||||
if (filePath && files?.writeFile && newXml && newXml !== xml) {
|
||||
await files.writeFile(filePath, newXml);
|
||||
setXml(newXml);
|
||||
}
|
||||
}, [filePath, files, xml]);
|
||||
|
||||
const fileName = filePath ? filePath.split('/').pop() || filePath : '';
|
||||
|
||||
if (!filePath) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="typography-ui text-muted-foreground">
|
||||
{t('filesView.editor.pickFileFromTree')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border/30 px-3 py-1.5">
|
||||
<Icon name="file" className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="typography-ui text-muted-foreground truncate flex-1">{fileName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveDiagram()}
|
||||
className="size-6 flex items-center justify-center rounded-md text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
title={t('filesView.diagram.saveDiagram')}
|
||||
>
|
||||
<Icon name="save-3" className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useUIStore.getState().setActiveMainTab('chat')}
|
||||
className="size-6 flex items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
title={t('filesView.diagram.closeDiagramView')}
|
||||
>
|
||||
<Icon name="close" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<DiagramEditor
|
||||
ref={editorRef}
|
||||
xml={xml}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,13 @@ import React from 'react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
|
||||
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
|
||||
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
|
||||
import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -79,7 +83,7 @@ type DiffData = {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
contextMode?: DiffContextMode;
|
||||
};
|
||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn';
|
||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch';
|
||||
|
||||
type TurnSnapshotDiff = {
|
||||
file?: string;
|
||||
@@ -91,6 +95,17 @@ type TurnSnapshotDiff = {
|
||||
deletions?: number;
|
||||
};
|
||||
|
||||
/** Reservation slot for a branch range diff while its fetch is in flight. */
|
||||
const EMPTY_BRANCH_DIFF_PLACEHOLDER: DiffData = {
|
||||
original: '',
|
||||
modified: '',
|
||||
isBinary: false,
|
||||
contextMode: 'patch',
|
||||
};
|
||||
|
||||
/** Bounded retries for branch metadata in the context diff panel (see effect). */
|
||||
const BRANCH_METADATA_MAX_ATTEMPTS = 3;
|
||||
|
||||
const BinaryDiffPlaceholder = React.memo(() => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -230,11 +245,13 @@ const formatDiffTotals = (
|
||||
};
|
||||
|
||||
interface ChangeScopeSelectorProps {
|
||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>;
|
||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>;
|
||||
workingCount: number;
|
||||
stagedCount: number;
|
||||
turnCount: number;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
||||
branchCount: number | null;
|
||||
showBranchOption: boolean;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
|
||||
}
|
||||
|
||||
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
@@ -242,16 +259,20 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
workingCount,
|
||||
stagedCount,
|
||||
turnCount,
|
||||
branchCount,
|
||||
showBranchOption,
|
||||
onScopeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount;
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : workingCount;
|
||||
const currentLabel = scope === 'staged'
|
||||
? t('diffView.scope.staged')
|
||||
: scope === 'turn'
|
||||
? t('diffView.scope.lastTurn')
|
||||
: t('diffView.scope.changed');
|
||||
: scope === 'branch'
|
||||
? t('diffView.scope.branch')
|
||||
: t('diffView.scope.changed');
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -271,7 +292,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn') {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') {
|
||||
onScopeChange?.(value);
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -295,6 +316,14 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<span className="typography-meta text-muted-foreground">{turnCount}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
{showBranchOption ? (
|
||||
<DropdownMenuRadioItem value="branch">
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span>{t('diffView.scope.branch')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{branchCount ?? '…'}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
) : null}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -483,7 +512,7 @@ const FileDiffActions = React.memo<FileDiffActionsProps>(({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 rounded-full border border-[var(--interactive-border)]/45 bg-[var(--surface-background)]/95 px-1 py-0.5 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-0.5 rounded-full border border-[var(--interactive-border)]/45 bg-[var(--surface-background)]/95 px-1 py-0.5 shadow-sm backdrop-blur-md">
|
||||
{staged ? (
|
||||
<FileDiffActionButton
|
||||
label={t('gitView.changes.unstageFileAria', { path: filePath })}
|
||||
@@ -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'),
|
||||
@@ -1539,6 +1768,37 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
scrollToFile(value);
|
||||
}, [cancelPendingScrollAlignment, expandStackedFile, scrollToFile]);
|
||||
|
||||
// Step review to the adjacent changed file (alt+arrow): selects, expands
|
||||
// a collapsed section, and scrolls to it. Window-level because the diff
|
||||
// surface has no persistent focus target; guarded off editable fields.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && (
|
||||
target.isContentEditable
|
||||
|| target.tagName === 'INPUT'
|
||||
|| target.tagName === 'TEXTAREA'
|
||||
|| target.closest('[role="dialog"]')
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (changedFiles.length === 0) return;
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const index = displayFile ? changedFiles.findIndex((file) => file.path === displayFile) : -1;
|
||||
const nextIndex = index === -1
|
||||
? (delta > 0 ? 0 : changedFiles.length - 1)
|
||||
: index + delta;
|
||||
const next = changedFiles[nextIndex];
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
handleSelectFileAndScroll(next.path);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [changedFiles, displayFile, handleSelectFileAndScroll]);
|
||||
|
||||
const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => {
|
||||
const nextLayout: 'inline' | 'side-by-side' =
|
||||
mode === 'side-by-side' ? 'side-by-side' : 'inline';
|
||||
@@ -1670,7 +1930,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 +1974,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 +2071,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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
@@ -1346,8 +1347,10 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await git.checkoutBranch(currentDirectory, normalized);
|
||||
toast.success(t('gitView.toast.checkedOut', { name: normalized }));
|
||||
// Picking a remote-tracking branch checks out the local branch that
|
||||
// tracks it, so report the branch the repository actually landed on.
|
||||
const result = await git.checkoutBranch(currentDirectory, normalized);
|
||||
toast.success(t('gitView.toast.checkedOut', { name: result?.branch || normalized }));
|
||||
await refreshStatusAndBranches();
|
||||
await refreshLog();
|
||||
} catch (err) {
|
||||
@@ -2585,7 +2588,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 +2598,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)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { findMatchRanges } from './markdownPreviewFind';
|
||||
|
||||
/**
|
||||
@@ -23,8 +24,10 @@ import { findMatchRanges } from './markdownPreviewFind';
|
||||
*/
|
||||
const MARK_ATTR = 'data-md-find';
|
||||
const CURRENT_MARK_ATTR = 'data-md-find-current';
|
||||
const MARK_CLASS = 'rounded-[2px] bg-[var(--status-warning)]/40';
|
||||
const CURRENT_MARK_CLASS = 'rounded-[2px] bg-[var(--status-warning)]/80';
|
||||
const MARK_CLASS = 'rounded-[2px] bg-status-warning/30 text-foreground';
|
||||
const CURRENT_MARK_CLASS = 'rounded-[2px] bg-status-warning/60 text-foreground';
|
||||
/** Keystrokes re-walk the whole preview, so coalesce bursts of typing. */
|
||||
const SEARCH_DEBOUNCE_MS = 120;
|
||||
|
||||
const isMarkElement = (node: Node): boolean => {
|
||||
return node instanceof Element && node.hasAttribute(MARK_ATTR);
|
||||
@@ -67,7 +70,10 @@ const applySearch = (container: HTMLElement, query: string): HTMLElement[] => {
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
while (walker.nextNode()) {
|
||||
textNodes.push(walker.currentNode as Text);
|
||||
const node = walker.currentNode;
|
||||
if (node instanceof Text) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of textNodes) {
|
||||
@@ -114,6 +120,8 @@ type MarkdownPreviewSearchProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Bumped every time the find shortcut is pressed to re-focus the input. */
|
||||
focusNonce: number;
|
||||
/** Layout overrides for the floating bar (position, offsets). */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
@@ -121,6 +129,7 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
focusNonce,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = React.useState('');
|
||||
@@ -130,6 +139,9 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
const marksRef = React.useRef<HTMLElement[]>([]);
|
||||
const queryRef = React.useRef(query);
|
||||
queryRef.current = query;
|
||||
const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Focus returns here when the bar closes, so Escape does not strand focus.
|
||||
const returnFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
const runSearch = React.useCallback((nextQuery: string) => {
|
||||
const container = containerRef.current;
|
||||
@@ -144,6 +156,31 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
setIndex(0);
|
||||
}, [containerRef]);
|
||||
|
||||
const scheduleSearch = React.useCallback((nextQuery: string) => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
runSearch(nextQuery);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}, [runSearch]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
onOpenChange(false);
|
||||
const target = returnFocusRef.current;
|
||||
returnFocusRef.current = null;
|
||||
if (target?.isConnected) {
|
||||
target.focus();
|
||||
}
|
||||
}, [onOpenChange]);
|
||||
|
||||
// Re-apply highlights when the renderer re-morphs the container (theme or
|
||||
// content changes), ignoring mutations this widget produces itself. Only
|
||||
// active while the bar is open; closing clears the highlights.
|
||||
@@ -171,11 +208,16 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
};
|
||||
}, [containerRef, open, runSearch]);
|
||||
|
||||
// Focus the input when the bar opens.
|
||||
// Focus the input when the bar opens, remembering what to restore on close.
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
inputRef.current?.focus();
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const previous = document.activeElement;
|
||||
if (previous instanceof HTMLElement && !returnFocusRef.current) {
|
||||
returnFocusRef.current = previous;
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Pressing the find shortcut again re-focuses and re-selects the query.
|
||||
@@ -226,23 +268,23 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onOpenChange(false);
|
||||
close();
|
||||
}
|
||||
}, [goToNext, goToPrevious, onOpenChange]);
|
||||
}, [close, goToNext, goToPrevious]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-1 rounded-lg border border-border/60 bg-[var(--surface-elevated)] px-1.5 py-1 shadow-lg">
|
||||
<div className={cn('absolute right-3 top-3 z-10 flex items-center gap-1 rounded-lg border border-border/60 bg-[var(--surface-elevated)] px-1.5 py-1 shadow-lg', className)}>
|
||||
<Icon name="search" className="ml-0.5 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
runSearch(event.target.value);
|
||||
scheduleSearch(event.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('filesView.preview.find.placeholder')}
|
||||
@@ -291,7 +333,7 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-muted-foreground"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={close}
|
||||
title={t('filesView.preview.find.closeAria')}
|
||||
aria-label={t('filesView.preview.find.closeAria')}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import * as React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { applyPendingOpenCodeRestart } from '@/lib/opencode/deferredRestart';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import {
|
||||
selectPendingOpenCodeRestartCount,
|
||||
usePendingOpenCodeRestartStore,
|
||||
} from '@/stores/usePendingOpenCodeRestartStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
type OpenCodeReloadFooterActionProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const OpenCodeReloadFooterAction: React.FC<OpenCodeReloadFooterActionProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const pendingCount = usePendingOpenCodeRestartStore(selectPendingOpenCodeRestartCount);
|
||||
const isApplying = usePendingOpenCodeRestartStore((state) => state.isApplying);
|
||||
const showRestartConfirm = useUIStore((state) => state.showOpenCodeRestartConfirm);
|
||||
const setShowOpenCodeRestartConfirm = useUIStore((state) => state.setShowOpenCodeRestartConfirm);
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||
const [dontShowAgain, setDontShowAgain] = React.useState(false);
|
||||
|
||||
const hasPending = pendingCount > 0;
|
||||
|
||||
const runApply = React.useCallback(async () => {
|
||||
try {
|
||||
const result = await applyPendingOpenCodeRestart({
|
||||
message: t('settings.view.pendingRestart.applying'),
|
||||
});
|
||||
if (result.requiresManualRestart) {
|
||||
toast.warning(t('settings.view.pendingRestart.manualRestartRequired'));
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
toast.success(t('settings.view.pendingRestart.applied'));
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('settings.view.pendingRestart.applyFailed');
|
||||
toast.error(message);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const runManualReload = React.useCallback(async () => {
|
||||
try {
|
||||
await reloadOpenCodeConfiguration({
|
||||
message: t('settings.view.pendingRestart.applying'),
|
||||
mode: 'projects',
|
||||
scopes: ['all'],
|
||||
});
|
||||
usePendingOpenCodeRestartStore.getState().clear();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleConfirmApply = React.useCallback(() => {
|
||||
if (dontShowAgain) {
|
||||
setShowOpenCodeRestartConfirm(false);
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
setDontShowAgain(false);
|
||||
void runApply();
|
||||
}, [dontShowAgain, runApply, setShowOpenCodeRestartConfirm]);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (!hasPending) {
|
||||
void runManualReload();
|
||||
return;
|
||||
}
|
||||
if (showRestartConfirm) {
|
||||
setDontShowAgain(false);
|
||||
setConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
void runApply();
|
||||
}, [hasPending, runApply, runManualReload, showRestartConfirm]);
|
||||
|
||||
const label = !hasPending
|
||||
? t('settings.view.actions.reloadOpenCode')
|
||||
: isApplying
|
||||
? t('settings.view.pendingRestart.applying')
|
||||
: t('settings.view.actions.applyAndRestartOpenCode');
|
||||
|
||||
const tooltip = !hasPending
|
||||
? t('settings.view.actions.reloadOpenCodeTooltip')
|
||||
: pendingCount === 1
|
||||
? t('settings.view.actions.applyAndRestartOpenCodeTooltipSingle')
|
||||
: t('settings.view.actions.applyAndRestartOpenCodeTooltipPlural', { count: pendingCount });
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isApplying}
|
||||
title={tooltip}
|
||||
aria-label={tooltip}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md overflow-hidden whitespace-nowrap',
|
||||
'h-11 px-3 sm:h-8 sm:px-2',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
hasPending
|
||||
? 'bg-primary text-primary-foreground typography-ui-label font-semibold hover:bg-primary/90'
|
||||
: 'text-sm font-semibold text-sidebar-foreground/90 hover:text-sidebar-foreground hover:bg-interactive-hover',
|
||||
isApplying && 'opacity-80',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasPending ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1.5',
|
||||
'bg-background text-foreground typography-micro font-semibold tabular-nums',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{pendingCount}
|
||||
</span>
|
||||
) : (
|
||||
<Icon name="restart" className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
|
||||
<Dialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
setConfirmOpen(open);
|
||||
if (!open) {
|
||||
setDontShowAgain(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="max-w-md gap-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.view.pendingRestart.confirm.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.view.pendingRestart.confirm.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Plain stack — avoid DialogFooter (sm:flex-row) fighting this layout */}
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<div className="flex w-full items-stretch justify-center gap-3">
|
||||
<div className="min-w-0 shrink-0" style={{ width: '40%' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 w-full normal-case"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
>
|
||||
{t('settings.view.pendingRestart.confirm.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-w-0 shrink-0" style={{ width: '40%' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="h-9 w-full normal-case"
|
||||
onClick={handleConfirmApply}
|
||||
>
|
||||
{t('settings.view.actions.applyAndRestartOpenCode')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDontShowAgain((value) => !value)}
|
||||
className="inline-flex items-center justify-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
aria-pressed={dontShowAgain}
|
||||
>
|
||||
{dontShowAgain
|
||||
? <Icon name="checkbox" className="h-4 w-4 text-primary" />
|
||||
: <Icon name="checkbox-blank" className="h-4 w-4" />}
|
||||
{t('settings.view.pendingRestart.confirm.dontShowAgain')}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -52,7 +52,7 @@ interface PierreDiffViewerProps {
|
||||
* and enables touch-friendly line interactions. Re-exported so plain
|
||||
* <PierreFile> consumers (e.g. `MobileFilesSurface`) can inject the same.
|
||||
*/
|
||||
export const PIERRE_RUNTIME_BASE_CSS = `
|
||||
const PIERRE_RUNTIME_BASE_CSS = `
|
||||
:host {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-code);
|
||||
@@ -82,6 +82,27 @@ export 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(() => {
|
||||
|
||||
@@ -38,7 +38,11 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||
import { parseProjectPlanMarkdown } from '@/lib/openchamberConfig';
|
||||
import { fetchProjectPlan, parsePlanMarkdown, resolveProjectContextId, type SavedProjectPlanTarget } from '@/lib/projectContextApi';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import { createPlanSaveQueue } from '@/lib/planSaveQueue';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -48,6 +52,12 @@ import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type PlanViewProps = {
|
||||
targetPath?: string | null;
|
||||
/** Saved project plan to open, with the project that owns it. The owner is
|
||||
part of the prop so the view never guesses it from the current directory:
|
||||
plan tabs outlive directory changes (persisted context tabs, mobile
|
||||
overlays), and for managed chats the owner is not a registered project a
|
||||
directory lookup could ever find. */
|
||||
savedProjectPlan?: SavedProjectPlanTarget | null;
|
||||
/** Called after a send action routes the user to the chat — hosts that show
|
||||
PlanView in an overlay (mobile fullscreen surface) close it here. */
|
||||
onNavigatedToChat?: () => void;
|
||||
@@ -145,12 +155,16 @@ const resolveProjectRefForDirectory = (
|
||||
return match ? { id: match.id, path: match.path } : null;
|
||||
};
|
||||
|
||||
const subscribeActiveRuntimeKey = (onStoreChange: () => void): (() => void) => {
|
||||
return subscribeRuntimeEndpointChanged(() => onStoreChange());
|
||||
};
|
||||
|
||||
type SelectedLineRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigatedToChat }) => {
|
||||
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, savedProjectPlan = null, onNavigatedToChat }) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
@@ -164,9 +178,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
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 activeRuntimeKey = React.useSyncExternalStore(subscribeActiveRuntimeKey, getRuntimeKey, getRuntimeKey);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
|
||||
@@ -187,14 +201,47 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
() => resolveProjectRefForDirectory(projectDirectory, projects, activeProjectId),
|
||||
[activeProjectId, projectDirectory, projects],
|
||||
);
|
||||
// Destructured to primitives so the load/save effects key on stable values
|
||||
// instead of a descriptor object rebuilt on every parent render.
|
||||
const savedPlanProjectId = savedProjectPlan?.projectRef.id ?? null;
|
||||
const savedPlanProjectPath = savedProjectPlan?.projectRef.path ?? null;
|
||||
const savedPlanProjectRef = React.useMemo(
|
||||
() => savedPlanProjectId && savedPlanProjectPath
|
||||
? { id: savedPlanProjectId, path: savedPlanProjectPath }
|
||||
: null,
|
||||
[savedPlanProjectId, savedPlanProjectPath],
|
||||
);
|
||||
const savedPlanId = savedProjectPlan?.planId ?? null;
|
||||
// Stable logical identity, composed from primitives: an effect keyed on the
|
||||
// descriptor object would reload — and flush — the same plan whenever a
|
||||
// parent rebuilds the owner object with identical values.
|
||||
const savedPlanKey = savedPlanProjectRef && savedPlanId
|
||||
? JSON.stringify(['saved-plan', activeRuntimeKey, resolveProjectContextId(savedPlanProjectRef), savedPlanId])
|
||||
: null;
|
||||
// Managed chats have no project directory to create a session in: their
|
||||
// sessions live in per-session directories under the chats root, which
|
||||
// createSession cannot prepare. Until a managed-chat send path exists,
|
||||
// Improve/Implement stay unavailable for plans stored under the Chats
|
||||
// owner — an OpenCode session created directly in the shared root would
|
||||
// break the managed-chats model.
|
||||
const isManagedChatPlan = savedPlanProjectRef?.id === CHAT_DRAFT_PROJECT_ID;
|
||||
const canCreateWorktree = React.useMemo(
|
||||
() => (currentProjectRef ? gitDirectories.get(currentProjectRef.path)?.isGitRepo === true : false),
|
||||
[currentProjectRef, gitDirectories],
|
||||
() => {
|
||||
// Worktree creation follows the session the plan would be sent to.
|
||||
const sendTarget = savedPlanProjectRef ?? currentProjectRef;
|
||||
return sendTarget ? gitDirectories.get(sendTarget.path)?.isGitRepo === true : false;
|
||||
},
|
||||
[currentProjectRef, gitDirectories, savedPlanProjectRef],
|
||||
);
|
||||
const [pendingPlanSend, setPendingPlanSend] = React.useState<PendingPlanSend | null>(null);
|
||||
const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false);
|
||||
|
||||
const [resolvedPath, setResolvedPath] = React.useState<string | null>(null);
|
||||
// Set once a saved project plan has actually loaded. Kept separate from
|
||||
// `resolvedPath` so nothing downstream can mistake a project plan for a file
|
||||
// the user could open, edit, or be shown a path for.
|
||||
const [loadedProjectPlanId, setLoadedProjectPlanId] = React.useState<string | null>(null);
|
||||
const hasDocument = Boolean(resolvedPath) || Boolean(loadedProjectPlanId);
|
||||
const displayPath = React.useMemo(() => {
|
||||
if (!resolvedPath || !sessionDirectory || !homeDirectory) {
|
||||
return resolvedPath;
|
||||
@@ -205,6 +252,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
const [saveError, setSaveError] = React.useState<string | null>(null);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
const planFileLabel = React.useMemo(() => {
|
||||
return displayPath ? displayPath.split('/').pop() || t('planView.file.defaultName') : t('planView.file.defaultName');
|
||||
}, [displayPath, t]);
|
||||
@@ -212,7 +260,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
if (!content.trim()) {
|
||||
return t('planView.title.default');
|
||||
}
|
||||
return parseProjectPlanMarkdown(content).title || t('planView.title.default');
|
||||
return parsePlanMarkdown(content, t('planView.title.default')).title;
|
||||
}, [content, t]);
|
||||
const sendPromptTitle = React.useMemo(() => parsedTitle.trim() || t('planView.title.default'), [parsedTitle, t]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
@@ -372,10 +420,98 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
return extensions;
|
||||
}, [currentTheme, resolvedPath, editorFontSize]);
|
||||
|
||||
// Pending-save bookkeeping for the open document. One ref record, not state:
|
||||
// debounced writes and close-time flushes must read the newest buffer and
|
||||
// revision without another render. `editRevision` advances on every editor
|
||||
// change; `savedRevision` only after a successful write of that exact
|
||||
// revision, so a slow in-flight save can never mark newer edits as saved.
|
||||
// `key` and `runtimeKey` make every write self-identifying: content never
|
||||
// crosses documents or runtimes, no matter when a queued write settles.
|
||||
const docRef = React.useRef<{
|
||||
key: string | null;
|
||||
target: SavedProjectPlanTarget | { filePath: string } | null;
|
||||
content: string;
|
||||
editRevision: number;
|
||||
savedRevision: number;
|
||||
runtimeKey: string;
|
||||
}>({ key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' });
|
||||
const saveQueue = React.useState(createPlanSaveQueue)[0];
|
||||
|
||||
// Filesystem writes keep the runtime adapter precedence the view always
|
||||
// used: the active RuntimeAPIs first, the registry as fallback.
|
||||
const writeDocument = React.useCallback(async (target: NonNullable<typeof docRef.current['target']>, text: string): Promise<void> => {
|
||||
if ('filePath' in target) {
|
||||
const files = runtimeApis.files ?? getRegisteredRuntimeAPIs()?.files;
|
||||
if (files?.writeFile) {
|
||||
const result = await files.writeFile(target.filePath, text);
|
||||
if (!result?.success) {
|
||||
throw new Error('Plan file write failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const response = await runtimeFetch('/api/fs/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target.filePath, content: text }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to write plan file (${response.status})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const saved = await useProjectContextStore.getState().savePlan(target.projectRef, target.planId, text);
|
||||
if (!saved) {
|
||||
throw new Error('Plan save rejected: the plan no longer exists');
|
||||
}
|
||||
}, [runtimeApis.files]);
|
||||
const writeDocumentRef = React.useRef(writeDocument);
|
||||
writeDocumentRef.current = writeDocument;
|
||||
|
||||
// Queue any unflushed edits. Runs on document switches and on unmount, both
|
||||
// of which cancel the debounced save — without this the last 350ms of typing
|
||||
// is silently dropped. The queue orders it behind any write already in
|
||||
// flight for the same document, and the captured runtime key stops content
|
||||
// from one host being written into another after a runtime switch.
|
||||
const scheduleSave = React.useCallback(() => {
|
||||
const doc = docRef.current;
|
||||
if (!doc.key || !doc.target || doc.editRevision <= doc.savedRevision) {
|
||||
return;
|
||||
}
|
||||
const captured = {
|
||||
key: doc.key,
|
||||
target: doc.target,
|
||||
content: doc.content,
|
||||
revision: doc.editRevision,
|
||||
runtimeKey: doc.runtimeKey,
|
||||
write: writeDocumentRef.current,
|
||||
};
|
||||
saveQueue.schedule(captured.key, captured.revision, async () => {
|
||||
if (getRuntimeKey() !== captured.runtimeKey) {
|
||||
// The runtime switched while this write waited: writing through the
|
||||
// new connection would land one host's edits on another.
|
||||
return;
|
||||
}
|
||||
await captured.write(captured.target, captured.content);
|
||||
const current = docRef.current;
|
||||
if (current.key === captured.key) {
|
||||
current.savedRevision = Math.max(current.savedRevision, captured.revision);
|
||||
// A recovered save clears the stale failure banner.
|
||||
setSaveError(null);
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (docRef.current.key === captured.key) {
|
||||
setSaveError(error instanceof Error ? error.message : 'Plan save failed');
|
||||
}
|
||||
});
|
||||
}, [saveQueue]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Saved project plans opened via context panel should work even when session plan mode is off.
|
||||
if (!planModeEnabled && !targetPath) {
|
||||
if (!planModeEnabled && !targetPath && !savedPlanId) {
|
||||
scheduleSave();
|
||||
docRef.current = { key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' };
|
||||
setResolvedPath(null);
|
||||
setLoadedProjectPlanId(null);
|
||||
setContent('');
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -406,15 +542,72 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
// Flush the outgoing document before the bookkeeping is replaced, so
|
||||
// edits typed within the debounce window survive a plan switch. React
|
||||
// reuses this component instance across saved-plan tabs.
|
||||
scheduleSave();
|
||||
docRef.current = { key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' };
|
||||
setResolvedPath(null);
|
||||
setLoadedProjectPlanId(null);
|
||||
setContent('');
|
||||
setSaveError(null);
|
||||
setLoadError(null);
|
||||
|
||||
if (savedPlanId && savedPlanProjectRef && savedPlanKey) {
|
||||
// A plan re-opened while its own flush is still writing must read the
|
||||
// post-write state, not race it. The queue reset afterwards is safe:
|
||||
// every write for this key has settled, and the reloaded document
|
||||
// restarts its revision counter at zero.
|
||||
await saveQueue.pendingFor(savedPlanKey);
|
||||
if (cancelled) return;
|
||||
saveQueue.reset(savedPlanKey);
|
||||
setLoading(true);
|
||||
try {
|
||||
const plan = await fetchProjectPlan(savedPlanProjectRef, savedPlanId);
|
||||
if (cancelled) return;
|
||||
if (!plan) {
|
||||
// The plan or its markdown is gone. Leave the view empty and
|
||||
// unsaveable rather than presenting an editor that would recreate
|
||||
// a document the user deleted.
|
||||
setLoadError('Plan not found');
|
||||
return;
|
||||
}
|
||||
docRef.current = {
|
||||
key: savedPlanKey,
|
||||
target: { projectRef: savedPlanProjectRef, planId: savedPlanId },
|
||||
content: plan.raw,
|
||||
editRevision: 0,
|
||||
savedRevision: 0,
|
||||
runtimeKey: activeRuntimeKey,
|
||||
};
|
||||
setContent(plan.raw);
|
||||
setLoadedProjectPlanId(savedPlanId);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setLoadError(error instanceof Error ? error.message : 'Plan load failed');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetPath) {
|
||||
const fileKey = JSON.stringify(['plan-file', activeRuntimeKey, targetPath]);
|
||||
await saveQueue.pendingFor(fileKey);
|
||||
if (cancelled) return;
|
||||
saveQueue.reset(fileKey);
|
||||
setLoading(true);
|
||||
try {
|
||||
const text = await readText(targetPath);
|
||||
if (cancelled) return;
|
||||
docRef.current = {
|
||||
key: fileKey,
|
||||
target: { filePath: targetPath },
|
||||
content: text,
|
||||
editRevision: 0,
|
||||
savedRevision: 0,
|
||||
runtimeKey: activeRuntimeKey,
|
||||
};
|
||||
setResolvedPath(targetPath);
|
||||
setContent(text);
|
||||
} catch {
|
||||
@@ -440,10 +633,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
const homePath = resolveTilde(buildHomePlanPath(session.time.created, session.slug), homeDirectory || null);
|
||||
|
||||
let resolved: string | null = null;
|
||||
let text: string | null = null;
|
||||
|
||||
try {
|
||||
text = await readText(repoPath);
|
||||
await readText(repoPath);
|
||||
resolved = repoPath;
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -451,7 +643,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
|
||||
if (!resolved) {
|
||||
try {
|
||||
text = await readText(homePath);
|
||||
await readText(homePath);
|
||||
resolved = homePath;
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -460,12 +652,26 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!resolved || text === null) {
|
||||
if (!resolved) {
|
||||
setResolvedPath(null);
|
||||
setContent('');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionFileKey = JSON.stringify(['plan-file', activeRuntimeKey, resolved]);
|
||||
await saveQueue.pendingFor(sessionFileKey);
|
||||
if (cancelled) return;
|
||||
const text = await readText(resolved);
|
||||
if (cancelled) return;
|
||||
saveQueue.reset(sessionFileKey);
|
||||
docRef.current = {
|
||||
key: sessionFileKey,
|
||||
target: { filePath: resolved },
|
||||
content: text,
|
||||
editRevision: 0,
|
||||
savedRevision: 0,
|
||||
runtimeKey: activeRuntimeKey,
|
||||
};
|
||||
setResolvedPath(resolved);
|
||||
setContent(text);
|
||||
} catch {
|
||||
@@ -482,41 +688,42 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [homeDirectory, planModeEnabled, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, targetPath]);
|
||||
}, [activeRuntimeKey, homeDirectory, planModeEnabled, runtimeApis.files, savedPlanId, savedPlanKey, savedPlanProjectRef, saveQueue, scheduleSave, session?.slug, session?.time?.created, sessionDirectory, targetPath]);
|
||||
|
||||
// Synchronous buffer tracking: if an edit and an unmount land in the same
|
||||
// batch, the passive content effect would never run and a flush would save
|
||||
// a stale buffer.
|
||||
const handleContentChange = React.useCallback((next: string) => {
|
||||
docRef.current.content = next;
|
||||
docRef.current.editRevision += 1;
|
||||
setContent(next);
|
||||
}, []);
|
||||
|
||||
// The debounced write and the close/switch flush go through the same queue
|
||||
// (scheduleSave), so two saves of one document can never complete out of
|
||||
// order and a flush never duplicates a debounce of the same revision.
|
||||
React.useEffect(() => {
|
||||
if (!resolvedPath) {
|
||||
setSaveError(null);
|
||||
if (!resolvedPath && !loadedProjectPlanId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = window.setTimeout(async () => {
|
||||
setSaveError(null);
|
||||
try {
|
||||
if (runtimeApis.files?.writeFile) {
|
||||
const result = await runtimeApis.files.writeFile(resolvedPath, content);
|
||||
if (!result?.success) {
|
||||
throw new Error(t('planView.error.writeFailed'));
|
||||
}
|
||||
} else {
|
||||
const response = await runtimeFetch('/api/fs/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: resolvedPath, content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(t('planView.error.writePlanFileFailed', { status: response.status }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : t('planView.error.saveFailed'));
|
||||
}
|
||||
const controller = window.setTimeout(() => {
|
||||
scheduleSave();
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(controller);
|
||||
};
|
||||
}, [content, resolvedPath, runtimeApis.files, t]);
|
||||
}, [content, loadedProjectPlanId, resolvedPath, scheduleSave]);
|
||||
|
||||
// Closing the view inside the 350ms debounce window would drop the last
|
||||
// edits: the cleanup above cancels the timer. Same for switching documents,
|
||||
// which the load effect handles before replacing the bookkeeping.
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
scheduleSave();
|
||||
};
|
||||
}, [scheduleSave]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
@@ -527,14 +734,17 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
}, []);
|
||||
|
||||
const routeToChat = React.useCallback(() => {
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
onNavigatedToChat?.();
|
||||
}, [onNavigatedToChat, setActiveMainTab, setSessionSwitcherOpen]);
|
||||
}, [onNavigatedToChat, setSessionSwitcherOpen]);
|
||||
|
||||
const handleConfirmPlanSend = React.useCallback(
|
||||
async (execution: TodoSendExecution) => {
|
||||
if (!currentProjectRef || !pendingPlanSend) {
|
||||
// A saved plan sends against its own project — the one it is stored
|
||||
// under — not against whatever directory the viewer is currently in.
|
||||
// For filesystem plans those are the same directory.
|
||||
const sendTargetProject = savedPlanProjectRef ?? currentProjectRef;
|
||||
if (!sendTargetProject || !pendingPlanSend || isManagedChatPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -551,32 +761,45 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
plan_path: resolvedPath ?? '',
|
||||
},
|
||||
);
|
||||
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
|
||||
// Saved project plans have no file path for the agent to read. Without
|
||||
// this the instructions say "read that file" with an empty path and the
|
||||
// plan contents never reach the session, so the plan substance rides
|
||||
// along in the synthetic message instead.
|
||||
const planSubstance = resolvedPath
|
||||
? instructionsText
|
||||
: [
|
||||
instructionsText,
|
||||
'',
|
||||
'The plan is not stored as a file in the repository and has no file path. Its full current contents follow below this note and are the source of truth for the plan. Where the instructions above refer to the plan file, treat the plan as stored in OpenChamber project knowledge (it is edited through the OpenChamber UI): propose plan revisions as plan text in the chat rather than editing a file.',
|
||||
'',
|
||||
content,
|
||||
].join('\n');
|
||||
const syntheticParts = [{ synthetic: true as const, text: planSubstance }];
|
||||
setIsPlanSendSubmitting(true);
|
||||
|
||||
try {
|
||||
routeToChat();
|
||||
|
||||
let sessionId: string | null = null;
|
||||
let directoryHint: string | null = currentProjectRef.path;
|
||||
let directoryHint: string | null = sendTargetProject.path;
|
||||
|
||||
if (pendingPlanSend.target === 'worktree') {
|
||||
if (!canCreateWorktree) {
|
||||
return;
|
||||
}
|
||||
const created = await createWorktreeSessionForNewBranch(currentProjectRef.path, generateBranchName());
|
||||
const created = await createWorktreeSessionForNewBranch(sendTargetProject.path, generateBranchName());
|
||||
if (!created?.id) {
|
||||
return;
|
||||
}
|
||||
sessionId = created.id;
|
||||
directoryHint = created.path;
|
||||
} else {
|
||||
const sessionResult = await createSession(undefined, currentProjectRef.path, null);
|
||||
const sessionResult = await createSession(undefined, sendTargetProject.path, null);
|
||||
if (!sessionResult?.id) {
|
||||
return;
|
||||
}
|
||||
sessionId = sessionResult.id;
|
||||
directoryHint = sessionResult.directory ?? currentProjectRef.path;
|
||||
directoryHint = sessionResult.directory ?? sendTargetProject.path;
|
||||
initializeNewOpenChamberSession(sessionResult.id, useConfigStore.getState().agents ?? []);
|
||||
}
|
||||
|
||||
@@ -614,8 +837,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
// source. Here we only compose header + full content.
|
||||
const goalObjective = execution.runAsGoal === true
|
||||
? [
|
||||
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`,
|
||||
'Re-read that file for full details — it is the source of truth.',
|
||||
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ' (the full plan follows)'}.`,
|
||||
resolvedPath
|
||||
? 'Re-read that file for full details — it is the source of truth.'
|
||||
: 'The full plan follows in this message and is the source of truth.',
|
||||
'',
|
||||
content,
|
||||
].join('\n')
|
||||
@@ -637,7 +862,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
setIsPlanSendSubmitting(false);
|
||||
}
|
||||
},
|
||||
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
|
||||
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, isManagedChatPlan, pendingPlanSend, resolvedPath, routeToChat, savedPlanProjectRef, sendMessage, sendPromptTitle, setCurrentSession]
|
||||
);
|
||||
|
||||
const blockWidgets = React.useMemo(() => {
|
||||
@@ -666,13 +891,18 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label font-medium truncate">{parsedTitle}</div>
|
||||
{loadError ? (
|
||||
<div className="typography-micro text-[color:var(--status-error)] truncate" title={loadError}>
|
||||
{t('planView.error.loadFailed')}
|
||||
</div>
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<div className="typography-micro text-[color:var(--status-error)] truncate" title={saveError}>
|
||||
{t('planView.error.saveFailed')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{resolvedPath ? (
|
||||
{hasDocument ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
@@ -683,7 +913,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
aria-label={t('planView.actions.improvePlanAria')}
|
||||
disabled={!content.trim()}
|
||||
disabled={!content.trim() || isManagedChatPlan}
|
||||
>
|
||||
<Icon name="loop-right-ai" className="size-4" />
|
||||
</Button>
|
||||
@@ -692,7 +922,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
<TooltipContent sideOffset={8}>{t('planView.actions.improve')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}
|
||||
disabled={isManagedChatPlan}
|
||||
>
|
||||
{t('planView.actions.sendToNewSession')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -712,7 +945,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
aria-label={t('planView.actions.implementPlanAria')}
|
||||
disabled={!content.trim()}
|
||||
disabled={!content.trim() || isManagedChatPlan}
|
||||
>
|
||||
<Icon name="code-ai" className="size-4" />
|
||||
</Button>
|
||||
@@ -721,7 +954,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
<TooltipContent sideOffset={8}>{t('planView.actions.implement')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}
|
||||
disabled={isManagedChatPlan}
|
||||
>
|
||||
{t('planView.actions.sendToNewSession')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -803,7 +1039,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
}
|
||||
}}
|
||||
target={pendingPlanSend?.target ?? 'session'}
|
||||
projectDirectory={currentProjectRef?.path ?? null}
|
||||
projectDirectory={savedPlanProjectRef?.path ?? currentProjectRef?.path ?? null}
|
||||
submitting={isPlanSendSubmitting}
|
||||
allowRunAsGoal
|
||||
onConfirm={handleConfirmPlanSend}
|
||||
@@ -835,7 +1071,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
|
||||
<div className="relative h-full" ref={editorWrapperRef}>
|
||||
<CodeMirrorEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onChange={handleContentChange}
|
||||
readOnly={false}
|
||||
className="h-full"
|
||||
extensions={editorExtensions}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
formatShortcutForDisplay,
|
||||
getEffectiveShortcutCombo,
|
||||
} from '@/lib/shortcuts';
|
||||
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';
|
||||
@@ -9,7 +14,7 @@ import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
|
||||
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
|
||||
@@ -34,6 +39,7 @@ import { MagicPromptsPage } from '@/components/sections/magic-prompts/MagicPromp
|
||||
import { SnippetsSidebar } from '@/components/sections/snippets/SnippetsSidebar';
|
||||
import { SnippetsPage } from '@/components/sections/snippets/SnippetsPage';
|
||||
import { GitPage } from '@/components/sections/git-identities/GitPage';
|
||||
import { IntegrationsPage } from '@/components/sections/integrations/IntegrationsPage';
|
||||
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
|
||||
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
||||
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
|
||||
@@ -46,11 +52,15 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRunti
|
||||
import { isWindowsArm64 as isWindowsArm64Platform } from '@/lib/platform';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { OpenCodeReloadFooterAction } from '@/components/views/OpenCodeReloadFooterAction';
|
||||
import {
|
||||
selectPendingOpenCodeRestartCount,
|
||||
usePendingOpenCodeRestartStore,
|
||||
} from '@/stores/usePendingOpenCodeRestartStore';
|
||||
import {
|
||||
SETTINGS_PAGE_METADATA,
|
||||
getSettingsNavIcon,
|
||||
getSettingsPageMeta,
|
||||
resolveSettingsSlug,
|
||||
type SettingsPageSlug,
|
||||
@@ -90,6 +100,7 @@ const pageOrder: SettingsPageSlug[] = [
|
||||
'sessions',
|
||||
'shortcuts',
|
||||
'voice',
|
||||
'integrations',
|
||||
'usage',
|
||||
'about',
|
||||
// 'projects' group — Workspace
|
||||
@@ -113,7 +124,6 @@ const pageOrder: SettingsPageSlug[] = [
|
||||
|
||||
const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const;
|
||||
|
||||
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
|
||||
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
|
||||
|
||||
function buildRuntimeContext(isDesktop: boolean, isMobile: boolean): SettingsRuntimeContext {
|
||||
@@ -171,74 +181,17 @@ function getCurrentHistoryState(): Record<string, unknown> {
|
||||
return window.history.state;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
switch (slug) {
|
||||
case 'general':
|
||||
return 'settings-3';
|
||||
case 'projects':
|
||||
return 'folders';
|
||||
case 'remote-instances':
|
||||
return 'computer';
|
||||
case 'appearance':
|
||||
return 'palette';
|
||||
case 'chat':
|
||||
return 'chat-ai-3';
|
||||
case 'magic-prompts':
|
||||
return 'ai-generate-2';
|
||||
case 'snippets':
|
||||
return SNIPPETS_SETTINGS_ICON.icon;
|
||||
case 'notifications':
|
||||
return 'notification-3';
|
||||
case 'shortcuts':
|
||||
return 'command';
|
||||
case 'sessions':
|
||||
return 'chat-history';
|
||||
|
||||
case 'providers':
|
||||
return 'cloud';
|
||||
case 'agents':
|
||||
return 'ai-agent';
|
||||
case 'behavior':
|
||||
return 'brain';
|
||||
case 'commands':
|
||||
return 'slash-commands-2';
|
||||
case 'mcp':
|
||||
return null;
|
||||
case 'plugins':
|
||||
return 'plug-2';
|
||||
|
||||
case 'skills.installed':
|
||||
return 'book-open';
|
||||
case 'skills.catalog':
|
||||
return 'book';
|
||||
|
||||
case 'git':
|
||||
return 'git-branch';
|
||||
|
||||
case 'usage':
|
||||
return 'bar-chart-2';
|
||||
case 'voice':
|
||||
return 'mic';
|
||||
case 'tunnel':
|
||||
return 'home-office';
|
||||
case 'about':
|
||||
return 'information';
|
||||
case 'home':
|
||||
return null;
|
||||
default:
|
||||
return 'robot-2';
|
||||
}
|
||||
}
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
|
||||
const { t } = useI18n();
|
||||
const deviceInfo = useDeviceInfo();
|
||||
const isMobile = forceMobile ?? deviceInfo.isMobile;
|
||||
const pendingRestartCount = usePendingOpenCodeRestartStore(selectPendingOpenCodeRestartCount);
|
||||
|
||||
const settingsPageRaw = useUIStore((state) => state.settingsPage);
|
||||
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings);
|
||||
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
|
||||
|
||||
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
|
||||
@@ -261,6 +214,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null);
|
||||
const [activeSearchResultIndex, setActiveSearchResultIndex] = React.useState(0);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const shouldFocusMobilePageContentRef = React.useRef(false);
|
||||
const searchResultRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const activeSearchResultIndexRef = React.useRef(0);
|
||||
const keyboardSearchNavigationRef = React.useRef(false);
|
||||
@@ -307,23 +261,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') {
|
||||
@@ -331,13 +286,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);
|
||||
@@ -353,6 +310,24 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
setMobileStage(def.kind === 'split' ? 'page-sidebar' : 'page-content');
|
||||
}, [isMobile, setSettingsPage]);
|
||||
|
||||
const openThirdPartyProviderSetup = React.useCallback(async (providerId: string): Promise<boolean> => {
|
||||
const configStore = useConfigStore.getState();
|
||||
await configStore.loadProviders({ source: 'settings:third-party-provider-setup' });
|
||||
const providerAvailable = useConfigStore.getState().providers.some(
|
||||
(provider) => provider.id === providerId,
|
||||
);
|
||||
if (!providerAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
configStore.setSelectedProvider(providerId);
|
||||
openPage('providers');
|
||||
if (isMobile) {
|
||||
setMobileStage('page-content');
|
||||
}
|
||||
return true;
|
||||
}, [isMobile, openPage]);
|
||||
|
||||
const activePageMeta = React.useMemo(() => {
|
||||
return getSettingsPageMeta(settingsSlug);
|
||||
}, [settingsSlug]);
|
||||
@@ -398,6 +373,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return t('settings.page.skillsCatalog.title');
|
||||
case 'git':
|
||||
return t('settings.page.git.title');
|
||||
case 'integrations':
|
||||
return t('settings.page.integrations.title');
|
||||
case 'appearance':
|
||||
return t('settings.page.appearance.title');
|
||||
case 'chat':
|
||||
@@ -701,6 +678,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <SnippetsPage />;
|
||||
case 'git':
|
||||
return <GitPage />;
|
||||
case 'integrations':
|
||||
return (
|
||||
<IntegrationsPage
|
||||
onOpenProviderSetup={openThirdPartyProviderSetup}
|
||||
onOpenPluginManager={() => openPage('plugins')}
|
||||
/>
|
||||
);
|
||||
case 'general':
|
||||
case 'appearance':
|
||||
case 'chat':
|
||||
@@ -716,7 +700,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [openChamberSectionBySlug, renderUnavailable, runtimeCtx, t]);
|
||||
}, [openChamberSectionBySlug, openPage, openThirdPartyProviderSetup, renderUnavailable, runtimeCtx, t]);
|
||||
|
||||
// Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
|
||||
React.useEffect(() => {
|
||||
@@ -750,7 +734,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: showBackButton
|
||||
? t('settings.view.actions.backToSettings')
|
||||
: t('settings.view.actions.closeSettings');
|
||||
const shortcutKey = getModifierLabel();
|
||||
const openSettingsCombo = getEffectiveShortcutCombo(
|
||||
'open_settings',
|
||||
openSettingsShortcutOverride === undefined ? undefined : { open_settings: openSettingsShortcutOverride },
|
||||
);
|
||||
const closeSettingsTitle = openSettingsCombo
|
||||
? t('settings.view.actions.closeSettingsWithShortcut', {
|
||||
shortcut: formatShortcutForDisplay(openSettingsCombo),
|
||||
})
|
||||
: t('settings.view.actions.closeSettings');
|
||||
|
||||
const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => {
|
||||
if (typeof window === 'undefined' || runtimeCtx.isVSCode) {
|
||||
@@ -773,12 +765,30 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
}, [runtimeCtx.isVSCode]);
|
||||
|
||||
const handleMobilePageSidebarItemSelect = React.useCallback(() => {
|
||||
shouldFocusMobilePageContentRef.current = true;
|
||||
setMobileStage('page-content');
|
||||
if (settingsSlug === 'skills.installed') {
|
||||
pushMobileSplitDetailHistory(settingsSlug);
|
||||
}
|
||||
}, [pushMobileSplitDetailHistory, settingsSlug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || mobileStage !== 'page-content' || !shouldFocusMobilePageContentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldFocusMobilePageContentRef.current = false;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
containerRef.current
|
||||
?.querySelector<HTMLElement>('[data-settings-page-heading]')
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [isMobile, mobileStage, settingsSlug]);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
if (backButtonTargetsPageSidebar) {
|
||||
const currentDetail = typeof window !== 'undefined'
|
||||
@@ -954,7 +964,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>
|
||||
@@ -973,29 +983,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
|
||||
{/* Footer */}
|
||||
<div className="overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<div className="border-t border-border bg-background px-4 py-1 space-y-0.5 sm:bg-sidebar">
|
||||
{!runtimeCtx.isVSCode && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-11 w-full items-center gap-2 rounded-md px-3 overflow-hidden whitespace-nowrap sm:h-7 sm:px-2',
|
||||
'text-sm font-semibold text-sidebar-foreground/90',
|
||||
'hover:text-sidebar-foreground hover:bg-interactive-hover',
|
||||
)}
|
||||
onClick={() => void reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] }).catch(() => undefined)}
|
||||
>
|
||||
<Icon name="restart" className="h-4 w-4 shrink-0" />
|
||||
<span>{t('settings.view.actions.reloadOpenCode')}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('settings.view.actions.reloadOpenCodeTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="border-t border-border bg-background px-4 py-1.5 space-y-0.5 sm:bg-sidebar">
|
||||
{(!runtimeCtx.isVSCode || pendingRestartCount > 0) && (
|
||||
<OpenCodeReloadFooterAction />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1118,7 +1109,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('settings.view.actions.closeSettings')}
|
||||
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
|
||||
title={closeSettingsTitle}
|
||||
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<Icon name="close" className="h-5 w-5" />
|
||||
@@ -1146,7 +1137,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('settings.view.actions.closeSettings')}
|
||||
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
|
||||
title={closeSettingsTitle}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<Icon name="close" className="h-5 w-5" />
|
||||
|
||||
@@ -38,7 +38,7 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
|
||||
<Dialog.Portal>
|
||||
<Dialog.Backdrop
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 dark:bg-black/75',
|
||||
'oc-glass-backdrop fixed inset-0 z-50 bg-black/25 dark:bg-black/40',
|
||||
'transition-opacity duration-150 ease-out',
|
||||
'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
type TerminalViewProps = {
|
||||
visible?: boolean;
|
||||
@@ -58,6 +59,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 +149,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 +176,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 +686,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]);
|
||||
@@ -924,7 +969,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
onClick={() => handleModifierToggle('ctrl')}
|
||||
disabled={quickKeysDisabled}
|
||||
>
|
||||
<span className="text-xs font-medium">{t('terminalView.quickKeys.controlLabel')}</span>
|
||||
<span className="text-xs font-medium">{formatShortcutForDisplay('ctrl')}</span>
|
||||
<span className="sr-only">{t('terminalView.quickKeys.controlModifierAria')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -937,7 +982,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
onClick={() => handleModifierToggle('alt')}
|
||||
disabled={quickKeysDisabled}
|
||||
>
|
||||
<span className="text-xs font-medium">{t('terminalView.quickKeys.altLabel')}</span>
|
||||
<span className="text-xs font-medium">{formatShortcutForDisplay('alt')}</span>
|
||||
<span className="sr-only">{t('terminalView.quickKeys.altModifierAria')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -22,8 +22,6 @@ import { useI18n } from '@/lib/i18n';
|
||||
|
||||
/** Max file size in bytes (10MB) */
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
/** Max number of concurrent runs */
|
||||
const MAX_MODELS = 5;
|
||||
|
||||
/** Attached file for agent manager */
|
||||
interface AttachedFile {
|
||||
@@ -132,11 +130,8 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
}, [projectRef]);
|
||||
|
||||
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
return;
|
||||
}
|
||||
setSelectedModels((prev) => [...prev, model]);
|
||||
}, [selectedModels.length]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveModel = React.useCallback((index: number) => {
|
||||
setSelectedModels((prev) => prev.filter((_, i) => i !== index));
|
||||
@@ -529,7 +524,6 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
onUpdate={handleUpdateModel}
|
||||
minModels={1}
|
||||
addButtonLabel={t('agentManager.empty.models.addModel')}
|
||||
maxModels={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
interface CommitInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: () => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
hasTouchInput?: boolean;
|
||||
@@ -18,6 +19,7 @@ const MAX_HEIGHT = 200;
|
||||
export const CommitInput: React.FC<CommitInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
hasTouchInput = false,
|
||||
@@ -58,6 +60,12 @@ export const CommitInput: React.FC<CommitInputProps> = ({
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
onSubmit?.();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder ?? t('gitView.commit.messagePlaceholder')}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -68,6 +68,9 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
<CommitInput
|
||||
value={commitMessage}
|
||||
onChange={onCommitMessageChange}
|
||||
onSubmit={() => {
|
||||
if (canCommit && !isGeneratingMessage) onCommit();
|
||||
}}
|
||||
placeholder={t('gitView.commit.messagePlaceholder')}
|
||||
disabled={commitAction !== null}
|
||||
hasTouchInput={hasTouchInput}
|
||||
|
||||
@@ -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();
|
||||
@@ -508,8 +507,13 @@ export const PullRequestSection: React.FC<{
|
||||
}, [useDetectedUpstream, detectedUpstream?.defaultBranch]);
|
||||
|
||||
const pr = status?.pr ?? null;
|
||||
// A closed/merged PR is the branch's history, not its live status: it still
|
||||
// deserves to be shown (you just merged it), but the branch is free again, so
|
||||
// the panel offers creating the next PR instead of a read-only detail view.
|
||||
const isHistoricalPr = pr?.state === 'merged' || pr?.state === 'closed';
|
||||
const livePr = isHistoricalPr ? null : pr;
|
||||
|
||||
const prContextKey = pr ? getPrContextKey(directory, pr.number) : null;
|
||||
const prContextKey = livePr ? getPrContextKey(directory, livePr.number) : null;
|
||||
const prContextEntry = usePrContextStore((state) => (prContextKey ? state.entries[prContextKey] : undefined));
|
||||
const ensurePrContext = usePrContextStore((state) => state.ensure);
|
||||
const prContext = prContextEntry?.result ?? null;
|
||||
@@ -525,14 +529,14 @@ export const PullRequestSection: React.FC<{
|
||||
|
||||
// Load the context the active segment needs; checks include details.
|
||||
React.useEffect(() => {
|
||||
if (!pr || !github?.prContext || activeSegment === 'overview') {
|
||||
if (!livePr || !github?.prContext || activeSegment === 'overview') {
|
||||
return;
|
||||
}
|
||||
void ensurePrContext(github, directory, pr.number, {
|
||||
void ensurePrContext(github, directory, livePr.number, {
|
||||
includeCheckDetails: activeSegment === 'checks',
|
||||
sourceRepo: status?.repo ?? null,
|
||||
});
|
||||
}, [activeSegment, directory, ensurePrContext, github, pr, status?.repo]);
|
||||
}, [activeSegment, directory, ensurePrContext, github, livePr, status?.repo]);
|
||||
|
||||
const checks = status?.checks ?? null;
|
||||
const checksArePending = (checks?.pending ?? 0) > 0;
|
||||
@@ -981,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) {
|
||||
@@ -1016,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();
|
||||
@@ -1032,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);
|
||||
@@ -1167,31 +1168,29 @@ export const PullRequestSection: React.FC<{
|
||||
}, [remotes, status?.resolvedRemoteName]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isTerminal = status?.pr?.state === 'closed' || status?.pr?.state === 'merged';
|
||||
const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0;
|
||||
const isStale = Date.now() - lastRefreshAt > 60_000;
|
||||
const shouldRefresh = !isTerminal && isStale;
|
||||
|
||||
const onFocus = () => {
|
||||
if (shouldRefresh) {
|
||||
// Coming back to the app is the moment a PR is most likely to have changed
|
||||
// elsewhere — including a merged one being replaced by a newer open PR — so
|
||||
// staleness is read from the store when the event fires, not captured here.
|
||||
const refreshWhenStale = () => {
|
||||
const lastRefreshAt = useGitHubPrStatusStore.getState().entries[prStatusKey]?.lastRefreshAt ?? 0;
|
||||
if (Date.now() - lastRefreshAt > 60_000) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (shouldRefresh) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
refreshWhenStale();
|
||||
};
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('focus', refreshWhenStale);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('focus', refreshWhenStale);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [refresh, status?.pr?.state, statusEntry?.lastRefreshAt]);
|
||||
}, [prStatusKey, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||
@@ -1454,19 +1453,17 @@ export const PullRequestSection: React.FC<{
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{pr ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md border border-border/60 bg-background/70 hover:bg-interactive-hover/60"
|
||||
onClick={() => void openExternal(pr.url)}
|
||||
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
|
||||
>
|
||||
<Icon name={prStateIconName} className="size-4 shrink-0" style={{ color: prColorVar }} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>{t('gitView.pr.actions.openOnGitHub')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => void openExternal(pr.url)}
|
||||
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
|
||||
>
|
||||
<Icon name={prStateIconName} className="size-4 shrink-0" style={{ color: prColorVar }} />
|
||||
{t('gitView.pr.actions.openOnGitHub')}
|
||||
</Button>
|
||||
) : (
|
||||
<Icon name={prStateIconName} className="size-4 shrink-0" style={{ color: 'var(--surface-muted-foreground)' }} />
|
||||
)}
|
||||
@@ -1614,7 +1611,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
{t('gitView.pr.checkingStatus')}
|
||||
</div>
|
||||
) : pr ? (
|
||||
) : pr && !isHistoricalPr ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="h-8 min-w-0">
|
||||
<SortableTabsStrip
|
||||
@@ -1967,6 +1964,30 @@ export const PullRequestSection: React.FC<{
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{pr && isHistoricalPr ? (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 bg-surface-muted/40 px-2.5 py-2">
|
||||
<Icon
|
||||
name={pr.state === 'merged' ? 'git-merge' : 'git-close-pull-request'}
|
||||
className="size-4 shrink-0"
|
||||
style={{ color: prColorVar }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 typography-micro text-muted-foreground">
|
||||
{pr.state === 'merged'
|
||||
? t('gitView.pr.history.merged', { number: pr.number, base: pr.base || targetBaseBranch })
|
||||
: t('gitView.pr.history.closed', { number: pr.number })}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => void openExternal(pr.url)}
|
||||
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
|
||||
>
|
||||
<Icon name="external-link" className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground">{t('gitView.pr.createTitle')}</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Virtualizer } from '@pierre/diffs';
|
||||
|
||||
/**
|
||||
* Owns one pierre `Virtualizer` bound to a scrolling container.
|
||||
*
|
||||
* The instance is created on first render (the constructor does no DOM work)
|
||||
* so a `<PierreFile>` mounted inside a `VirtualizerContext.Provider` already
|
||||
* picks the virtualized path on mount. pierre queues `connect()` calls made
|
||||
* before `setup()` and flushes them once the real scroller element is bound.
|
||||
*
|
||||
* The scroller passed to `setScroller` must be the actual scrolling element:
|
||||
* pierre reads `scrollTop`/`scrollHeight`/client height and applies its scroll
|
||||
* fix on that element.
|
||||
*/
|
||||
export function useFileViewVirtualizer() {
|
||||
const [virtualizer] = useState(() => new Virtualizer());
|
||||
const setupRef = useRef(false);
|
||||
|
||||
const setScroller = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node == null) {
|
||||
// The scroller was removed (e.g. mobile tree/files toggle or exiting
|
||||
// fullscreen). pierre's setup() no-ops when a root is already bound,
|
||||
// so tear the binding down or the next mount would silently attach to
|
||||
// the stale element and the virtualized file would never update.
|
||||
virtualizer.cleanUp();
|
||||
setupRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (setupRef.current) return;
|
||||
setupRef.current = true;
|
||||
virtualizer.setup(node, node.firstElementChild ?? undefined);
|
||||
},
|
||||
[virtualizer],
|
||||
);
|
||||
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
setupRef.current = false;
|
||||
virtualizer.cleanUp();
|
||||
},
|
||||
[virtualizer],
|
||||
);
|
||||
|
||||
return { virtualizer, setScroller };
|
||||
}
|
||||
|
||||
export type FileViewVirtualizer = ReturnType<typeof useFileViewVirtualizer>;
|
||||
Reference in New Issue
Block a user