返回 DeepSeek-Reasonix
WorkspacePanel.tsx
根目录 / desktop / frontend / src / components / WorkspacePanel.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { isAbsoluteDisplayPath, formatWorkspaceSource } from "../lib/workspacePanelFormat";
3 import type { WorkspaceChangeRevealRequest, WorkspaceVerificationRevealRequest, WorkspaceFileListRequest, WorkspaceChangeListRequest, WorkspaceChangeListEntry } from "../lib/dockDelivery";
4 import type { FileResourceRef } from "../lib/fileResource";
5 import { fileNavigationOwner } from "../lib/fileNavigationCommands";
6 import type { FileNavigationOwner } from "../lib/fileNavigationOwner";
7 import { useFileNavigationRecord } from "../app-shell/useFileNavigation";
8 import { useWorkspaceFilePreview } from "./useWorkspaceFilePreview";
9 export type { WorkspaceVerificationRevealRequest } from "../lib/dockDelivery";
10 import { useVirtualizer } from "@tanstack/react-virtual";
11 import type {
12 CSSProperties,
13 DragEvent as ReactDragEvent,
14 KeyboardEvent,
15 MouseEvent as ReactMouseEvent,
16 PointerEvent as ReactPointerEvent,
17 } from "react";
18 import {
19 ChevronDown,
20 ChevronRight,
21 Code2,
22 Eye,
23 FileText,
24 FolderTree,
25 FolderX,
26 GitBranch,
27 Maximize2,
28 MessageSquarePlus,
29 Minimize2,
30 RefreshCw,
31 Search,
32 X,
33 } from "lucide-react";
34 import { asArray } from "../lib/array";
35 import { app } from "../lib/bridge";
36 import { useT } from "../lib/i18n";
37 import {
38 clampWorkspaceTreeWidth,
39 WORKSPACE_TREE_MIN_WIDTH, WORKSPACE_TREE_DEFAULT_WIDTH, WORKSPACE_PREVIEW_MIN_WIDTH,
40 WORKSPACE_DUAL_PANEL_TARGET_WIDTH,
41 initialWorkspaceSplitTreeWidth,
42 resolveWorkspaceSplitTreeWidth,
43 shouldInitializeWorkspaceSplitOnFileSelect,
44 type WorkspaceSplitTreeWidthMode,
45 workspaceSplitCanFit,
46 workspaceSplitTreeWidthFromPointer,
47 } from "../lib/workspaceSplit";
48 import { createRafResizeUpdater } from "../lib/resizeDrag";
49 import { useWorkspaceRefresh } from "../lib/workspaceRefreshStore";
50 import { useWorkspaceRefreshInvalidation, workspaceRefreshFallbackSequence } from "../lib/workspaceRefreshInvalidation";
51 import { createWorkspaceRefreshScheduler } from "../lib/workspaceRefreshScheduler";
52 import {
53 beginKeyedResourceRequest,
54 emptyKeyedResource,
55 rejectKeyedResourceRequest,
56 resolveKeyedResourceRequest,
57 } from "../lib/keyedResource";
58 import { shouldScrollWorkspaceTreeSelection } from "../lib/workspaceTreeReveal";
59 import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch";
60 import { useWorkspaceTreeScrollPersistence } from "../lib/useWorkspaceTreeScrollPersistence";
61 import {
62 readWorkspaceTreeMemory,
63 rememberWorkspaceTreeOpenDirs,
64 rememberWorkspaceTreeState,
65 touchWorkspaceTreeVisit,
66 workspaceTreeVisitId,
67 } from "../lib/workspaceViewMemory";
68 import { loadLayoutSize, loadOptionalLayoutSize } from "../lib/layoutPreferences";
69 import {
70 RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH,
71 defaultRightDockTreeWidth,
72 } from "../store/layout";
73 import type {
74 DirEntry,
75 GitCommitView,
76 GitCommitDetailView,
77 RewindResultView,
78 WorkspaceChangeDetailView,
79 WireCompletionSummary,
80 } from "../lib/types";
81 import { workspaceGitStatusLabel } from "../lib/workspaceChanges";
82 import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
83 import { formatSelectionReference, languageFor } from "../lib/selectedTextContext";
84 import { cleanGitDiff } from "../lib/diff";
85 import { CodeViewer } from "./CodeViewer";
86 import { DiffView } from "./DiffView";
87 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
88 import { FloatingMenu, FloatingMenuItems } from "./FloatingMenu";
89 import { Markdown } from "./Markdown";
90 import { Tooltip } from "./Tooltip";
91 import { AnchoredPopover } from "./AnchoredPopover";
92 import { MarkdownImageTabContext } from "./MarkdownImageContext";
93 import { WorkspaceMediaPreview } from "./WorkspaceMediaPreview";
94 import { WorkspaceCsvPreview } from "./WorkspaceCsvPreview";
95 import { buildWorkspacePathBreadcrumbs, WorkspacePathBreadcrumbs } from "./WorkspacePathBreadcrumbs";
96 import { WorkspaceTreeRow, type WorkspaceTreeRowData } from "./WorkspaceTreeRow";
97 import { WorkspaceTreeMenu } from "./WorkspaceTreeMenu";
98 import { WORKSPACE_TURN_VERIFICATION_ID } from "./WorkspaceTurnVerification";
99 import { WorkspaceTurnResult } from "./WorkspaceTurnResult";
100 import { useWorkspaceChangesResource } from "../lib/useWorkspaceChangesResource";
101 import {
102 workspaceBasename as basename, workspaceEntryPath as entryPath,
103 workspaceFormatCommitDate as formatCommitDate,
104 workspaceParentDirs as parentDirs, workspaceParentPath as parentPath,
105 workspaceTopLevelDirPath as topLevelDirPath,
106 } from "../lib/workspacePanelFormat";
107
108 const WORKSPACE_CONTEXT_MENU_SELECTION_HEIGHT = 48;
109 const WORKSPACE_MAX_PREVIEW_TABS = 5;
110
111 export { WORKSPACE_TURN_VERIFICATION_ID } from "./WorkspaceTurnVerification";
112
113 export function WorkspacePanel({
114 open,
115 tabId,
116 cwd,
117 maximized,
118 panelWidth,
119 onClose,
120 onToggleMaximized,
121 onPreviewModeChange,
122 onAddToChat,
123 onAddCodeToChat,
124 onOpenInTerminal,
125 onRequestPanelWidth,
126 onFileTreeRefresh,
127 onSessionRevertCommitted,
128 initialViewMode = "files",
129 navigationSignal,
130 dockTabId,
131 fileNavigation: fileNavigationProp,
132 changeRevealRequest,
133 verificationRevealRequest,
134 sessionPath,
135 onDismissTurnResult,
136 fileListRequest,
137 changeListRequest,
138 showViewTabs = true,
139 workspaceScopeKey: workspaceScopeKeyProp,
140 workspaceMemoryKey: workspaceMemoryKeyProp,
141 workspaceMemoryVisitId: workspaceMemoryVisitIdProp,
142 dockTreeWidth,
143 dockPreviewWidth,
144 onRestoreDockWidths,
145 completionSummary,
146 turnStartAt = 0,
147 }: {
148 open: boolean;
149 tabId?: string;
150 cwd?: string;
151 maximized: boolean;
152 panelWidth?: number;
153 onClose: () => void;
154 onToggleMaximized: () => void;
155 onPreviewModeChange?: (active: boolean) => void;
156 onAddToChat?: (text: string) => void;
157 onAddCodeToChat?: (path: string, code: string) => void;
158 onOpenInTerminal?: (path: string) => void;
159 onRequestPanelWidth?: (width: number) => void;
160 onFileTreeRefresh?: () => void;
161 onSessionRevertCommitted?: (tabId: string, result: RewindResultView) => void;
162 initialViewMode?: "files" | "changed";
163 navigationSignal?: AbortSignal;
164 /** The dock instance this panel renders; its navigation record lives outside. */
165 dockTabId?: string;
166 fileNavigation?: FileNavigationOwner;
167 changeRevealRequest?: WorkspaceChangeRevealRequest | null;
168 verificationRevealRequest?: WorkspaceVerificationRevealRequest | null;
169 sessionPath?: string;
170 onDismissTurnResult?: () => void;
171 fileListRequest?: WorkspaceFileListRequest | null;
172 changeListRequest?: WorkspaceChangeListRequest | null;
173 showViewTabs?: boolean;
174 workspaceScopeKey?: string;
175 workspaceMemoryKey?: string;
176 workspaceMemoryVisitId?: number;
177 dockTreeWidth?: number;
178 dockPreviewWidth?: number;
179 onRestoreDockWidths?: (treeWidth: number, previewWidth: number) => void;
180 completionSummary?: WireCompletionSummary;
181 turnStartAt?: number;
182 }) {
183 const t = useT();
184 const workspaceTabId = tabId ?? "";
185 const activeVerificationRevealRequest = verificationRevealRequest?.tabId === workspaceTabId
186 && verificationRevealRequest.turnStartAt === turnStartAt
187 && verificationRevealRequest.sessionPath === sessionPath ? verificationRevealRequest : null;
188 const requestedSummary = activeVerificationRevealRequest?.summary;
189 const visibleCompletionSummary = requestedSummary?.turnId && requestedSummary.turnId === completionSummary?.turnId ? completionSummary : requestedSummary ?? completionSummary;
190 const workspaceScopeKey = workspaceScopeKeyProp ?? `${workspaceTabId}\u0000${cwd ?? ""}`;
191 const lastWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
192 const scopeSwitchPendingRef = useRef(false);
193 const workspaceMemoryKey = workspaceMemoryKeyProp ?? workspaceScopeKey;
194 const workspaceMemoryVisitId = workspaceMemoryVisitIdProp ?? workspaceTreeVisitId(workspaceMemoryKey);
195 // A standalone panel (a test or a fixture) keeps one instance for its whole
196 // life; the runtime passes the instance it holds, so this fallback never runs
197 // under the app shell.
198 const [fallbackFileNavigation] = useState(fileNavigationOwner);
199 const fileNavigation = fileNavigationProp ?? fallbackFileNavigation;
200 const fileScope = useMemo(
201 () => ({ sessionTabId: workspaceTabId, dockTabId: dockTabId ?? "" }),
202 [dockTabId, workspaceTabId],
203 );
204 // The project is the resource space these paths belong to; the session scope
205 // only decides the credentials. Another session in the same project keeps the
206 // previews on screen without carrying the presented tool scope across.
207 const fileKey = useMemo(
208 () => ({ resource: workspaceMemoryKey, session: workspaceScopeKey }),
209 [workspaceMemoryKey, workspaceScopeKey],
210 );
211 const fileRecord = useFileNavigationRecord(fileNavigation, fileScope, fileKey);
212 const workspaceRefresh = useWorkspaceRefresh(workspaceTabId, workspaceScopeKey, open);
213 const initialWorkspaceMemory = readWorkspaceTreeMemory(workspaceMemoryKey);
214 const legacyTreeWidth = loadOptionalLayoutSize("workspaceTreeWidth");
215 const panelRef = useRef<HTMLElement>(null);
216 const treeRef = useRef<HTMLDivElement>(null);
217 const onWorkspaceTreeScroll = useWorkspaceTreeScrollPersistence({ memoryKey: workspaceMemoryKey, open, scrollRef: treeRef });
218 const filterRef = useRef<HTMLInputElement>(null);
219 const previewBodyRef = useRef<HTMLDivElement>(null);
220 const [entriesByDir, setEntriesByDir] = useState<Record<string, DirEntry[]>>({});
221 const [openDirs, setOpenDirs] = useState<Set<string>>(
222 () => new Set(initialWorkspaceMemory?.openDirs ?? [""]),
223 );
224 const [revealedRootPaths, setRevealedRootPaths] = useState<Set<string> | null>(
225 () => initialWorkspaceMemory && initialWorkspaceMemory.visitId !== workspaceMemoryVisitId ? new Set() : null,
226 );
227 // The working-tree diff selection stays with the change requests; the file
228 // preview's selection, tab list and source modes come from the dock's
229 // navigation record, so they survive a remount without replaying a command.
230 const [selectedChangePath, setSelectedChangePath] = useState<string | null>(
231 () => initialWorkspaceMemory?.selectedChangePath ?? null,
232 );
233 // Independent "recently opened" history: survives closing all preview tabs
234 // (openTabs is the live preview state) and app restarts, so the recent-files
235 // menu keeps the user's file history even after the previews are dismissed.
236 const [recentPaths, setRecentPaths] = useState<string[]>(() =>
237 (initialWorkspaceMemory?.recentPaths ?? []).slice(0, WORKSPACE_MAX_PREVIEW_TABS),
238 );
239 const [presentedTextTail, setPresentedTextTail] = useState<{
240 key: string;
241 version: string;
242 body: string;
243 nextOffset: number;
244 hasMore: boolean;
245 loading: boolean;
246 error?: string;
247 } | null>(null);
248 const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
249 const openTabs = useMemo(
250 () => fileRecord?.entries.map((entry) => entry.resource.path) ?? [],
251 [fileRecord],
252 );
253 const selectedEntry = viewMode === "changed" ? null : fileRecord?.selected ?? null;
254 const selectedGeneration = fileRecord?.generation ?? 0;
255 const selectedFilePath = selectedEntry?.resource.path ?? null;
256 const selectedPath = viewMode === "changed" ? selectedChangePath : selectedFilePath;
257 // Change history starts collapsed; the changed view keeps it expanded.
258 const groupedChangesLayout = viewMode === "changed";
259 const [gitHistoryResource, setGitHistoryResource] = useState(() => emptyKeyedResource<GitCommitView[]>());
260 const [changeDetailResource, setChangeDetailResource] = useState(() => emptyKeyedResource<WorkspaceChangeDetailView>());
261 const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
262 const [commitDetail, setCommitDetail] = useState<GitCommitDetailView | null>(null);
263 const [loadingCommit, setLoadingCommit] = useState(false);
264 const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string; path: string } | null>(null);
265 const [treeMenu, setTreeMenu] = useState<{ x: number; y: number; path: string; isDir: boolean } | null>(null);
266 const [treeBlankMenuPoint, setTreeBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
267 const [filter, setFilter] = useState(() => initialWorkspaceMemory?.filter ?? "");
268 const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
269 const [scopedFilePaths, setScopedFilePaths] = useState<string[] | null>(null);
270 const [scopedChangeRows, setScopedChangeRows] = useState<WorkspaceChangeListEntry[] | null>(null);
271 const [treeVisible, setTreeVisible] = useState(true);
272 const [treeWidth, setTreeWidth] = useState(initialWorkspaceMemory?.treeWidth ?? legacyTreeWidth ?? WORKSPACE_TREE_DEFAULT_WIDTH);
273 const [treeWidthMode, setTreeWidthMode] = useState<WorkspaceSplitTreeWidthMode>(
274 initialWorkspaceMemory?.treeWidthMode ?? "manual",
275 );
276 const [treeResizing, setTreeResizing] = useState(false);
277 const [recentOpen, setRecentOpen] = useState(false);
278 const [codeSearchRequestPending, setCodeSearchRequestPending] = useState(false);
279 const [codeSearchRequestPath, setCodeSearchRequestPath] = useState<string | null>(null);
280 /** Changes overview: commit history is secondary and starts collapsed. */
281 const [commitHistoryOpen, setCommitHistoryOpen] = useState(false);
282 const lastPreviewModeActiveRef = useRef<boolean | null>(null);
283 // A mount reads the record's last navigation without replaying it: the
284 // selection already comes from the record, and re-applying would re-run the
285 // command's side effects (clearing the filter, dismissing a scope) that the
286 // user did not ask for now.
287 const appliedNavigationRevisionRef = useRef<number | null>(fileRecord?.navigation?.revision ?? null);
288 const lastChangeRevealRequestIdRef = useRef<number | null>(null);
289 const lastVerificationRevealRequestIdRef = useRef<number | null>(null);
290 const verificationSummaryRef = useRef<HTMLElement | null>(null);
291 const dismissedChangeRevealRequestIdRef = useRef<number | null>(null);
292 const lastFileListRequestIdRef = useRef<number | null>(null);
293 const dismissedFileListRequestIdRef = useRef<number | null>(null);
294 const lastChangeListRequestIdRef = useRef<number | null>(null);
295 const dismissedChangeListRequestIdRef = useRef<number | null>(null);
296 const currentWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
297 const currentFileGenerationRef = useRef(0);
298 const changeDetailRequestIdRef = useRef(0);
299 const gitHistoryRequestIdRef = useRef(0);
300 const previewRequestIdRef = useRef(0);
301 const textPageRequestIdRef = useRef(0);
302 const commitDetailRequestIdRef = useRef(0);
303 const dirLoadGenerationRef = useRef(0);
304 const fileLifetime = fileRecord?.signal;
305 const adoptedFileLifetimeRef = useRef<AbortSignal | null>(null);
306 const adoptedDockLifetimeRef = useRef<AbortSignal | null>(null);
307 useEffect(() => {
308 const invalidate = () => {
309 dirLoadGenerationRef.current++; previewRequestIdRef.current++; textPageRequestIdRef.current++;
310 changeDetailRequestIdRef.current++; gitHistoryRequestIdRef.current++; commitDetailRequestIdRef.current++;
311 };
312 // A lifetime that ended or was replaced while still live invalidates the
313 // work it started: the dock occurrence closing, or this dock instance's
314 // navigation record going away. Adopting the first lifetime is not that —
315 // on mount it arrives after the directory load it would otherwise discard.
316 const replaced = (previous: AbortSignal | null, next: AbortSignal | undefined) =>
317 previous !== null && previous !== next && !previous.aborted;
318 if (replaced(adoptedFileLifetimeRef.current, fileLifetime)
319 || replaced(adoptedDockLifetimeRef.current, navigationSignal)) invalidate();
320 adoptedFileLifetimeRef.current = fileLifetime ?? null;
321 adoptedDockLifetimeRef.current = navigationSignal ?? null;
322 navigationSignal?.addEventListener("abort", invalidate);
323 fileLifetime?.addEventListener("abort", invalidate);
324 return () => {
325 navigationSignal?.removeEventListener("abort", invalidate);
326 fileLifetime?.removeEventListener("abort", invalidate);
327 };
328 }, [fileLifetime, navigationSignal]);
329 const dirLoadRequestIdsRef = useRef<Record<string, number>>({});
330 const compactProbeInFlightRef = useRef(new Set<string>());
331 const recentAnchorRef = useRef<HTMLButtonElement>(null);
332 const openDirsRef = useRef(openDirs);
333 const pendingTreeRevealPathRef = useRef<string | null>(null);
334 const lastRestoredMemoryKeyRef = useRef(workspaceMemoryKey);
335 const memoryRestorePendingRef = useRef(false);
336 const workingTreeRefreshSchedulerRef = useRef<ReturnType<typeof createWorkspaceRefreshScheduler> | null>(null);
337 const gitMetaRefreshSchedulerRef = useRef<ReturnType<typeof createWorkspaceRefreshScheduler> | null>(null);
338 if (!workingTreeRefreshSchedulerRef.current) {
339 workingTreeRefreshSchedulerRef.current = createWorkspaceRefreshScheduler(300);
340 }
341 if (!gitMetaRefreshSchedulerRef.current) {
342 gitMetaRefreshSchedulerRef.current = createWorkspaceRefreshScheduler(750);
343 }
344 currentWorkspaceScopeKeyRef.current = workspaceScopeKey;
345 currentFileGenerationRef.current = selectedGeneration;
346 // Every read takes its credentials from the entry the command committed, so a
347 // path reopened from another entry point never inherits an earlier tool scope.
348 const selectedPresentedToolCallId = selectedEntry?.resource.access.source === "presented"
349 ? selectedEntry.resource.access.toolCallId
350 : undefined;
351 const selectedIsReference = selectedEntry?.resource.access.source === "reference";
352 const sourceOverride = selectedEntry?.source ?? false;
353 const {
354 previewKey, preview, loadingPreview, previewErr, presentedFileStale,
355 setPresentedFileStale, refreshSelected, resetPreview,
356 } = useWorkspaceFilePreview({
357 open,
358 selectedPath,
359 accessSource: selectedEntry?.resource.access.source ?? "workspace",
360 presentedToolCallId: selectedPresentedToolCallId,
361 source: sourceOverride,
362 generation: selectedGeneration,
363 workspaceScopeKey,
364 workspaceTabId,
365 contentRevision: workspaceRefresh.revisions.content,
366 });
367 const changeDetailKey = selectedPath ? `${workspaceScopeKey}\u0000change\u0000${selectedPath}` : null;
368 const gitHistoryKey = `${workspaceScopeKey}\u0000history\u0000${selectedPath ?? ""}`;
369 const activePresentedTextTail = previewKey && preview?.version && presentedTextTail?.key === previewKey && presentedTextTail.version === preview.version
370 ? presentedTextTail
371 : null;
372 const previewBody = preview ? preview.body + (activePresentedTextTail?.body ?? "") : "";
373 const presentedTextPaginationAvailable = Boolean(
374 selectedPresentedToolCallId && preview?.version && (activePresentedTextTail?.nextOffset ?? preview?.nextOffset ?? 0) > 0,
375 );
376
377 useEffect(() => {
378 const resourceURL = preview?.url;
379 return () => {
380 if (resourceURL) void app.RevokeWorkspaceMediaPreview(resourceURL);
381 };
382 }, [preview?.url]);
383
384 useEffect(() => {
385 textPageRequestIdRef.current++;
386 setPresentedTextTail(null);
387 }, [previewKey, preview?.version]);
388 const changeDetail = changeDetailKey && changeDetailResource.key === changeDetailKey ? changeDetailResource.data : null;
389 const loadingChangeDetail = changeDetailKey != null && changeDetailResource.key === changeDetailKey && changeDetailResource.status === "refreshing";
390 const changeDetailErr = changeDetailKey && changeDetailResource.key === changeDetailKey ? changeDetailResource.error : "";
391 const gitHistory = gitHistoryResource.key === gitHistoryKey ? gitHistoryResource.data ?? [] : [];
392 const loadingHistory = gitHistoryResource.key === gitHistoryKey && gitHistoryResource.status === "refreshing";
393 const gitHistoryErr = gitHistoryResource.key === gitHistoryKey ? gitHistoryResource.error : "";
394 const { workspaceChanges, loadingWorkspaceChanges, workspaceChangesErr, loadWorkspaceChanges, resetWorkspaceChanges } =
395 useWorkspaceChangesResource(workspaceTabId, workspaceScopeKey, workspaceRefresh.revisions.workingTree);
396 const overviewResourceStatus = workspaceChangesErr && workspaceChanges
397 ? { error: true, text: `${t("workspace.changesUnavailable")}: ${workspaceChangesErr}` }
398 : gitHistoryErr && gitHistory.length > 0
399 ? { error: true, text: `${t("workspace.historyUnavailable")}: ${gitHistoryErr}` }
400 : loadingWorkspaceChanges && workspaceChanges
401 ? { error: false, text: t("workspace.loadingChanges") }
402 : loadingHistory && gitHistory.length > 0 ? { error: false, text: t("workspace.loading") } : null;
403
404 useEffect(() => {
405 openDirsRef.current = openDirs;
406 }, [openDirs]);
407
408 const updateOpenDirs = useCallback(
409 (update: (previous: ReadonlySet<string>) => Set<string>) => {
410 setOpenDirs((previous) => {
411 const next = update(previous);
412 rememberWorkspaceTreeOpenDirs(workspaceMemoryKey, next, workspaceMemoryVisitId);
413 return next;
414 });
415 },
416 [workspaceMemoryKey, workspaceMemoryVisitId],
417 );
418
419 useEffect(() => {
420 memoryRestorePendingRef.current = true;
421 const remembered = readWorkspaceTreeMemory(workspaceMemoryKey);
422 const nextOpenDirs = new Set(remembered?.openDirs ?? [""]);
423 setOpenDirs(nextOpenDirs);
424 openDirsRef.current = nextOpenDirs;
425 setRevealedRootPaths(remembered && remembered.visitId !== workspaceMemoryVisitId ? new Set() : null);
426 if (remembered) touchWorkspaceTreeVisit(workspaceMemoryKey, workspaceMemoryVisitId);
427 else rememberWorkspaceTreeOpenDirs(workspaceMemoryKey, nextOpenDirs, workspaceMemoryVisitId);
428 onRestoreDockWidths?.(
429 remembered?.dockTreeWidth ?? loadLayoutSize(
430 "rightDockTreeWidth",
431 defaultRightDockTreeWidth(),
432 ),
433 remembered?.dockPreviewWidth ?? loadLayoutSize("rightDockPreviewWidth", RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH),
434 );
435 if (lastRestoredMemoryKeyRef.current !== workspaceMemoryKey) {
436 lastRestoredMemoryKeyRef.current = workspaceMemoryKey;
437 // The remembered file selection is restored into the dock's record, which
438 // owns it; only the change surface's own selection is local state here.
439 setSelectedChangePath(remembered?.selectedChangePath ?? null);
440 setTreeWidth(remembered?.treeWidth ?? legacyTreeWidth ?? WORKSPACE_TREE_DEFAULT_WIDTH);
441 setTreeWidthMode(remembered?.treeWidthMode ?? "manual");
442 requestAnimationFrame(() => {
443 const tree = treeRef.current;
444 if (tree) tree.scrollTop = remembered?.scrollTop ?? 0;
445 });
446 }
447 }, [legacyTreeWidth, onRestoreDockWidths, workspaceMemoryKey, workspaceMemoryVisitId]);
448
449 useEffect(() => {
450 if (memoryRestorePendingRef.current) return;
451 rememberWorkspaceTreeState(workspaceMemoryKey, { selectedFilePath, selectedChangePath, openTabs, filter });
452 }, [selectedChangePath, selectedFilePath, openTabs, filter, workspaceMemoryKey]);
453
454 useEffect(() => {
455 if (memoryRestorePendingRef.current) return;
456 rememberWorkspaceTreeState(workspaceMemoryKey, { treeWidth, treeWidthMode });
457 }, [treeWidth, treeWidthMode, workspaceMemoryKey]);
458
459 useEffect(() => {
460 if (memoryRestorePendingRef.current) return;
461 rememberWorkspaceTreeState(workspaceMemoryKey, { recentPaths });
462 }, [recentPaths, workspaceMemoryKey]);
463
464 useEffect(() => {
465 if (memoryRestorePendingRef.current || dockTreeWidth == null || dockPreviewWidth == null) return;
466 rememberWorkspaceTreeState(workspaceMemoryKey, { dockTreeWidth, dockPreviewWidth });
467 }, [dockPreviewWidth, dockTreeWidth, workspaceMemoryKey]);
468
469 useEffect(() => {
470 memoryRestorePendingRef.current = false;
471 });
472
473 const loadDir = useCallback(async (dir: string) => {
474 const requestTabId = workspaceTabId;
475 const requestScopeKey = workspaceScopeKey;
476 const generation = dirLoadGenerationRef.current;
477 const requestId = (dirLoadRequestIdsRef.current[dir] ?? 0) + 1;
478 dirLoadRequestIdsRef.current[dir] = requestId;
479 const entries = asArray(await app.ListDirForTab(requestTabId, dir).catch((): DirEntry[] => []));
480 if (
481 currentWorkspaceScopeKeyRef.current !== requestScopeKey ||
482 dirLoadGenerationRef.current !== generation ||
483 dirLoadRequestIdsRef.current[dir] !== requestId
484 ) return null;
485 setEntriesByDir((prev) => ({ ...prev, [dir]: entries }));
486 return entries;
487 }, [workspaceScopeKey, workspaceTabId]);
488
489 const loadGitHistory = useCallback(async () => {
490 const requestId = ++gitHistoryRequestIdRef.current;
491 const requestTabId = workspaceTabId;
492 const requestScopeKey = workspaceScopeKey;
493 const requestKey = `${requestScopeKey}\u0000history\u0000${selectedPath ?? ""}`;
494 setGitHistoryResource((current) => beginKeyedResourceRequest(current, requestKey, requestId, workspaceRefresh.revisions.gitMeta));
495 try {
496 const result = await app.WorkspaceGitHistory(requestTabId, selectedPath || "");
497 if (gitHistoryRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
498 setGitHistoryResource((current) => resolveKeyedResourceRequest(current, requestKey, requestId, result || [], workspaceRefresh.revisions.gitMeta));
499 }
500 } catch (err) {
501 if (gitHistoryRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
502 setGitHistoryResource((current) => rejectKeyedResourceRequest(current, requestKey, requestId, String((err as { message?: unknown })?.message ?? err)));
503 }
504 }
505 }, [selectedPath, workspaceRefresh.revisions.gitMeta, workspaceScopeKey, workspaceTabId]);
506
507 const loadChangeDetail = useCallback(async () => {
508 const requestId = ++changeDetailRequestIdRef.current;
509 const requestTabId = workspaceTabId;
510 const requestScopeKey = workspaceScopeKey;
511 const requestPath = selectedPath;
512 if (!requestPath) {
513 setChangeDetailResource(emptyKeyedResource());
514 return;
515 }
516 // Scope switch must not request the previous scope's selected change:
517 // the selection is preserved for the original scope, but a request must
518 // not fire for the just-switched scope before the scope effect settles.
519 if (scopeSwitchPendingRef.current) return;
520 const requestKey = `${requestScopeKey}\u0000change\u0000${requestPath}`;
521 setChangeDetailResource((current) => beginKeyedResourceRequest(current, requestKey, requestId, workspaceRefresh.revisions.content));
522 try {
523 const detail = await app.WorkspaceChangeDetail(requestTabId, requestPath);
524 if (changeDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
525 setChangeDetailResource((current) => resolveKeyedResourceRequest(current, requestKey, requestId, detail ?? {}, workspaceRefresh.revisions.content));
526 }
527 } catch (err) {
528 if (changeDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
529 setChangeDetailResource((current) => rejectKeyedResourceRequest(current, requestKey, requestId, String((err as { message?: unknown })?.message ?? err)));
530 }
531 }
532 }, [selectedPath, workspaceRefresh.revisions.content, workspaceScopeKey, workspaceTabId]);
533
534 const toggleCommit = useCallback((hash: string) => {
535 setExpandedCommit((prev) => {
536 const next = prev === hash ? null : hash;
537 if (next) onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
538 return next;
539 });
540 }, [onRequestPanelWidth]);
541
542 useEffect(() => {
543 if (!open) return;
544 if (expandedCommit) {
545 const requestId = ++commitDetailRequestIdRef.current;
546 const requestTabId = workspaceTabId;
547 const requestScopeKey = workspaceScopeKey;
548 let live = true;
549 setLoadingCommit(true);
550 app
551 .WorkspaceGitCommitDetail(requestTabId, expandedCommit, selectedPath || "")
552 .then((detail) => {
553 if (live && commitDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
554 setCommitDetail(detail);
555 }
556 })
557 .catch(() => {
558 if (live && commitDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
559 setCommitDetail(null);
560 }
561 })
562 .finally(() => {
563 if (live && commitDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
564 setLoadingCommit(false);
565 }
566 });
567 return () => {
568 live = false;
569 };
570 } else {
571 commitDetailRequestIdRef.current += 1;
572 setCommitDetail(null);
573 }
574 }, [expandedCommit, selectedPath, open, workspaceScopeKey, workspaceTabId]);
575
576 /**
577 * Local navigation is a command like any other: the dock's record is
578 * committed outside React and this panel renders the result. What the command
579 * implies for local view state is applied by the navigation effect below, so
580 * a command from the transcript and a click in the tree land identically.
581 */
582 const openFile = useCallback(
583 (path: string, view: "files" | "changed" = "files") => {
584 const ref: FileResourceRef = { source: "workspace", hostId: "local", tabId: workspaceTabId, path };
585 // A click inside this panel navigates this panel: it is already the target
586 // dock, so it must not ask the activity bar which dock to open.
587 void Promise.resolve(fileNavigation.openIn(fileScope, { ref, params: { action: "preview", view } }));
588 },
589 [fileNavigation, fileScope, workspaceTabId],
590 );
591
592 useEffect(() => {
593 if (!open) return;
594 dirLoadGenerationRef.current += 1;
595 dirLoadRequestIdsRef.current = {};
596 compactProbeInFlightRef.current.clear();
597 setEntriesByDir({});
598 // Remembered paths come back as plain workspace resources: a persisted path
599 // never restores the presented access an earlier session read it with.
600 const remembered = readWorkspaceTreeMemory(workspaceMemoryKey);
601 void Promise.resolve(fileNavigation.restore(fileScope, {
602 paths: remembered?.openTabs ?? [],
603 selectedPath: remembered?.selectedFilePath ?? null,
604 hostId: "local",
605 }));
606 // The preview is left alone: its key carries the scope, path, mode and
607 // access context, so a preview for anything else cannot render anyway, and
608 // clearing it here would erase the read another effect just started when
609 // this effect reconnects.
610 setGitHistoryResource(emptyKeyedResource());
611 changeDetailRequestIdRef.current += 1;
612 setChangeDetailResource(emptyKeyedResource());
613 setExpandedCommit(null);
614 setCommitDetail(null);
615 setSelectionMenu(null);
616 setTreeMenu(null);
617 setFilter(remembered?.filter ?? "");
618 setScopedFilePaths(null);
619 setScopedChangeRows(null);
620 setTreeVisible(true);
621 void loadDir("");
622 }, [cwd, fileNavigation, fileScope, loadDir, open, workspaceMemoryKey]);
623
624 // What the preview area showed when the previous navigation was applied;
625 // only a first preview initializes the split, and the record already carries
626 // the new selection by the time this effect runs.
627 const previewShown = openTabs.length > 0 || selectedPath !== null;
628 const previewVisibleAtLastApplyRef = useRef(false);
629 const navigationIntent = fileRecord?.navigation ?? null;
630 useEffect(() => {
631 if (!open || !navigationIntent) return;
632 if (appliedNavigationRevisionRef.current === navigationIntent.revision) return;
633 appliedNavigationRevisionRef.current = navigationIntent.revision;
634 const path = navigationIntent.resource.path;
635 if (shouldInitializeWorkspaceSplitOnFileSelect({
636 previewVisible: previewVisibleAtLastApplyRef.current,
637 treeVisible,
638 })) {
639 setTreeWidth(initialWorkspaceSplitTreeWidth({
640 panelWidth,
641 // Preserve a user-resized (manual) tree width: only first-time splits
642 // (no saved width yet) default to an even 50/50 division. Reopening a
643 // file after closing its preview keeps the remembered width.
644 savedTreeWidth: treeWidthMode === "manual" ? treeWidth : null,
645 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
646 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
647 }));
648 setTreeWidthMode("even");
649 }
650 pendingTreeRevealPathRef.current = path;
651 if (navigationIntent.params.view === "changed") setSelectedChangePath(path);
652 else if (navigationIntent.params.action === "reveal-tree") setTreeVisible(true);
653 setViewMode(navigationIntent.params.view);
654 // Picking a file dismisses a scoped list for good: the request that opened
655 // it must not re-apply while the user reads the file it pointed at.
656 setScopedFilePaths((current) => {
657 if (current) dismissedFileListRequestIdRef.current = lastFileListRequestIdRef.current;
658 return null;
659 });
660 setScopedChangeRows((current) => {
661 if (current) dismissedChangeListRequestIdRef.current = lastChangeListRequestIdRef.current;
662 return null;
663 });
664 setFilter("");
665 setRecentPaths((paths) => [...paths.filter((entry) => entry !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
666 const dirs = isAbsoluteDisplayPath(path) ? [] : parentDirs(path);
667 updateOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
668 dirs.forEach((dir) => void loadDir(dir));
669 if (navigationIntent.resource.access.source !== "workspace" && isAbsoluteDisplayPath(path)) setScopedFilePaths([path]);
670 }, [loadDir, navigationIntent, open, panelWidth, treeVisible, treeWidth, treeWidthMode, updateOpenDirs]);
671 useEffect(() => {
672 previewVisibleAtLastApplyRef.current = previewShown;
673 });
674
675 useEffect(() => {
676 if (!open) return;
677 if (lastWorkspaceScopeKeyRef.current === workspaceScopeKey) return;
678 lastWorkspaceScopeKeyRef.current = workspaceScopeKey;
679 scopeSwitchPendingRef.current = true;
680 workingTreeRefreshSchedulerRef.current?.cancel();
681 gitMetaRefreshSchedulerRef.current?.cancel();
682 changeDetailRequestIdRef.current += 1;
683 gitHistoryRequestIdRef.current += 1;
684 commitDetailRequestIdRef.current += 1;
685 resetWorkspaceChanges();
686 setChangeDetailResource(emptyKeyedResource());
687 setGitHistoryResource(emptyKeyedResource());
688 setCommitHistoryOpen(false);
689 setExpandedCommit(null);
690 setCommitDetail(null);
691 setScopedChangeRows(null);
692 lastChangeRevealRequestIdRef.current = null;
693 dismissedChangeRevealRequestIdRef.current = null;
694 lastChangeListRequestIdRef.current = null;
695 dismissedChangeListRequestIdRef.current = null;
696 if (viewMode === "changed") {
697 fileNavigation.clearEntries(fileScope);
698 resetPreview();
699 }
700 }, [fileNavigation, fileScope, open, resetWorkspaceChanges, viewMode, workspaceScopeKey]);
701
702 useEffect(() => () => {
703 workingTreeRefreshSchedulerRef.current?.cancel();
704 gitMetaRefreshSchedulerRef.current?.cancel();
705 }, []);
706
707 // A tab/scope switch must discard the floating menus: their text and paths
708 // were captured from the previous scope, while add-to-chat routes to
709 // whatever tab is active at click time — a menu surviving a keyboard tab
710 // switch would add the old scope's selection to the new session.
711 useEffect(() => {
712 setSelectionMenu(null);
713 setTreeMenu(null);
714 setTreeBlankMenuPoint(null);
715 }, [tabId, workspaceScopeKey]);
716
717 useEffect(() => {
718 if (!open) return;
719 setViewMode(initialViewMode);
720 setCommitHistoryOpen(false);
721 setExpandedCommit(null);
722 setCommitDetail(null);
723 changeDetailRequestIdRef.current += 1;
724 setChangeDetailResource(emptyKeyedResource());
725 setSelectionMenu(null);
726 setTreeMenu(null);
727 setRecentOpen(false);
728 if (initialViewMode === "changed") {
729 setScopedFilePaths(null);
730 return;
731 }
732 setScopedChangeRows(null);
733 setTreeVisible(true);
734 }, [initialViewMode, open]);
735
736 useEffect(() => {
737 if (!open || fileListRequest) return;
738 lastFileListRequestIdRef.current = null;
739 dismissedFileListRequestIdRef.current = null;
740 setScopedFilePaths(null);
741 }, [fileListRequest, open]);
742
743 useEffect(() => {
744 if (!open || !fileListRequest) return;
745 if (fileListRequest.acceptNavigation && !fileListRequest.acceptNavigation()) return;
746 const paths = Array.from(new Set(fileListRequest.paths.map((path) => path.trim()).filter(Boolean)));
747 const scopedPathsSettled =
748 scopedFilePaths !== null &&
749 scopedFilePaths.length === paths.length &&
750 scopedFilePaths.every((path, index) => path === paths[index]);
751 if (dismissedFileListRequestIdRef.current === fileListRequest.id) return;
752 if (lastFileListRequestIdRef.current === fileListRequest.id && viewMode === "files" && scopedPathsSettled) return;
753 lastFileListRequestIdRef.current = fileListRequest.id;
754 dismissedFileListRequestIdRef.current = null;
755 if (paths.length === 0) {
756 setScopedFilePaths(null);
757 return;
758 }
759 setViewMode("files");
760 setTreeVisible(true);
761 setScopedFilePaths(paths);
762 fileNavigation.clearSelection(fileScope);
763 fileNavigation.clearEntries(fileScope);
764 resetPreview();
765 setFilter("");
766 setExpandedCommit(null);
767 setCommitDetail(null);
768 setSelectionMenu(null);
769 setTreeMenu(null);
770 const dirs = Array.from(new Set(paths.flatMap(parentDirs)));
771 updateOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
772 dirs.forEach((dir) => void loadDir(dir));
773 }, [fileListRequest, loadDir, open, scopedFilePaths, updateOpenDirs, viewMode]);
774
775 useEffect(() => {
776 if (!open || changeListRequest) return;
777 lastChangeListRequestIdRef.current = null;
778 dismissedChangeListRequestIdRef.current = null;
779 setScopedChangeRows(null);
780 }, [changeListRequest, open]);
781
782 useEffect(() => {
783 if (!open || !changeListRequest) return;
784 if (changeListRequest.acceptNavigation && !changeListRequest.acceptNavigation()) return;
785 const changes = changeListRequest.changes
786 .map((change) => ({ ...change, path: change.path.trim() }))
787 .filter((change) => change.path.length > 0);
788 const scopedChangesSettled =
789 scopedChangeRows !== null &&
790 scopedChangeRows.length === changes.length &&
791 scopedChangeRows.every((change, index) => change.path === changes[index]?.path);
792 if (dismissedChangeListRequestIdRef.current === changeListRequest.id) return;
793 if (lastChangeListRequestIdRef.current === changeListRequest.id && viewMode === "changed" && scopedChangesSettled) return;
794 lastChangeListRequestIdRef.current = changeListRequest.id;
795 dismissedChangeListRequestIdRef.current = null;
796 if (changes.length === 0) {
797 setScopedChangeRows(null);
798 return;
799 }
800 setViewMode("changed");
801 setScopedChangeRows(changes);
802 setScopedFilePaths(null);
803 setSelectedChangePath(null);
804 fileNavigation.clearEntries(fileScope);
805 resetPreview();
806 setFilter("");
807 setExpandedCommit(null);
808 setCommitDetail(null);
809 setSelectionMenu(null);
810 setTreeMenu(null);
811 }, [changeListRequest, open, scopedChangeRows, viewMode]);
812
813 useEffect(() => {
814 if (!open || changeRevealRequest) return;
815 lastChangeRevealRequestIdRef.current = null;
816 dismissedChangeRevealRequestIdRef.current = null;
817 }, [changeRevealRequest, open]);
818
819 useEffect(() => {
820 if (!open || !changeRevealRequest) return;
821 if (changeRevealRequest.acceptNavigation && !changeRevealRequest.acceptNavigation()) return;
822 if (dismissedChangeRevealRequestIdRef.current === changeRevealRequest.id) return;
823 if (
824 lastChangeRevealRequestIdRef.current === changeRevealRequest.id &&
825 selectedPath === changeRevealRequest.path &&
826 viewMode === "changed"
827 ) {
828 return;
829 }
830 lastChangeRevealRequestIdRef.current = changeRevealRequest.id;
831 dismissedChangeRevealRequestIdRef.current = null;
832 setViewMode("changed");
833 setScopedFilePaths(null);
834 setScopedChangeRows(null);
835 setSelectedChangePath(changeRevealRequest.path);
836 fileNavigation.clearEntries(fileScope);
837 resetPreview();
838 setFilter("");
839 setExpandedCommit(null);
840 setCommitDetail(null);
841 setSelectionMenu(null);
842 setTreeMenu(null);
843 }, [changeRevealRequest, fileNavigation, fileScope, open, selectedPath, viewMode]);
844
845 useEffect(() => {
846 if (!open || !activeVerificationRevealRequest) return;
847 if (activeVerificationRevealRequest.acceptNavigation && !activeVerificationRevealRequest.acceptNavigation()) return;
848 if (lastVerificationRevealRequestIdRef.current !== activeVerificationRevealRequest.id) {
849 lastVerificationRevealRequestIdRef.current = activeVerificationRevealRequest.id;
850 setViewMode("changed");
851 setSelectedChangePath(null);
852 fileNavigation.clearEntries(fileScope);
853 resetPreview();
854 setFilter("");
855 setExpandedCommit(null);
856 setCommitDetail(null);
857 setSelectionMenu(null);
858 setTreeMenu(null);
859 setScopedChangeRows(null);
860 if (viewMode !== "changed" || selectedChangePath) return;
861 }
862 if (viewMode !== "changed" || selectedChangePath) return;
863 const node = verificationSummaryRef.current ?? document.getElementById(WORKSPACE_TURN_VERIFICATION_ID);
864 node?.scrollIntoView({ block: "nearest", inline: "nearest" });
865 }, [activeVerificationRevealRequest, fileNavigation, fileScope, open, selectedChangePath, viewMode]);
866
867 useEffect(() => {
868 if (!open) return;
869 if (viewMode === "changed") {
870 void loadWorkspaceChanges();
871 // A scope switch just happened in this render batch: the previous
872 // scope's selected change must not be re-requested, but the selection
873 // itself is preserved for when the user returns to that scope.
874 if (selectedPath && !scopeSwitchPendingRef.current) void loadChangeDetail();
875 scopeSwitchPendingRef.current = false;
876 if (commitHistoryOpen) void loadGitHistory();
877 } else {
878 changeDetailRequestIdRef.current += 1;
879 setChangeDetailResource(emptyKeyedResource());
880 }
881 }, [commitHistoryOpen, selectedPath, viewMode, loadChangeDetail, loadGitHistory, loadWorkspaceChanges, open]);
882
883 useEffect(() => {
884 if (!selectionMenu && !treeMenu) return;
885 const close = () => {
886 setSelectionMenu(null);
887 setTreeMenu(null);
888 };
889 const onKey = (event: globalThis.KeyboardEvent) => {
890 if (event.key === "Escape") close();
891 };
892 // Dismiss on mousedown rather than click: the trailing click a drag-selection
893 // emits would otherwise close the toolbar the instant mouseup opens it. A fresh
894 // mousedown only fires when the user starts another interaction, and FloatingMenu
895 // stops propagation so pressing its buttons never counts as an outside press.
896 window.addEventListener("mousedown", close);
897 const panel = panelRef.current;
898 panel?.addEventListener("scroll", close, true);
899 window.addEventListener("resize", close);
900 window.addEventListener("keydown", onKey);
901 return () => {
902 window.removeEventListener("mousedown", close);
903 panel?.removeEventListener("scroll", close, true);
904 window.removeEventListener("resize", close);
905 window.removeEventListener("keydown", onKey);
906 };
907 }, [selectionMenu, treeMenu]);
908
909 const refreshWorkspaceList = useCallback(() => {
910 setTreeBlankMenuPoint(null);
911 setSelectionMenu(null);
912 setTreeMenu(null);
913 if (viewMode === "changed") {
914 workingTreeRefreshSchedulerRef.current?.cancel();
915 gitMetaRefreshSchedulerRef.current?.cancel();
916 void loadWorkspaceChanges();
917 if (selectedPath) void loadChangeDetail();
918 // Manual refresh is the explicit escape hatch and includes collapsed
919 // history even when ordinary file writes do not.
920 void loadGitHistory();
921 return;
922 }
923 onFileTreeRefresh?.();
924 const dirs = Array.from(openDirsRef.current);
925 dirs.forEach((dir) => void loadDir(dir));
926 }, [loadChangeDetail, loadGitHistory, loadWorkspaceChanges, loadDir, onFileTreeRefresh, selectedPath, viewMode]);
927
928 const loadMorePresentedText = useCallback(async () => {
929 if (!previewKey || !preview || !selectedPath || !selectedPresentedToolCallId || !preview.version) return;
930 const prior = activePresentedTextTail;
931 const offset = prior?.nextOffset ?? preview.nextOffset ?? 0;
932 if (offset <= 0 || prior?.hasMore === false) return;
933 const requestId = ++textPageRequestIdRef.current;
934 const requestScopeKey = workspaceScopeKey;
935 const version = preview.version;
936 setPresentedTextTail({
937 key: previewKey,
938 version,
939 body: prior?.body ?? "",
940 nextOffset: offset,
941 hasMore: prior?.hasMore ?? true,
942 loading: true,
943 });
944 try {
945 const page = await app.ReadPresentedTextPageForTab(
946 workspaceTabId,
947 selectedPresentedToolCallId,
948 selectedPath,
949 offset,
950 version,
951 );
952 if (textPageRequestIdRef.current !== requestId || currentWorkspaceScopeKeyRef.current !== requestScopeKey) return;
953 setPresentedTextTail({
954 key: previewKey,
955 version,
956 body: (prior?.body ?? "") + page.body,
957 nextOffset: page.nextOffset,
958 hasMore: page.hasMore,
959 loading: false,
960 });
961 } catch (err) {
962 if (textPageRequestIdRef.current !== requestId || currentWorkspaceScopeKeyRef.current !== requestScopeKey) return;
963 setPresentedTextTail({
964 key: previewKey,
965 version,
966 body: prior?.body ?? "",
967 nextOffset: offset,
968 hasMore: true,
969 loading: false,
970 error: String((err as Error)?.message ?? err),
971 });
972 }
973 }, [activePresentedTextTail, preview, previewKey, selectedPath, selectedPresentedToolCallId, workspaceScopeKey, workspaceTabId]);
974
975 useWorkspaceRefreshInvalidation({ commitHistoryOpen,
976 deferSelectedRefresh: Boolean(selectedPresentedToolCallId),
977 filter,
978 gitMetaSchedulerRef: gitMetaRefreshSchedulerRef,
979 loadChangeDetail,
980 loadDir,
981 loadGitHistory,
982 loadWorkspaceChanges,
983 open,
984 openDirsRef,
985 refreshSelected,
986 onSelectedInvalidated: () => setPresentedFileStale(true),
987 selectedPath,
988 setSearchResults,
989 viewMode,
990 workingTreeSchedulerRef: workingTreeRefreshSchedulerRef,
991 workspaceRefresh,
992 workspaceScopeKey,
993 });
994
995 const toggleDir = useCallback(
996 (dir: string, compactPaths: string[] = [dir]) => {
997 const firstPath = compactPaths[0] ?? dir;
998 const rootPath = topLevelDirPath(firstPath);
999 if (revealedRootPaths !== null && !revealedRootPaths.has(rootPath)) {
1000 setRevealedRootPaths((current) => new Set([...(current ?? []), rootPath]));
1001 const remembered = openDirsRef.current;
1002 if (!remembered.has(firstPath)) {
1003 updateOpenDirs((previous) => new Set([...previous, ...compactPaths]));
1004 compactPaths.forEach((path) => void loadDir(path));
1005 return;
1006 }
1007 remembered.forEach((path) => {
1008 if (path === rootPath || path.startsWith(rootPath)) void loadDir(path);
1009 });
1010 return;
1011 }
1012
1013 const isOpen = openDirsRef.current.has(firstPath);
1014 updateOpenDirs((previous) => {
1015 const next = new Set(previous);
1016 compactPaths.forEach((path) => {
1017 if (isOpen) next.delete(path);
1018 else next.add(path);
1019 });
1020 return next;
1021 });
1022 if (!isOpen) compactPaths.forEach((path) => void loadDir(path));
1023 },
1024 [loadDir, revealedRootPaths, updateOpenDirs],
1025 );
1026
1027 const sessionChanges = useMemo(
1028 () => workspaceChanges?.files.filter((c) => c.sources.includes("session")) ?? [],
1029 [workspaceChanges],
1030 );
1031 /** Working-tree files that are not already covered by the session section. */
1032 const gitWorkingChanges = useMemo(
1033 () =>
1034 workspaceChanges?.files.filter(
1035 (c) => c.sources.includes("git") && !c.sources.includes("session"),
1036 ) ?? [],
1037 [workspaceChanges],
1038 );
1039 const hasFileChanges = sessionChanges.length > 0 || gitWorkingChanges.length > 0;
1040 const workspaceGitWarning = workspaceChanges && (!workspaceChanges.gitAvailable || workspaceChanges.gitErr?.trim())
1041 ? t("workspace.gitUnavailable")
1042 : null;
1043
1044 const renderChangeScope = (title: string, changes: typeof sessionChanges) => (
1045 <div className="workspace-change-scope">
1046 <div className="workspace-change-scope__head">
1047 <span className="workspace-change-scope__title">{title}</span>
1048 <span className="workspace-change-scope__meta">{t("context.changedMeta", { count: changes.length })}</span>
1049 </div>
1050 <div className="workspace-change-scope__list">
1051 {changes.map((change) => {
1052 const dir = parentPath(change.path);
1053 return (
1054 <div key={change.path} className="workspace-change-row">
1055 <button
1056 className="workspace-change"
1057 type="button"
1058 onClick={() => openFile(change.path, viewMode)}
1059 >
1060 <FileText size={14} />
1061 <span className="workspace-change__body">
1062 <span className="workspace-change__name">{basename(change.path)}</span>
1063 {dir && <span className="workspace-change__path">{dir}</span>}
1064 {change.latestPrompt && <span className="workspace-change__detail">{change.latestPrompt}</span>}
1065 </span>
1066 <span className="workspace-change__meta">
1067 {change.gitStatus && <span className="workspace-change__badge workspace-change__badge--git">{workspaceGitStatusLabel(change.gitStatus, t)}</span>}
1068 </span>
1069 </button>
1070 {change.canSessionRevert && change.sources.includes("session") && (
1071 <button
1072 type="button"
1073 className="workspace-change__revert"
1074 title={t("workspace.revertSessionFile")}
1075 onClick={(e) => {
1076 e.stopPropagation();
1077 void (async () => {
1078 const plan = await app.PreviewWorkspaceFileRevertForTab(workspaceTabId, change.path);
1079 if (!plan?.ok && !plan?.canFiles && !(plan?.conflicts?.length)) {
1080 return;
1081 }
1082 const resolution = plan?.conflicts?.length ? "overwrite_checkpoint" : "";
1083 if (plan?.conflicts?.length) {
1084 const ok = window.confirm(
1085 t("workspace.revertSessionFileConflict", {
1086 path: change.path,
1087 conflicts: (plan.conflicts || []).join("\n"),
1088 }),
1089 );
1090 if (!ok) return;
1091 }
1092 const result = await app.CommitWorkspaceFileRevertForTab(
1093 workspaceTabId,
1094 plan.planId || "",
1095 resolution,
1096 );
1097 if (result?.ok) {
1098 onSessionRevertCommitted?.(workspaceTabId, result);
1099 void loadWorkspaceChanges();
1100 if (selectedPath === change.path) void loadChangeDetail();
1101 }
1102 })();
1103 }}
1104 >
1105 {t("workspace.revertSessionFileShort")}
1106 </button>
1107 )}
1108 </div>
1109 );
1110 })}
1111 </div>
1112 </div>
1113 );
1114
1115 const changedMode = viewMode === "changed";
1116 const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile");
1117 const previewTitle = changedMode && !selectedPath
1118 ? scopedChangeRows ? t("context.sessionChanges") : t("workspace.changedTab")
1119 : currentFileName;
1120 const activeSelectedPath = selectedPath;
1121 // Changed overview shows just the project name (matching the file-preview
1122 // breadcrumb); hovering reveals the full absolute workspace root.
1123 const previewSubtitleCrumbs = changedMode && !selectedChangePath && !scopedChangeRows
1124 ? (cwd ? [{ label: basename(cwd), full: cwd }] : [])
1125 : [];
1126 const previewFullPath = activeSelectedPath || "";
1127 const previewCrumbs = buildWorkspacePathBreadcrumbs(cwd, previewFullPath);
1128 const recentFiles = useMemo(() => [...recentPaths].reverse(), [recentPaths]);
1129
1130 const workspaceSearchFallbackSequence = workspaceRefreshFallbackSequence(workspaceRefresh);
1131
1132 useEffect(() => {
1133 const q = filter.trim();
1134 if (!open || viewMode === "changed" || !q || scopedFilePaths) {
1135 setSearchResults(null);
1136 return;
1137 }
1138 let cancelled = false;
1139 app.SearchFileRefsForTab(workspaceTabId, q).then((results) => {
1140 if (!cancelled) setSearchResults(asArray(results));
1141 }).catch(() => {
1142 if (!cancelled) setSearchResults(null);
1143 });
1144 return () => { cancelled = true; };
1145 }, [filter, viewMode, scopedFilePaths, open, workspaceRefresh.revisions.tree, workspaceSearchFallbackSequence, workspaceScopeKey, workspaceTabId]);
1146
1147 const flattened = useMemo(() => {
1148 const q = filter.trim().toLowerCase();
1149 if (scopedFilePaths) {
1150 return scopedFilePaths
1151 .map((path) => ({ path, entry: { name: basename(path), isDir: false } }))
1152 .filter((row) => !q || row.path.toLowerCase().includes(q))
1153 .sort((a, b) => a.path.localeCompare(b.path));
1154 }
1155 const rows: { path: string; entry: DirEntry }[] = [];
1156 for (const [dir, entries] of Object.entries(entriesByDir)) {
1157 for (const entry of entries) {
1158 rows.push({ path: entryPath(dir, entry), entry });
1159 }
1160 }
1161 if (!q) return null;
1162 return mergeWorkspaceSearchResults(rows, searchResults)
1163 .filter((row) => row.path.toLowerCase().includes(q))
1164 .sort((a, b) => a.path.localeCompare(b.path));
1165 }, [entriesByDir, filter, scopedFilePaths, searchResults]);
1166
1167 const treeRows = useMemo<WorkspaceTreeRowData[]>(() => {
1168 if (flattened) {
1169 return flattened.map(({ path, entry }) => ({
1170 key: path,
1171 path,
1172 depth: 0,
1173 entry,
1174 active: selectedPath === path,
1175 isSearch: true,
1176 }));
1177 }
1178 const acc: WorkspaceTreeRowData[] = [];
1179 const build = (dir: string, depth: number) => {
1180 const entries = entriesByDir[dir] ?? [];
1181 for (const entry of entries) {
1182 const firstPath = entryPath(dir, entry);
1183 if (!entry.isDir) {
1184 acc.push({
1185 key: firstPath,
1186 path: firstPath,
1187 depth,
1188 entry,
1189 active: selectedPath === firstPath,
1190 isOpen: false,
1191 });
1192 continue;
1193 }
1194
1195 const compactPaths = [firstPath];
1196 const compactNames = [entry.name];
1197 let lastPath = firstPath;
1198 let lastEntry = entry;
1199 while (true) {
1200 const children = entriesByDir[lastPath];
1201 if (!children || children.length !== 1 || !children[0]?.isDir) break;
1202 lastEntry = children[0];
1203 lastPath = entryPath(lastPath, lastEntry);
1204 compactPaths.push(lastPath);
1205 compactNames.push(lastEntry.name);
1206 }
1207 const rootPath = topLevelDirPath(firstPath);
1208 const isRevealed = revealedRootPaths === null || revealedRootPaths.has(rootPath);
1209 const isOpen = isRevealed && openDirs.has(firstPath);
1210 acc.push({
1211 key: lastPath,
1212 path: lastPath,
1213 depth,
1214 entry: lastEntry,
1215 active: selectedPath === lastPath,
1216 isOpen,
1217 compactPaths,
1218 displayName: compactNames.join(" / "),
1219 });
1220 if (isOpen) {
1221 build(lastPath, depth + 1);
1222 }
1223 }
1224 };
1225 build("", 0);
1226 return acc;
1227 }, [flattened, entriesByDir, openDirs, revealedRootPaths, selectedPath]);
1228 const getTreeRowKey = useCallback((index: number) => treeRows[index]?.key ?? index, [treeRows]);
1229
1230 const virtualizer = useVirtualizer({
1231 count: treeRows.length,
1232 getScrollElement: () => treeRef.current,
1233 estimateSize: () => 24,
1234 getItemKey: getTreeRowKey,
1235 overscan: 10,
1236 directDomUpdates: true,
1237 });
1238 // Restore the persisted scroll position once the tree has grown tall enough
1239 // to actually reach it. The tree loads asynchronously layer by layer: the
1240 // first render usually has only the top-level rows, so scrolling then would
1241 // clamp the saved offset to the (tiny) current scrollable range and lose the
1242 // position forever. Keep a pending target and retry on every tree change
1243 // until the tree's total height exceeds it, then scroll once.
1244 const pendingScrollRestoreRef = useRef<number | null>(null);
1245 // Reset the pending restore whenever the view mode changes: the saved offset
1246 // belongs to the previous mode's tree, and re-applying it to a shorter list
1247 // (e.g. files -> changed) scrolls past the end and leaves a blank band at
1248 // the top. Each mode restores its own offset on its own data.
1249 const previousViewModeRef = useRef(viewMode);
1250 if (previousViewModeRef.current !== viewMode) {
1251 previousViewModeRef.current = viewMode;
1252 pendingScrollRestoreRef.current = null;
1253 }
1254 useEffect(() => {
1255 if (!open || treeRows.length === 0) return;
1256 if (pendingScrollRestoreRef.current == null) {
1257 const saved = readWorkspaceTreeMemory(workspaceMemoryKey)?.scrollTop;
1258 if (saved == null || !Number.isFinite(saved) || saved <= 0) return;
1259 pendingScrollRestoreRef.current = saved;
1260 }
1261 const target = pendingScrollRestoreRef.current;
1262 // A saved offset is only reachable once the tree's total height exceeds
1263 // the offset by at least one viewport: the maximum scroll position is
1264 // totalSize - viewportHeight. Using totalSize < target would clear the
1265 // pending restore once content just barely exceeds the offset, but before
1266 // it provides enough scrollable space — the browser then clamps the scroll
1267 // and the deep-scroll restore is silently lost. Use the virtualizer's own
1268 // viewport rect (reliable after remount) rather than reading clientHeight
1269 // at restore time, which can be 0 while the tree is still settling.
1270 const viewportHeight = virtualizer.scrollRect?.height ?? treeRef.current?.clientHeight ?? 0;
1271 if (virtualizer.getTotalSize() - viewportHeight < target) return; // not enough scrollable space yet
1272 virtualizer.scrollToOffset(target, { align: "start" });
1273 pendingScrollRestoreRef.current = null;
1274 }, [open, treeRows.length, virtualizer.getTotalSize(), virtualizer.scrollRect?.height, workspaceMemoryKey, virtualizer]);
1275
1276 const virtualTreeItems = virtualizer.getVirtualItems();
1277 const compactProbePaths = virtualTreeItems
1278 .map((item) => treeRows[item.index])
1279 .filter((row): row is WorkspaceTreeRowData => Boolean(row?.entry.isDir && entriesByDir[row.path] === undefined))
1280 .map((row) => row.path);
1281 const compactProbeKey = compactProbePaths.join("\u0000");
1282
1283 useEffect(() => {
1284 if (!open || !compactProbeKey) return;
1285 compactProbePaths.forEach((path) => {
1286 if (compactProbeInFlightRef.current.has(path)) return;
1287 compactProbeInFlightRef.current.add(path);
1288 void loadDir(path).finally(() => compactProbeInFlightRef.current.delete(path));
1289 });
1290 }, [compactProbeKey, loadDir, open]);
1291
1292 const externalFileScope = scopedFilePaths?.some(isAbsoluteDisplayPath) ?? false;
1293 const searchPlaceholder = t(
1294 scopedFilePaths
1295 ? externalFileScope
1296 ? "workspace.filterExternalFiles"
1297 : "workspace.filterReferencedFiles"
1298 : changedMode
1299 ? "workspace.filterChanges"
1300 : "workspace.filter",
1301 );
1302
1303 const filePreviewActive = openTabs.length > 0 || selectedPath !== null;
1304 const changeDetailActive = changedMode && expandedCommit !== null;
1305 const previewVisible = changedMode || filePreviewActive;
1306 const splitPanesFit = useMemo(
1307 () =>
1308 workspaceSplitCanFit({
1309 panelWidth,
1310 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
1311 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
1312 }),
1313 [panelWidth],
1314 );
1315 const actualTreeVisible = changedMode ? false : treeVisible && (!previewVisible || splitPanesFit);
1316 const previewModeActive = open && (filePreviewActive || changeDetailActive);
1317 const embeddedDockMode = !showViewTabs;
1318 const showFileTools = true;
1319 const effectiveTreeWidth = useMemo(
1320 () =>
1321 resolveWorkspaceSplitTreeWidth({
1322 mode: treeWidthMode,
1323 currentTreeWidth: treeWidth,
1324 panelWidth,
1325 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
1326 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
1327 }),
1328 [panelWidth, treeWidth, treeWidthMode],
1329 );
1330 const maxTreeWidthForPanel = useMemo(
1331 () => Math.max(WORKSPACE_TREE_MIN_WIDTH, (panelWidth ?? WORKSPACE_DUAL_PANEL_TARGET_WIDTH) - WORKSPACE_PREVIEW_MIN_WIDTH),
1332 [panelWidth],
1333 );
1334
1335 useEffect(() => {
1336 const pendingRevealPath = pendingTreeRevealPathRef.current;
1337 if (!pendingRevealPath) return;
1338 if (!selectedPath || pendingRevealPath !== selectedPath) {
1339 pendingTreeRevealPathRef.current = null;
1340 return;
1341 }
1342 const selectedIndex = treeRows.findIndex((row) => row.path === selectedPath);
1343 if (!shouldScrollWorkspaceTreeSelection({ selectedPath, pendingRevealPath, actualTreeVisible, selectedIndex })) return;
1344 virtualizer.scrollToIndex(selectedIndex, { align: "auto" });
1345 pendingTreeRevealPathRef.current = null;
1346 }, [selectedPath, actualTreeVisible, treeRows, virtualizer]);
1347
1348 const panelStyle = useMemo(
1349 () =>
1350 ({
1351 "--workspace-tree-width": `${effectiveTreeWidth}px`,
1352 "--workspace-preview-min-width": `${WORKSPACE_PREVIEW_MIN_WIDTH}px`,
1353 }) as CSSProperties,
1354 [effectiveTreeWidth],
1355 );
1356
1357 useEffect(() => {
1358 if (lastPreviewModeActiveRef.current === previewModeActive) return;
1359 lastPreviewModeActiveRef.current = previewModeActive;
1360 onPreviewModeChange?.(previewModeActive);
1361 }, [onPreviewModeChange, previewModeActive]);
1362
1363 useEffect(() => {
1364 if (open && !treeVisible && !previewVisible) onClose();
1365 }, [onClose, open, previewVisible, treeVisible]);
1366
1367 const hideTreeOrClosePanel = useCallback(() => {
1368 if (previewVisible) {
1369 setTreeVisible(false);
1370 } else {
1371 onClose();
1372 }
1373 }, [onClose, previewVisible]);
1374
1375 const showTreeEvenSplit = useCallback(() => {
1376 setTreeWidth(initialWorkspaceSplitTreeWidth({
1377 panelWidth,
1378 savedTreeWidth: null,
1379 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
1380 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
1381 }));
1382 setTreeWidthMode("even");
1383 setTreeVisible(true);
1384 }, [panelWidth]);
1385
1386 const toggleTreeRail = useCallback(() => {
1387 if (actualTreeVisible) {
1388 setTreeVisible(false);
1389 return;
1390 }
1391 showTreeEvenSplit();
1392 }, [actualTreeVisible, showTreeEvenSplit]);
1393
1394 const closePreviewArea = useCallback(() => {
1395 if (lastChangeRevealRequestIdRef.current === changeRevealRequest?.id) {
1396 dismissedChangeRevealRequestIdRef.current = changeRevealRequest.id;
1397 }
1398 if (viewMode === "changed") setSelectedChangePath(null);
1399 else if (selectedFilePath) fileNavigation.closeEntry(fileScope, selectedFilePath);
1400 resetPreview();
1401 setSelectionMenu(null);
1402 setTreeMenu(null);
1403 setRecentOpen(false);
1404 setTreeVisible(true);
1405 }, [changeRevealRequest, fileNavigation, fileScope, selectedFilePath, viewMode]);
1406
1407 const closePreviewTab = useCallback((path: string) => {
1408 fileNavigation.closeEntry(fileScope, path);
1409 if (selectedFilePath === path) {
1410 resetPreview();
1411 setPresentedFileStale(false);
1412 }
1413 }, [fileNavigation, fileScope, selectedFilePath]);
1414
1415 const setSavedTreeWidth = useCallback(
1416 (width: number) => {
1417 const next = clampWorkspaceTreeWidth(width, panelWidth);
1418 setTreeWidth(next);
1419 setTreeWidthMode("manual");
1420 },
1421 [panelWidth],
1422 );
1423
1424 const startTreeResize = useCallback(
1425 (event: ReactPointerEvent<HTMLButtonElement>) => {
1426 if (!treeVisible) return;
1427 const panel = panelRef.current;
1428 const rect = panel?.getBoundingClientRect();
1429 if (!panel || !rect) return;
1430 event.preventDefault();
1431 const committedTreeWidth = clampWorkspaceTreeWidth(effectiveTreeWidth, panelWidth);
1432 setTreeWidth(committedTreeWidth);
1433 setTreeWidthMode("manual");
1434 setTreeResizing(true);
1435 let nextWidth = committedTreeWidth;
1436 const liveResize = createRafResizeUpdater({
1437 target: panel,
1438 separator: event.currentTarget,
1439 cssVar: "--workspace-tree-width",
1440 });
1441 const onMove = (moveEvent: PointerEvent) => {
1442 nextWidth = workspaceSplitTreeWidthFromPointer({
1443 clientX: moveEvent.clientX,
1444 panelLeft: rect.left,
1445 panelWidth: rect.width,
1446 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
1447 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
1448 treeOnRight: true,
1449 });
1450 liveResize.schedule(nextWidth);
1451 };
1452 const onDone = () => {
1453 liveResize.flush();
1454 setTreeWidth(nextWidth);
1455 setTreeResizing(false);
1456 window.removeEventListener("pointermove", onMove);
1457 window.removeEventListener("pointerup", onDone);
1458 window.removeEventListener("pointercancel", onDone);
1459 document.body.style.cursor = "";
1460 document.body.style.userSelect = "";
1461 };
1462 document.body.style.cursor = "col-resize";
1463 document.body.style.userSelect = "none";
1464 window.addEventListener("pointermove", onMove);
1465 window.addEventListener("pointerup", onDone);
1466 window.addEventListener("pointercancel", onDone);
1467 },
1468 [effectiveTreeWidth, panelWidth, treeVisible],
1469 );
1470
1471 const resizeTreeWithKeyboard = useCallback(
1472 (event: KeyboardEvent<HTMLButtonElement>) => {
1473 if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
1474 event.preventDefault();
1475 setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowLeft" ? 16 : -16));
1476 } else if (event.key === "Home") {
1477 event.preventDefault();
1478 setSavedTreeWidth(WORKSPACE_TREE_MIN_WIDTH);
1479 } else if (event.key === "End") {
1480 event.preventDefault();
1481 setSavedTreeWidth(maxTreeWidthForPanel);
1482 }
1483 },
1484 [effectiveTreeWidth, maxTreeWidthForPanel, setSavedTreeWidth],
1485 );
1486
1487 useEffect(() => {
1488 setCodeSearchRequestPending(false);
1489 setCodeSearchRequestPath(null);
1490 }, [selectedPath]);
1491
1492 const consumeCodeSearchRequest = useCallback(() => {
1493 setCodeSearchRequestPending(false);
1494 }, []);
1495
1496 if (!open) return null;
1497
1498 const selectedTextFromPreview = (): string => {
1499 const root = previewBodyRef.current;
1500 const selection = typeof window === "undefined" ? null : window.getSelection();
1501 if (!root || !selection || selection.rangeCount === 0) return "";
1502 const range = selection.getRangeAt(0);
1503 const container = range.commonAncestorContainer;
1504 const node = container instanceof Element ? container : container.parentElement;
1505 if (!node || !root.contains(node)) return "";
1506 return selection.toString();
1507 };
1508
1509 const openSelectionMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
1510 if (!selectedPath || (loadingPreview && !preview) || previewErr || preview?.err || preview?.binary || preview?.kind) return;
1511 const text = selectedTextFromPreview();
1512 if (text.trim() === "") return;
1513 event.preventDefault();
1514 event.stopPropagation();
1515 setSelectionMenu({ x: event.clientX, y: event.clientY, text, path: selectedPath });
1516 };
1517
1518 // Selecting code with the mouse pops the "Add to Chat" button right away,
1519 // so a snippet is one click from the composer instead of right-click →
1520 // menu item. The right-click menu (openSelectionMenu) stays as a fallback.
1521 const showSelectionToolbar = (event: ReactMouseEvent<HTMLDivElement>) => {
1522 // Mouseup on the floating button bubbles back here through the portal's
1523 // React tree; let the button handle it.
1524 if ((event.target as HTMLElement | null)?.closest(".floating-menu")) return;
1525 if (!selectedPath || (loadingPreview && !preview) || previewErr || preview?.err || preview?.binary || preview?.kind) return;
1526 const text = selectedTextFromPreview();
1527 if (text.trim() === "") return;
1528 setSelectionMenu({ x: event.clientX, y: event.clientY + 8, text, path: selectedPath });
1529 };
1530
1531 const addSelectionToChat = () => {
1532 if (!selectionMenu) return;
1533 if (onAddCodeToChat) onAddCodeToChat(selectionMenu.path, selectionMenu.text);
1534 else onAddToChat?.(formatSelectionReference(selectionMenu.path, selectionMenu.text));
1535 setSelectionMenu(null);
1536 };
1537
1538 const openTreeMenu = (event: ReactMouseEvent<HTMLElement>, path: string, isDir: boolean) => {
1539 event.preventDefault();
1540 event.stopPropagation();
1541 setTreeBlankMenuPoint(null);
1542 setSelectionMenu(null);
1543 setTreeMenu({ x: event.clientX, y: event.clientY, path, isDir });
1544 };
1545
1546 const openTreeBlankMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
1547 const target = event.target as HTMLElement | null;
1548 if (target?.closest(".workspace-tree__row,.workspace-change,button,input,textarea,select")) return;
1549 event.preventDefault();
1550 event.stopPropagation();
1551 setSelectionMenu(null);
1552 setTreeMenu(null);
1553 setTreeBlankMenuPoint(contextMenuPointFromEvent(event));
1554 };
1555
1556 const startTreeDrag = (event: ReactDragEvent<HTMLElement>, path: string, isDir: boolean) => {
1557 const ref = formatWorkspaceReference(path, isDir);
1558 event.dataTransfer.effectAllowed = "copy";
1559 event.dataTransfer.setData(WORKSPACE_REF_DRAG_TYPE, JSON.stringify({ path, isDir }));
1560 event.dataTransfer.setData("text/plain", ref);
1561 };
1562
1563 const addTreeReferenceToChat = () => {
1564 if (!treeMenu) return;
1565 onAddToChat?.(formatWorkspaceReference(treeMenu.path, treeMenu.isDir));
1566 setTreeMenu(null);
1567 };
1568
1569 const addTreeFileToChat = async () => {
1570 if (!treeMenu || treeMenu.isDir) return;
1571 const target = treeMenu;
1572 const requestTabId = workspaceTabId;
1573 const requestScopeKey = workspaceScopeKey;
1574 setTreeMenu(null);
1575 try {
1576 const file = await app.ReadFileForTab(requestTabId, target.path);
1577 if (currentWorkspaceScopeKeyRef.current !== requestScopeKey) return;
1578 if (file.err || file.binary || file.kind) {
1579 onAddToChat?.(formatWorkspaceReference(target.path, false));
1580 return;
1581 }
1582 const body = file.truncated ? `${file.body}\n\n${t("workspace.truncated")}` : file.body;
1583 if (onAddCodeToChat) onAddCodeToChat(target.path, body);
1584 else onAddToChat?.(formatSelectionReference(target.path, body));
1585 } catch {
1586 if (currentWorkspaceScopeKeyRef.current !== requestScopeKey) return;
1587 onAddToChat?.(formatWorkspaceReference(target.path, false));
1588 }
1589 };
1590
1591 const activateTreeRow = (row: WorkspaceTreeRowData) => {
1592 if (row.entry.isDir) {
1593 toggleDir(row.path, row.compactPaths ?? [row.path]);
1594 } else if (selectedPath === row.path) {
1595 fileNavigation.clearSelection(fileScope);
1596 } else {
1597 openFile(row.path);
1598 }
1599 };
1600
1601 const isMarkdown = selectedPath?.toLowerCase().endsWith(".md") ?? false;
1602 const isCSV = selectedPath ? /\.(?:csv|tsv)$/i.test(selectedPath) : false;
1603 // Host-verifiable text references may switch between their rendered and
1604 // source forms. SVG is text even though its preview is an image.
1605 const canTogglePresentedSource = Boolean(
1606 selectedPath && (selectedPresentedToolCallId || selectedIsReference) && /\.(?:html?|md|markdown|csv|tsv|svg)$/i.test(selectedPath),
1607 );
1608 const renderedAsMarkdown = isMarkdown && !sourceOverride;
1609 const renderedAsCSV = isCSV && !sourceOverride;
1610 const codePreviewActive = Boolean(
1611 selectedPath &&
1612 !changedMode &&
1613 preview &&
1614 !(loadingPreview && !preview) &&
1615 !previewErr &&
1616 !preview.err &&
1617 !preview.kind &&
1618 !preview.binary &&
1619 !renderedAsMarkdown &&
1620 !renderedAsCSV,
1621 );
1622 // The preview body must keep its flex-column layout while a code file is
1623 // loading. Switching it to the padded block layout mid-load and back again
1624 // makes WebKit leave stale gaps between rows once the viewer mounts.
1625 const codePreviewLayoutActive = Boolean(
1626 selectedFilePath &&
1627 !changedMode &&
1628 !renderedAsMarkdown &&
1629 !renderedAsCSV &&
1630 !previewErr &&
1631 !preview?.err &&
1632 !preview?.kind &&
1633 !preview?.binary,
1634 );
1635 const openCodeSearch = () => {
1636 if (!codePreviewActive || !selectedPath) return;
1637 setCodeSearchRequestPath(selectedPath);
1638 setCodeSearchRequestPending(true);
1639 };
1640 const treeBlankMenuItems: ContextMenuItem[] = [
1641 {
1642 key: "refresh-tree",
1643 icon: <RefreshCw size={13} />,
1644 label: t(viewMode === "changed" ? "workspace.refreshChanges" : "workspace.refreshTree"),
1645 onSelect: refreshWorkspaceList,
1646 },
1647 ];
1648
1649 return (
1650 <MarkdownImageTabContext.Provider value={workspaceTabId}>
1651 <aside
1652 ref={panelRef}
1653 className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${changedMode ? " workspace-panel--detail-only" : ""}${changedMode && !selectedPath ? " workspace-panel--changed-overview" : ""}${previewVisible && actualTreeVisible ? " workspace-panel--split-preview" : ""}${actualTreeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
1654 aria-label={t("workspace.title")}
1655 style={panelStyle}
1656 onKeyDownCapture={(event) => {
1657 if (
1658 codePreviewActive
1659 && (event.ctrlKey || event.metaKey)
1660 && event.key.toLowerCase() === "f"
1661 ) {
1662 event.preventDefault();
1663 event.stopPropagation();
1664 openCodeSearch();
1665 }
1666 }}
1667 >
1668 {previewVisible && <section className="workspace-preview">
1669 <header className="workspace-preview__head">
1670 <div className="workspace-current-file" aria-label={t("workspace.currentFile")}>
1671 {changedMode && !selectedPath ? (
1672 <GitBranch size={15} className="workspace-current-file__icon" />
1673 ) : (
1674 <FileText size={15} className="workspace-current-file__icon" />
1675 )}
1676 <div className="workspace-current-file__text">
1677 <Tooltip label={selectedPath ?? undefined}>
1678 <span className="workspace-current-file__name">{previewTitle}</span>
1679 </Tooltip>
1680 <WorkspacePathBreadcrumbs crumbs={previewSubtitleCrumbs} />
1681 <WorkspacePathBreadcrumbs crumbs={previewCrumbs} />
1682 </div>
1683 <Tooltip label={t("workspace.recentFiles")}>
1684 <button
1685 ref={recentAnchorRef}
1686 className={`workspace-current-file__recent${recentOpen ? " workspace-current-file__recent--open" : ""}`}
1687 type="button"
1688 aria-label={t("workspace.recentFiles")}
1689 aria-expanded={recentOpen}
1690 onClick={() => setRecentOpen((open) => !open)}
1691 >
1692 <ChevronDown size={13} />
1693 </button>
1694 </Tooltip>
1695 </div>
1696
1697 <div className="workspace-preview__window-actions">
1698 {canTogglePresentedSource && selectedPath && (
1699 <>
1700 <Tooltip label={t("workspace.previewMode")}>
1701 <button
1702 className={`workspace-iconbtn${sourceOverride ? "" : " workspace-iconbtn--on"}`}
1703 type="button"
1704 aria-label={t("workspace.previewMode")}
1705 aria-pressed={!sourceOverride}
1706 onClick={() => fileNavigation.setSourceMode(fileScope, selectedPath, false)}
1707 >
1708 <Eye size={15} />
1709 </button>
1710 </Tooltip>
1711 <Tooltip label={t("workspace.sourceMode")}>
1712 <button
1713 className={`workspace-iconbtn${sourceOverride ? " workspace-iconbtn--on" : ""}`}
1714 type="button"
1715 aria-label={t("workspace.sourceMode")}
1716 aria-pressed={sourceOverride}
1717 onClick={() => fileNavigation.setSourceMode(fileScope, selectedPath, true)}
1718 >
1719 <Code2 size={15} />
1720 </button>
1721 </Tooltip>
1722 </>
1723 )}
1724 {codePreviewActive && (
1725 <Tooltip label={t("workspace.searchPlaceholder")}>
1726 <button
1727 className="workspace-iconbtn"
1728 type="button"
1729 aria-label={t("workspace.searchPlaceholder")}
1730 aria-keyshortcuts="Control+F Meta+F"
1731 onClick={openCodeSearch}
1732 >
1733 <Search size={15} />
1734 </button>
1735 </Tooltip>
1736 )}
1737 <Tooltip label={maximized ? t("workspace.restore") : t("workspace.maximize")}>
1738 <button className="workspace-iconbtn" onClick={onToggleMaximized}>
1739 {maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
1740 </button>
1741 </Tooltip>
1742 {previewVisible && !changedMode && (
1743 <Tooltip label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
1744 <button
1745 className={`workspace-iconbtn${actualTreeVisible ? " workspace-iconbtn--on" : ""}`}
1746 type="button"
1747 aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}
1748 aria-pressed={actualTreeVisible}
1749 onClick={toggleTreeRail}
1750 >
1751 <FolderTree size={15} />
1752 </button>
1753 </Tooltip>
1754 )}
1755 {selectedPath && (
1756 <Tooltip label={t("workspace.closePreview")}>
1757 <button className="workspace-iconbtn" onClick={closePreviewArea}>
1758 <X size={15} />
1759 </button>
1760 </Tooltip>
1761 )}
1762 </div>
1763 <AnchoredPopover
1764 open={recentOpen}
1765 anchorRef={recentAnchorRef}
1766 onClose={() => setRecentOpen(false)}
1767 className="workspace-recent-menu"
1768 align="start"
1769 offset={6}
1770 placement="bottom"
1771 >
1772 <div className="workspace-recent-menu__title">{t("workspace.recentFiles")}</div>
1773 <div className="workspace-recent-menu__list">
1774 {recentFiles.map((path) => (
1775 <button
1776 key={path}
1777 type="button"
1778 className={`workspace-recent-menu__item${path === selectedPath ? " workspace-recent-menu__item--active" : ""}`}
1779 onClick={() => {
1780 // A remembered path carries no presentation of its own: it
1781 // opens as a workspace file under the current session scope.
1782 void Promise.resolve(fileNavigation.selectPath(fileScope, { hostId: "local", path }));
1783 setRecentOpen(false);
1784 }}
1785 >
1786 <FileText size={14} />
1787 <span>
1788 <span className="workspace-recent-menu__name">{basename(path)}</span>
1789 <span className="workspace-recent-menu__path">{parentPath(path)}</span>
1790 </span>
1791 </button>
1792 ))}
1793 </div>
1794 </AnchoredPopover>
1795 </header>
1796 {!changedMode && openTabs.length > 1 && (
1797 <nav className="workspace-document-tabs" aria-label={t("workspace.openFiles")}>
1798 {openTabs.map((path) => (
1799 <span key={path} className={`workspace-document-tab${selectedFilePath === path ? " is-active" : ""}`} title={path}>
1800 <button type="button" onClick={() => fileNavigation.selectEntry(fileScope, path)}>
1801 <FileText size={12} />
1802 <span>{basename(path)}</span>
1803 </button>
1804 <button type="button" aria-label={t("workspace.closePreview")} onClick={() => closePreviewTab(path)}><X size={11} /></button>
1805 </span>
1806 ))}
1807 </nav>
1808 )}
1809
1810 <div
1811 className={`workspace-preview__body${codePreviewLayoutActive ? " workspace-preview__body--code" : ""}`}
1812 ref={previewBodyRef}
1813 onContextMenu={openSelectionMenu}
1814 onMouseUp={showSelectionToolbar}
1815 >
1816 {viewMode === "changed" && activeVerificationRevealRequest && visibleCompletionSummary ? (
1817 <WorkspaceTurnResult key={activeVerificationRevealRequest.id} ref={verificationSummaryRef} summary={visibleCompletionSummary} tabId={workspaceTabId} sessionPath={sessionPath ?? ""} initialView={activeVerificationRevealRequest.view} onAllChanges={() => { onDismissTurnResult?.(); }} />
1818 ) : viewMode === "changed" && scopedChangeRows ? (
1819 <div className="workspace-change-scope">
1820 <div className="workspace-change-scope__head">
1821 <span className="workspace-change-scope__title">{t("context.sessionChanges")}</span>
1822 <span className="workspace-change-scope__meta">{t("context.changedMeta", { count: scopedChangeRows.length })}</span>
1823 <Tooltip label={t("workspace.clearChangeScope")}>
1824 <button
1825 type="button"
1826 aria-label={t("workspace.clearChangeScope")}
1827 onClick={() => {
1828 dismissedChangeListRequestIdRef.current = lastChangeListRequestIdRef.current;
1829 setScopedChangeRows(null);
1830 setSelectedChangePath(null);
1831 setExpandedCommit(null);
1832 setCommitDetail(null);
1833 }}
1834 >
1835 <X size={12} />
1836 </button>
1837 </Tooltip>
1838 </div>
1839 <div className="workspace-change-scope__list">
1840 {scopedChangeRows.map((change) => {
1841 const dir = parentPath(change.path);
1842 return (
1843 <button
1844 key={change.key}
1845 className="workspace-change"
1846 type="button"
1847 onClick={() => {
1848 dismissedChangeListRequestIdRef.current = lastChangeListRequestIdRef.current;
1849 setScopedChangeRows(null);
1850 openFile(change.path, viewMode);
1851 }}
1852 >
1853 <FileText size={14} />
1854 <span className="workspace-change__body">
1855 <span className="workspace-change__name">{basename(change.path)}</span>
1856 {dir && <span className="workspace-change__path">{dir}</span>}
1857 {change.detail && <span className="workspace-change__detail">{change.detail}</span>}
1858 </span>
1859 <span className="workspace-change__meta">
1860 <span className="workspace-change__badge workspace-change__badge--git">{change.meta}</span>
1861 {change.time && <span className="workspace-change__badge">{change.time}</span>}
1862 </span>
1863 </button>
1864 );
1865 })}
1866 </div>
1867 </div>
1868 ) : viewMode === "changed" && !selectedPath ? (
1869 <div className="workspace-git-history">
1870 {workspaceGitWarning && (
1871 <div className="workspace-note workspace-note--warning" role="status">
1872 {workspaceGitWarning}
1873 </div>
1874 )}
1875 {overviewResourceStatus && (
1876 <div className={`workspace-resource-status${overviewResourceStatus.error ? " workspace-resource-status--error" : ""}`} role={overviewResourceStatus.error ? "alert" : "status"}>
1877 {overviewResourceStatus.text}
1878 </div>
1879 )}
1880 {groupedChangesLayout ? (
1881 <>
1882 {sessionChanges.length > 0 && renderChangeScope(t("context.sessionChanges"), sessionChanges)}
1883 {gitWorkingChanges.length > 0 && renderChangeScope(t("workspace.workingChanges"), gitWorkingChanges)}
1884 {!loadingWorkspaceChanges && !workspaceChangesErr && !hasFileChanges && !workspaceGitWarning && (
1885 <div className="workspace-empty">{t("context.noChanges")}</div>
1886 )}
1887 {loadingWorkspaceChanges && !workspaceChanges && (
1888 <div className="workspace-empty">{t("workspace.loadingChanges")}</div>
1889 )}
1890 {workspaceChangesErr && !workspaceChanges && (
1891 <div className="workspace-empty workspace-empty--error">{t("workspace.changesUnavailable")}: {workspaceChangesErr}</div>
1892 )}
1893 <section className={`workspace-commit-history${commitHistoryOpen ? " workspace-commit-history--open" : ""}`}>
1894 <button
1895 className="workspace-commit-history__toggle"
1896 type="button"
1897 aria-expanded={commitHistoryOpen}
1898 onClick={() => {
1899 setCommitHistoryOpen((open) => !open);
1900 setExpandedCommit(null);
1901 }}
1902 >
1903 {commitHistoryOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
1904 <span>{t("workspace.commitHistory")}</span>
1905 <small>{t("workspace.commitHistoryMeta", { count: gitHistory.length })}</small>
1906 </button>
1907 {commitHistoryOpen && (loadingHistory && gitHistory.length === 0 ? (
1908 <div className="workspace-empty">{t("workspace.loading")}</div>
1909 ) : gitHistoryErr && gitHistory.length === 0 ? (
1910 <div className="workspace-empty workspace-empty--error">{t("workspace.historyUnavailable")}: {gitHistoryErr}</div>
1911 ) : gitHistory.length === 0 ? (
1912 <div className="workspace-empty">{t("workspace.noCommitHistory")}</div>
1913 ) : (
1914 <div className="workspace-git-history__list">
1915 {gitHistory.map((commit) => (
1916 <div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
1917 <button
1918 className="workspace-git-history__head"
1919 onClick={() => void toggleCommit(commit.hash)}
1920 >
1921 <div className="workspace-git-history__head-top">
1922 {expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
1923 <span className="workspace-git-history__message">{commit.message}</span>
1924 </div>
1925 <div className="workspace-git-history__head-bottom">
1926 <span className="workspace-git-history__author">{commit.author}</span>
1927 <span className="workspace-git-history__date">
1928 {formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
1929 </span>
1930 </div>
1931 </button>
1932 {expandedCommit === commit.hash && (
1933 <div className="workspace-git-history__detail">
1934 {loadingCommit ? (
1935 <div className="workspace-empty">{t("workspace.loading")}</div>
1936 ) : commitDetail?.diff ? (
1937 <CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
1938 ) : commitDetail?.files ? (
1939 <div className="workspace-git-history__files">
1940 {commitDetail.files.map((file) => (
1941 <button
1942 key={file}
1943 className="workspace-git-history__file"
1944 onClick={() => { openFile(file, viewMode); }}
1945 >
1946 <FileText size={14} /> {file}
1947 </button>
1948 ))}
1949 </div>
1950 ) : (
1951 <div className="workspace-empty">No details available</div>
1952 )}
1953 </div>
1954 )}
1955 </div>
1956 ))}
1957 </div>
1958 ))}
1959 </section>
1960 </>
1961 ) : (
1962 <>
1963 {sessionChanges.length > 0 && renderChangeScope(t("workspace.changedTab"), sessionChanges)}
1964 {gitWorkingChanges.length > 0 && renderChangeScope(t("workspace.workingChanges"), gitWorkingChanges)}
1965 {loadingHistory && gitHistory.length === 0 ? (
1966 <div className="workspace-empty">{t("workspace.loading")}</div>
1967 ) : gitHistoryErr && gitHistory.length === 0 ? (
1968 <div className="workspace-empty workspace-empty--error">{t("workspace.historyUnavailable")}: {gitHistoryErr}</div>
1969 ) : gitHistory.length === 0 && !hasFileChanges ? (
1970 <div className="workspace-empty">{workspaceGitWarning ? t("workspace.gitChangesUnknown") : t("workspace.noChanges")}</div>
1971 ) : (
1972 <div className="workspace-git-history__list">
1973 {gitHistory.map((commit) => (
1974 <div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
1975 <button
1976 className="workspace-git-history__head"
1977 onClick={() => void toggleCommit(commit.hash)}
1978 >
1979 <div className="workspace-git-history__head-top">
1980 {expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
1981 <span className="workspace-git-history__message">{commit.message}</span>
1982 </div>
1983 <div className="workspace-git-history__head-bottom">
1984 <span className="workspace-git-history__author">{commit.author}</span>
1985 <span className="workspace-git-history__date">
1986 {formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
1987 </span>
1988 </div>
1989 </button>
1990 {expandedCommit === commit.hash && (
1991 <div className="workspace-git-history__detail">
1992 {loadingCommit ? (
1993 <div className="workspace-empty">{t("workspace.loading")}</div>
1994 ) : commitDetail?.diff ? (
1995 <CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
1996 ) : commitDetail?.files ? (
1997 <div className="workspace-git-history__files">
1998 {commitDetail.files.map((file) => (
1999 <button
2000 key={file}
2001 className="workspace-git-history__file"
2002 onClick={() => { openFile(file, viewMode); }}
2003 >
2004 <FileText size={14} /> {file}
2005 </button>
2006 ))}
2007 </div>
2008 ) : (
2009 <div className="workspace-empty">No details available</div>
2010 )}
2011 </div>
2012 )}
2013 </div>
2014 ))}
2015 </div>
2016 )}
2017 </>
2018 )}
2019 </div>
2020 ) : viewMode === "changed" && selectedPath ? (
2021 <div className="workspace-git-history">
2022 <section className="workspace-current-change">
2023 <header className="workspace-current-change__head">
2024 <div>
2025 <strong>{t("workspace.currentChanges")}</strong>
2026 {changeDetail?.source && (
2027 <span>{t(changeDetail.source === "git" ? "workspace.currentChangesSourceGit" : "workspace.currentChangesSourceSession")}</span>
2028 )}
2029 </div>
2030 {(changeDetail?.added || changeDetail?.removed) ? (
2031 <small>
2032 <span className="workspace-current-change__added">+{changeDetail.added ?? 0}</span>
2033 <span className="workspace-current-change__removed">-{changeDetail.removed ?? 0}</span>
2034 </small>
2035 ) : null}
2036 </header>
2037 <div className="workspace-current-change__body">
2038 {loadingChangeDetail && changeDetail && <div className="workspace-resource-status" role="status">{t("workspace.loading")}</div>}
2039 {changeDetailErr && changeDetail && <div className="workspace-resource-status workspace-resource-status--error">{t("workspace.changeDetailUnavailable")}: {changeDetailErr}</div>}
2040 {loadingChangeDetail && !changeDetail ? (
2041 <div className="workspace-empty">{t("workspace.loading")}</div>
2042 ) : changeDetailErr && !changeDetail ? (
2043 <div className="workspace-empty workspace-empty--error">{t("workspace.changeDetailUnavailable")}: {changeDetailErr}</div>
2044 ) : changeDetail?.truncated ? (
2045 <div className="workspace-empty">{t("workspace.changeDetailTooLarge")}</div>
2046 ) : changeDetail?.binary ? (
2047 <div className="workspace-empty">{t("workspace.binaryChange")}</div>
2048 ) : changeDetail?.diff ? (
2049 changeDetail.diff.includes("@@") ? (
2050 <DiffView diff={changeDetail.diff} language={languageFor(selectedPath)} />
2051 ) : (
2052 <CodeViewer value={changeDetail.diff} language="diff" />
2053 )
2054 ) : (
2055 <div className="workspace-empty">{t("workspace.noCurrentDiff")}</div>
2056 )}
2057 </div>
2058 </section>
2059 <section className={`workspace-commit-history${commitHistoryOpen ? " workspace-commit-history--open" : ""}`}>
2060 <button
2061 className="workspace-commit-history__toggle"
2062 type="button"
2063 aria-expanded={commitHistoryOpen}
2064 onClick={() => {
2065 setCommitHistoryOpen((value) => !value);
2066 setExpandedCommit(null);
2067 }}
2068 >
2069 {commitHistoryOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
2070 <span>{t("workspace.commitHistory")}</span>
2071 <small>{loadingHistory && gitHistory.length === 0 ? t("workspace.loading") : t("workspace.commitHistoryMeta", { count: gitHistory.length })}</small>
2072 </button>
2073 {commitHistoryOpen && (
2074 loadingHistory && gitHistory.length === 0 ? (
2075 <div className="workspace-empty">{t("workspace.loading")}</div>
2076 ) : gitHistoryErr && gitHistory.length === 0 ? (
2077 <div className="workspace-empty workspace-empty--error">{t("workspace.historyUnavailable")}: {gitHistoryErr}</div>
2078 ) : gitHistory.length === 0 ? (
2079 <div className="workspace-empty">{t("workspace.noCommitHistory")}</div>
2080 ) : (
2081 <div className="workspace-git-history__list">
2082 {gitHistory.map((commit) => (
2083 <div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
2084 <button className="workspace-git-history__head" onClick={() => void toggleCommit(commit.hash)}>
2085 <div className="workspace-git-history__head-top">
2086 {expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
2087 <span className="workspace-git-history__message">{commit.message}</span>
2088 </div>
2089 <div className="workspace-git-history__head-bottom">
2090 <span className="workspace-git-history__author">{commit.author}</span>
2091 <span className="workspace-git-history__date">
2092 {formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
2093 </span>
2094 </div>
2095 </button>
2096 {expandedCommit === commit.hash && (
2097 <div className="workspace-git-history__detail">
2098 {loadingCommit ? (
2099 <div className="workspace-empty">{t("workspace.loading")}</div>
2100 ) : commitDetail?.diff ? (
2101 <CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
2102 ) : (
2103 <div className="workspace-empty">{t("workspace.noCommitDetail")}</div>
2104 )}
2105 </div>
2106 )}
2107 </div>
2108 ))}
2109 </div>
2110 )
2111 )}
2112 </section>
2113 </div>
2114 ) : !selectedPath ? (
2115 <div className="workspace-empty">{t("workspace.pickFile")}</div>
2116 ) : loadingPreview && !preview ? (
2117 <div className="workspace-empty">{t("workspace.loading")}</div>
2118 ) : preview?.err || (previewErr && !preview) ? (
2119 <div className="workspace-empty workspace-empty--error">
2120 {/no such file|not found|enoent/i.test(previewErr || preview?.err || "") ? t("workspace.fileDeleted") : (previewErr || preview?.err)}
2121 </div>
2122 ) : preview?.kind ? (
2123 <>
2124 {presentedFileStale && (
2125 <div className="workspace-resource-status" role="status">
2126 <span>{t("workspace.fileUpdated")}</span>
2127 <button type="button" className="btn btn--small" onClick={() => void refreshSelected()}>{t("workspace.reload")}</button>
2128 </div>
2129 )}
2130 {loadingPreview && <div className="workspace-resource-status" role="status">{t("workspace.loading")}</div>}
2131 {previewErr && <div className="workspace-resource-status workspace-resource-status--error">{previewErr}</div>}
2132 <WorkspaceMediaPreview preview={preview} />
2133 </>
2134 ) : preview?.binary ? (
2135 <div className="workspace-empty">{t("workspace.binary")}</div>
2136 ) : preview ? (
2137 <>
2138 {presentedFileStale && (
2139 <div className="workspace-resource-status" role="status">
2140 <span>{t("workspace.fileUpdated")}</span>
2141 <button type="button" className="btn btn--small" onClick={() => void refreshSelected()}>{t("workspace.reload")}</button>
2142 </div>
2143 )}
2144 {loadingPreview && <div className="workspace-resource-status" role="status">{t("workspace.loading")}</div>}
2145 {previewErr && <div className="workspace-resource-status workspace-resource-status--error">{previewErr}</div>}
2146 {preview.truncated && !presentedTextPaginationAvailable && <div className="workspace-note">{t("workspace.truncated")}</div>}
2147 {presentedTextPaginationAvailable && (
2148 <div className="workspace-note workspace-note--pagination">
2149 {activePresentedTextTail?.error && <span className="workspace-resource-status--error">{activePresentedTextTail.error}</span>}
2150 {(activePresentedTextTail?.hasMore ?? preview.truncated) ? (
2151 <button
2152 type="button"
2153 className="btn btn--small"
2154 disabled={activePresentedTextTail?.loading}
2155 onClick={() => void loadMorePresentedText()}
2156 >
2157 {activePresentedTextTail?.loading ? t("workspace.loading") : t("workspace.loadMore")}
2158 </button>
2159 ) : <span>{t("workspace.fullFileLoaded")}</span>}
2160 </div>
2161 )}
2162 {renderedAsMarkdown ? (
2163 <Markdown text={previewBody} />
2164 ) : renderedAsCSV ? (
2165 <WorkspaceCsvPreview body={previewBody} delimiter={/\.tsv$/i.test(selectedPath) ? "\t" : ","} />
2166 ) : (
2167 <CodeViewer
2168 value={formatWorkspaceSource(selectedPath, previewBody) || " "}
2169 language={languageFor(selectedPath)}
2170 scrollMode="expand"
2171 sourceSize={preview.size}
2172 showLineNumbers
2173 searchRequestPending={codeSearchRequestPending && codeSearchRequestPath === selectedPath}
2174 onSearchRequestConsumed={consumeCodeSearchRequest}
2175 />
2176 )}
2177 </>
2178 ) : null}
2179 {selectionMenu && (
2180 <FloatingMenu x={selectionMenu.x} y={selectionMenu.y} estimatedHeight={WORKSPACE_CONTEXT_MENU_SELECTION_HEIGHT}>
2181 <FloatingMenuItems
2182 items={[
2183 {
2184 icon: <MessageSquarePlus size={14} />,
2185 label: t("workspace.addSelectionToChat"),
2186 onSelect: addSelectionToChat,
2187 },
2188 ]}
2189 />
2190 </FloatingMenu>
2191 )}
2192 </div>
2193 </section>}
2194
2195 {actualTreeVisible && previewVisible && (
2196 <button
2197 className="workspace-tree-resizer"
2198 type="button"
2199 role="separator"
2200 aria-orientation="vertical"
2201 aria-label={t("workspace.resizeTree")}
2202 aria-valuemin={WORKSPACE_TREE_MIN_WIDTH}
2203 aria-valuemax={maxTreeWidthForPanel}
2204 aria-valuenow={effectiveTreeWidth}
2205 onPointerDown={startTreeResize}
2206 onKeyDown={resizeTreeWithKeyboard}
2207 onDoubleClick={() => setSavedTreeWidth(WORKSPACE_TREE_DEFAULT_WIDTH)}
2208 />
2209 )}
2210
2211 <section className="workspace-files">
2212 {showFileTools && (
2213 <div className={`workspace-files__tools${embeddedDockMode ? " workspace-files__tools--embedded" : ""}`}>
2214 {showViewTabs && (
2215 <Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
2216 <button
2217 className="workspace-iconbtn workspace-iconbtn--on"
2218 type="button"
2219 aria-label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}
2220 onClick={hideTreeOrClosePanel}
2221 >
2222 {previewVisible ? <FolderX size={15} /> : <X size={15} />}
2223 </button>
2224 </Tooltip>
2225 )}
2226 {showViewTabs && (
2227 <div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
2228 <button
2229 className={viewMode === "files" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
2230 onClick={() => setViewMode("files")}
2231 >
2232 {t("workspace.filesTab")}
2233 </button>
2234 <button
2235 className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
2236 onClick={() => {
2237 setViewMode("changed");
2238 }}
2239 >
2240 <GitBranch size={13} />
2241 {t("workspace.changedTab")}
2242 </button>
2243 </div>
2244 )}
2245 <Tooltip label={t("workspace.refreshChanges")}>
2246 <button
2247 className="workspace-iconbtn"
2248 type="button"
2249 aria-label={t("workspace.refreshChanges")}
2250 aria-busy={loadingPreview || loadingHistory}
2251 onClick={() => {
2252 refreshWorkspaceList();
2253 void refreshSelected();
2254 }}
2255 >
2256 <RefreshCw size={14} />
2257 </button>
2258 </Tooltip>
2259 {workspaceRefresh.watchState !== "active" && (
2260 <span
2261 className="workspace-watch-status"
2262 role="status"
2263 title={t(workspaceRefresh.watchState === "degraded" ? "workspace.watchDegraded" : "workspace.watchUnavailable")}
2264 aria-label={t(workspaceRefresh.watchState === "degraded" ? "workspace.watchDegraded" : "workspace.watchUnavailable")}
2265 >
2266
2267 </span>
2268 )}
2269 </div>
2270 )}
2271
2272 <div className="workspace-search">
2273 <Search size={14} />
2274 <input ref={filterRef} value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={searchPlaceholder} />
2275 </div>
2276 {scopedFilePaths && (
2277 <div className="workspace-files__scope">
2278 <span className="workspace-files__scope-title">
2279 {t(externalFileScope ? "workspace.externalFiles" : "context.referencedFiles")}
2280 </span>
2281 <span className="workspace-files__scope-meta">{t("context.readMeta", { count: scopedFilePaths.length })}</span>
2282 <Tooltip label={t("workspace.clearFileScope")}>
2283 <button
2284 type="button"
2285 aria-label={t("workspace.clearFileScope")}
2286 onClick={() => {
2287 dismissedFileListRequestIdRef.current = lastFileListRequestIdRef.current;
2288 setScopedFilePaths(null);
2289 setFilter("");
2290 }}
2291 >
2292 <X size={12} />
2293 </button>
2294 </Tooltip>
2295 </div>
2296 )}
2297 <div
2298 className="workspace-tree"
2299 ref={treeRef}
2300 onScroll={onWorkspaceTreeScroll}
2301 onContextMenu={openTreeBlankMenu}
2302 style={{
2303 height: "100%",
2304 overflow: "auto",
2305 position: "relative",
2306 }}
2307 >
2308 {treeRows.length > 0 ? (
2309 <div
2310 ref={virtualizer.containerRef}
2311 className="workspace-tree__sizer"
2312 style={{
2313 width: "100%",
2314 position: "relative",
2315 }}
2316 >
2317 {virtualTreeItems.map((row) => {
2318 const item = treeRows[row.index];
2319 if (!item) return null;
2320 return (
2321 <div
2322 key={item.key}
2323 data-index={row.index}
2324 ref={virtualizer.measureElement}
2325 style={{
2326 position: "absolute",
2327 top: 0,
2328 left: 0,
2329 width: "100%",
2330 }}
2331 >
2332 <WorkspaceTreeRow
2333 row={item}
2334 onActivate={activateTreeRow}
2335 onDragStart={startTreeDrag}
2336 onContextMenu={openTreeMenu}
2337 />
2338 </div>
2339 );
2340 })}
2341 </div>
2342 ) : null}
2343 </div>
2344 </section>
2345 {treeMenu && (
2346 <WorkspaceTreeMenu
2347 target={treeMenu}
2348 workspaceTabId={workspaceTabId}
2349 isScopeCurrent={() => currentWorkspaceScopeKeyRef.current === workspaceScopeKey}
2350 onClose={() => setTreeMenu(null)}
2351 onOpenInTerminal={onOpenInTerminal}
2352 onAddReference={addTreeReferenceToChat}
2353 onAddFile={() => void addTreeFileToChat()}
2354 />
2355 )}
2356 <ContextMenu
2357 open={Boolean(treeBlankMenuPoint)}
2358 point={treeBlankMenuPoint}
2359 items={treeBlankMenuItems}
2360 minWidth={150}
2361 ariaLabel={t("workspace.treeMenu")}
2362 onClose={() => setTreeBlankMenuPoint(null)}
2363 />
2364 </aside>
2365 </MarkdownImageTabContext.Provider>
2366 );
2367 }
2368
2368 lines Plain Text