返回 DeepSeek-Reasonix
WorkspacePanel.tsx
根目录 / desktop / frontend / src / components / WorkspacePanel.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { useVirtualizer } from "@tanstack/react-virtual";
3 import type {
4 CSSProperties,
5 DragEvent as ReactDragEvent,
6 KeyboardEvent,
7 MouseEvent as ReactMouseEvent,
8 PointerEvent as ReactPointerEvent,
9 ReactElement,
10 } from "react";
11 import {
12 ChevronDown,
13 ChevronRight,
14 FileText,
15 Folder,
16 FolderOpen,
17 FolderTree,
18 FolderX,
19 GitBranch,
20 Maximize2,
21 MessageSquarePlus,
22 Minimize2,
23 RefreshCw,
24 Search,
25 TerminalSquare,
26 X,
27 } from "lucide-react";
28 import { asArray } from "../lib/array";
29 import { app } from "../lib/bridge";
30 import { useT } from "../lib/i18n";
31 import {
32 clampWorkspaceSplitTreeWidth,
33 initialWorkspaceSplitTreeWidth,
34 resolveWorkspaceSplitTreeWidth,
35 shouldInitializeWorkspaceSplitOnFileSelect,
36 type WorkspaceSplitTreeWidthMode,
37 workspaceSplitCanFit,
38 workspaceSplitTreeWidthFromPointer,
39 } from "../lib/workspaceSplit";
40 import { createRafResizeUpdater } from "../lib/resizeDrag";
41 import { closeWorkspacePreviewTab } from "../lib/workspacePreviewTabs";
42 import { shouldScrollWorkspaceTreeSelection } from "../lib/workspaceTreeReveal";
43 import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch";
44 import {
45 readWorkspaceTreeMemory,
46 rememberWorkspaceTreeOpenDirs,
47 touchWorkspaceTreeVisit,
48 workspaceTreeVisitId,
49 } from "../lib/workspaceTreeMemory";
50 import type {
51 DirEntry,
52 FilePreview,
53 GitCommitView,
54 GitCommitDetailView,
55 RewindResultView,
56 WorkspaceChangeDetailView,
57 WorkspaceChangesView,
58 } from "../lib/types";
59 import { workspaceGitStatusLabel } from "../lib/workspaceChanges";
60 import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
61 import { formatSelectionReference, languageFor } from "../lib/selectedTextContext";
62 import { cleanGitDiff } from "../lib/diff";
63 import { CodeViewer } from "./CodeViewer";
64 import { DiffView } from "./DiffView";
65 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
66 import { FloatingMenu, FloatingMenuItems } from "./FloatingMenu";
67 import { Markdown } from "./Markdown";
68 import { Tooltip } from "./Tooltip";
69 import { AnchoredPopover } from "./AnchoredPopover";
70 import { WorkspaceFileIcon } from "./WorkspaceFileIcon";
71
72 const WORKSPACE_TREE_MIN_WIDTH = 140;
73 const WORKSPACE_TREE_DEFAULT_WIDTH = 300;
74 const WORKSPACE_TREE_RAIL_WIDTH = 44;
75 const WORKSPACE_PREVIEW_MIN_WIDTH = 140;
76 const WORKSPACE_PREVIEW_TARGET_WIDTH = 360;
77 const WORKSPACE_DUAL_PANEL_TARGET_WIDTH = WORKSPACE_TREE_DEFAULT_WIDTH + WORKSPACE_PREVIEW_TARGET_WIDTH;
78 const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 136;
79 const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 92;
80 const WORKSPACE_CONTEXT_MENU_SELECTION_HEIGHT = 48;
81 const WORKSPACE_MAX_PREVIEW_TABS = 5;
82
83 type WorkspaceRevealRequest = { id: number; path: string };
84 type WorkspaceFileListRequest = { id: number; paths: string[] };
85 type WorkspaceChangeListEntry = { key: string; path: string; meta: string; time: string; detail: string };
86 type WorkspaceChangeListRequest = { id: number; changes: WorkspaceChangeListEntry[] };
87
88 function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
89 return clampWorkspaceSplitTreeWidth({
90 width,
91 panelWidth,
92 railWidth: WORKSPACE_TREE_RAIL_WIDTH,
93 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
94 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
95 });
96 }
97
98 function entryPath(dir: string, entry: DirEntry): string {
99 const prefix = dir === "" || dir.endsWith("/") ? dir : dir + "/";
100 return prefix + entry.name + (entry.isDir ? "/" : "");
101 }
102
103 function basename(path: string): string {
104 const parts = path.split("/").filter(Boolean);
105 return parts[parts.length - 1] ?? "";
106 }
107
108 function parentPath(path: string): string {
109 const clean = path.replace(/\/$/, "");
110 const parts = clean.split("/").filter(Boolean);
111 return parts.slice(0, -1).join("/");
112 }
113
114 function parentDirs(path: string): string[] {
115 const parts = path.split("/").filter(Boolean);
116 const dirs: string[] = [""];
117 let acc = "";
118 for (let i = 0; i < parts.length - 1; i++) {
119 acc += parts[i] + "/";
120 dirs.push(acc);
121 }
122 return dirs;
123 }
124
125 function topLevelDirPath(path: string): string {
126 const first = path.split("/").find(Boolean);
127 return first ? `${first}/` : "";
128 }
129
130 function renderMediaPreview(preview: FilePreview): ReactElement | null {
131 if (!preview.url) return null;
132 if (preview.kind === "image") {
133 return (
134 <div className="workspace-media workspace-media--image">
135 <img src={preview.url} alt={basename(preview.path)} decoding="async" draggable={false} />
136 </div>
137 );
138 }
139 if (preview.kind === "pdf") {
140 return (
141 <iframe
142 className="workspace-media workspace-media--pdf"
143 src={preview.url}
144 title={basename(preview.path)}
145 />
146 );
147 }
148 return null;
149 }
150
151 function shortCwd(cwd?: string): string {
152 if (!cwd) return "";
153 const parts = cwd.split("/").filter(Boolean);
154 if (parts.length <= 2) return cwd;
155 return "…/" + parts.slice(-2).join("/");
156 }
157
158 function formatBytes(n: number): string {
159 if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
160 if (n >= 1024) return `${Math.ceil(n / 1024)} KB`;
161 return `${n} B`;
162 }
163
164 function formatCommitDate(dateStr: string): string {
165 const d = new Date(dateStr);
166 if (isNaN(d.getTime())) return dateStr;
167 const day = String(d.getDate()).padStart(2, "0");
168 const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
169 const month = monthNames[d.getMonth()];
170 const year = d.getFullYear();
171 const hours = String(d.getHours()).padStart(2, "0");
172 const minutes = String(d.getMinutes()).padStart(2, "0");
173 return `${day} ${month} ${year} ${hours}:${minutes}`;
174 }
175 interface TreeRow {
176 key: string;
177 path: string;
178 depth: number;
179 entry: DirEntry;
180 active: boolean;
181 isOpen?: boolean;
182 isSearch?: boolean;
183 compactPaths?: string[];
184 displayName?: string;
185 }
186
187 export function WorkspacePanel({
188 open,
189 tabId,
190 cwd,
191 maximized,
192 panelWidth,
193 onClose,
194 onToggleMaximized,
195 onPreviewModeChange,
196 onAddToChat,
197 onAddCodeToChat,
198 onOpenInTerminal,
199 onRequestPanelWidth,
200 onFileTreeRefresh,
201 onSessionRevertCommitted,
202 refreshKey,
203 initialViewMode = "files",
204 revealPathRequest,
205 changeRevealRequest,
206 fileListRequest,
207 changeListRequest,
208 showViewTabs = true,
209 workspaceScopeKey: workspaceScopeKeyProp,
210 workspaceMemoryKey: workspaceMemoryKeyProp,
211 workspaceMemoryVisitId: workspaceMemoryVisitIdProp,
212 creationMode = false,
213 }: {
214 open: boolean;
215 tabId?: string;
216 cwd?: string;
217 maximized: boolean;
218 panelWidth?: number;
219 onClose: () => void;
220 onToggleMaximized: () => void;
221 onPreviewModeChange?: (active: boolean) => void;
222 onAddToChat?: (text: string) => void;
223 onAddCodeToChat?: (path: string, code: string) => void;
224 onOpenInTerminal?: (path: string) => void;
225 onRequestPanelWidth?: (width: number) => void;
226 onFileTreeRefresh?: () => void;
227 onSessionRevertCommitted?: (tabId: string, result: RewindResultView) => void;
228 refreshKey?: number;
229 initialViewMode?: "files" | "changed";
230 revealPathRequest?: WorkspaceRevealRequest | null;
231 changeRevealRequest?: WorkspaceRevealRequest | null;
232 fileListRequest?: WorkspaceFileListRequest | null;
233 changeListRequest?: WorkspaceChangeListRequest | null;
234 showViewTabs?: boolean;
235 workspaceScopeKey?: string;
236 workspaceMemoryKey?: string;
237 workspaceMemoryVisitId?: number;
238 creationMode?: boolean;
239 }) {
240 const t = useT();
241 const workspaceTabId = tabId ?? "";
242 const workspaceScopeKey = workspaceScopeKeyProp ?? `${workspaceTabId}\u0000${cwd ?? ""}`;
243 const workspaceMemoryKey = workspaceMemoryKeyProp ?? workspaceScopeKey;
244 const workspaceMemoryVisitId = workspaceMemoryVisitIdProp ?? workspaceTreeVisitId(workspaceMemoryKey);
245 const initialWorkspaceMemory = readWorkspaceTreeMemory(workspaceMemoryKey);
246 const panelRef = useRef<HTMLElement>(null);
247 const treeRef = useRef<HTMLDivElement>(null);
248 const filterRef = useRef<HTMLInputElement>(null);
249 const previewBodyRef = useRef<HTMLDivElement>(null);
250 const [entriesByDir, setEntriesByDir] = useState<Record<string, DirEntry[]>>({});
251 const [openDirs, setOpenDirs] = useState<Set<string>>(
252 () => new Set(initialWorkspaceMemory?.openDirs ?? [""]),
253 );
254 const [revealedRootPaths, setRevealedRootPaths] = useState<Set<string> | null>(
255 () => initialWorkspaceMemory && initialWorkspaceMemory.visitId !== workspaceMemoryVisitId ? new Set() : null,
256 );
257 const [selectedPath, setSelectedPath] = useState<string | null>(null);
258 const [openTabs, setOpenTabs] = useState<string[]>([]);
259 const [preview, setPreview] = useState<FilePreview | null>(null);
260 const [loadingPreview, setLoadingPreview] = useState(false);
261 const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
262 const [gitHistory, setGitHistory] = useState<GitCommitView[]>([]);
263 const [workspaceChanges, setWorkspaceChanges] = useState<WorkspaceChangesView | null>(null);
264 const [changeDetail, setChangeDetail] = useState<WorkspaceChangeDetailView | null>(null);
265 const [loadingChangeDetail, setLoadingChangeDetail] = useState(false);
266 const [changeDetailErr, setChangeDetailErr] = useState("");
267 const [loadingHistory, setLoadingHistory] = useState(false);
268 const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
269 const [commitDetail, setCommitDetail] = useState<GitCommitDetailView | null>(null);
270 const [loadingCommit, setLoadingCommit] = useState(false);
271 const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string; path: string } | null>(null);
272 const [treeMenu, setTreeMenu] = useState<{ x: number; y: number; path: string; isDir: boolean } | null>(null);
273 const [treeBlankMenuPoint, setTreeBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
274 const [filter, setFilter] = useState("");
275 const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
276 const [scopedFilePaths, setScopedFilePaths] = useState<string[] | null>(null);
277 const [scopedChangeRows, setScopedChangeRows] = useState<WorkspaceChangeListEntry[] | null>(null);
278 const [treeVisible, setTreeVisible] = useState(true);
279 const [treeWidth, setTreeWidth] = useState(WORKSPACE_TREE_DEFAULT_WIDTH);
280 const [treeWidthMode, setTreeWidthMode] = useState<WorkspaceSplitTreeWidthMode>("manual");
281 const [treeResizing, setTreeResizing] = useState(false);
282 const [recentOpen, setRecentOpen] = useState(false);
283 const [codeSearchRequestPending, setCodeSearchRequestPending] = useState(false);
284 const [codeSearchRequestPath, setCodeSearchRequestPath] = useState<string | null>(null);
285 /** Changes overview: commit history is secondary and starts collapsed. */
286 const [commitHistoryOpen, setCommitHistoryOpen] = useState(false);
287 const lastPreviewModeActiveRef = useRef<boolean | null>(null);
288 const lastRevealRequestIdRef = useRef<number | null>(null);
289 const dismissedRevealRequestIdRef = useRef<number | null>(null);
290 const lastChangeRevealRequestIdRef = useRef<number | 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 lastWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
298 const workspaceChangesRequestIdRef = useRef(0);
299 const changeDetailRequestIdRef = useRef(0);
300 const gitHistoryRequestIdRef = useRef(0);
301 const commitDetailRequestIdRef = useRef(0);
302 const dirLoadGenerationRef = useRef(0);
303 const dirLoadRequestIdsRef = useRef<Record<string, number>>({});
304 const compactProbeInFlightRef = useRef(new Set<string>());
305 const recentAnchorRef = useRef<HTMLButtonElement>(null);
306 const openDirsRef = useRef(openDirs);
307 const pendingTreeRevealPathRef = useRef<string | null>(null);
308 currentWorkspaceScopeKeyRef.current = workspaceScopeKey;
309
310 useEffect(() => {
311 openDirsRef.current = openDirs;
312 }, [openDirs]);
313
314 const updateOpenDirs = useCallback(
315 (update: (previous: ReadonlySet<string>) => Set<string>) => {
316 setOpenDirs((previous) => {
317 const next = update(previous);
318 rememberWorkspaceTreeOpenDirs(workspaceMemoryKey, next, workspaceMemoryVisitId);
319 return next;
320 });
321 },
322 [workspaceMemoryKey, workspaceMemoryVisitId],
323 );
324
325 useEffect(() => {
326 const remembered = readWorkspaceTreeMemory(workspaceMemoryKey);
327 const nextOpenDirs = new Set(remembered?.openDirs ?? [""]);
328 setOpenDirs(nextOpenDirs);
329 openDirsRef.current = nextOpenDirs;
330 setRevealedRootPaths(remembered && remembered.visitId !== workspaceMemoryVisitId ? new Set() : null);
331 if (remembered) touchWorkspaceTreeVisit(workspaceMemoryKey, workspaceMemoryVisitId);
332 else rememberWorkspaceTreeOpenDirs(workspaceMemoryKey, nextOpenDirs, workspaceMemoryVisitId);
333 }, [workspaceMemoryKey, workspaceMemoryVisitId]);
334
335 const loadDir = useCallback(async (dir: string) => {
336 const requestTabId = workspaceTabId;
337 const requestScopeKey = workspaceScopeKey;
338 const generation = dirLoadGenerationRef.current;
339 const requestId = (dirLoadRequestIdsRef.current[dir] ?? 0) + 1;
340 dirLoadRequestIdsRef.current[dir] = requestId;
341 const entries = asArray(await app.ListDirForTab(requestTabId, dir).catch((): DirEntry[] => []));
342 if (
343 currentWorkspaceScopeKeyRef.current !== requestScopeKey ||
344 dirLoadGenerationRef.current !== generation ||
345 dirLoadRequestIdsRef.current[dir] !== requestId
346 ) return null;
347 setEntriesByDir((prev) => ({ ...prev, [dir]: entries }));
348 return entries;
349 }, [workspaceScopeKey, workspaceTabId]);
350
351 const loadGitHistory = useCallback(async () => {
352 const requestId = ++gitHistoryRequestIdRef.current;
353 const requestTabId = workspaceTabId;
354 const requestScopeKey = workspaceScopeKey;
355 setLoadingHistory(true);
356 try {
357 const result = await app.WorkspaceGitHistory(requestTabId, selectedPath || "");
358 if (gitHistoryRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
359 setGitHistory(result || []);
360 }
361 } catch (err) {
362 if (gitHistoryRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
363 setGitHistory([]);
364 }
365 } finally {
366 if (gitHistoryRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
367 setLoadingHistory(false);
368 }
369 }
370 }, [selectedPath, workspaceScopeKey, workspaceTabId]);
371
372 const loadWorkspaceChanges = useCallback(async () => {
373 const requestId = ++workspaceChangesRequestIdRef.current;
374 const requestTabId = workspaceTabId;
375 const requestScopeKey = workspaceScopeKey;
376 try {
377 const result = await app.WorkspaceChanges(requestTabId);
378 if (workspaceChangesRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
379 setWorkspaceChanges({
380 files: Array.isArray(result?.files) ? result.files : [],
381 gitAvailable: result?.gitAvailable !== false,
382 gitErr: result?.gitErr,
383 gitBranch: result?.gitBranch,
384 });
385 }
386 } catch {
387 if (workspaceChangesRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
388 setWorkspaceChanges({ files: [], gitAvailable: false });
389 }
390 }
391 }, [workspaceScopeKey, workspaceTabId]);
392
393 const loadChangeDetail = useCallback(async () => {
394 const requestId = ++changeDetailRequestIdRef.current;
395 const requestTabId = workspaceTabId;
396 const requestScopeKey = workspaceScopeKey;
397 const requestPath = selectedPath;
398 if (!requestPath) {
399 setChangeDetail(null);
400 setChangeDetailErr("");
401 setLoadingChangeDetail(false);
402 return;
403 }
404 setLoadingChangeDetail(true);
405 setChangeDetailErr("");
406 try {
407 const detail = await app.WorkspaceChangeDetail(requestTabId, requestPath);
408 if (changeDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
409 setChangeDetail(detail ?? null);
410 }
411 } catch (err) {
412 if (changeDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
413 setChangeDetail(null);
414 setChangeDetailErr(String((err as { message?: unknown })?.message ?? err));
415 }
416 } finally {
417 if (changeDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
418 setLoadingChangeDetail(false);
419 }
420 }
421 }, [selectedPath, workspaceScopeKey, workspaceTabId]);
422
423 const toggleCommit = useCallback((hash: string) => {
424 setExpandedCommit((prev) => {
425 const next = prev === hash ? null : hash;
426 if (next) onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
427 return next;
428 });
429 }, [onRequestPanelWidth]);
430
431 useEffect(() => {
432 if (!open) return;
433 if (expandedCommit) {
434 const requestId = ++commitDetailRequestIdRef.current;
435 const requestTabId = workspaceTabId;
436 const requestScopeKey = workspaceScopeKey;
437 let live = true;
438 setLoadingCommit(true);
439 app
440 .WorkspaceGitCommitDetail(requestTabId, expandedCommit, selectedPath || "")
441 .then((detail) => {
442 if (live && commitDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
443 setCommitDetail(detail);
444 }
445 })
446 .catch(() => {
447 if (live && commitDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
448 setCommitDetail(null);
449 }
450 })
451 .finally(() => {
452 if (live && commitDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
453 setLoadingCommit(false);
454 }
455 });
456 return () => {
457 live = false;
458 };
459 } else {
460 commitDetailRequestIdRef.current += 1;
461 setCommitDetail(null);
462 }
463 }, [expandedCommit, selectedPath, open, workspaceScopeKey, workspaceTabId]);
464
465 const selectFile = useCallback(
466 (path: string) => {
467 const initializeSplit = shouldInitializeWorkspaceSplitOnFileSelect({
468 previewVisible: openTabs.length > 0 || selectedPath !== null,
469 treeVisible,
470 });
471 if (initializeSplit) {
472 setTreeWidth(initialWorkspaceSplitTreeWidth({
473 panelWidth,
474 railWidth: WORKSPACE_TREE_RAIL_WIDTH,
475 savedTreeWidth: null,
476 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
477 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
478 }));
479 setTreeWidthMode("even");
480 }
481 pendingTreeRevealPathRef.current = path;
482 setSelectedPath(path);
483 setScopedFilePaths((current) => {
484 if (current) dismissedFileListRequestIdRef.current = lastFileListRequestIdRef.current;
485 return null;
486 });
487 setScopedChangeRows((current) => {
488 if (current) dismissedChangeListRequestIdRef.current = lastChangeListRequestIdRef.current;
489 return null;
490 });
491 setFilter("");
492 setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
493 const dirs = parentDirs(path);
494 updateOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
495 dirs.forEach((dir) => void loadDir(dir));
496 },
497 [loadDir, openTabs.length, panelWidth, selectedPath, treeVisible, updateOpenDirs],
498 );
499
500 useEffect(() => {
501 if (!open) return;
502 dirLoadGenerationRef.current += 1;
503 dirLoadRequestIdsRef.current = {};
504 compactProbeInFlightRef.current.clear();
505 setEntriesByDir({});
506 setSelectedPath(null);
507 setOpenTabs([]);
508 setPreview(null);
509 setGitHistory([]);
510 changeDetailRequestIdRef.current += 1;
511 setChangeDetail(null);
512 setChangeDetailErr("");
513 setLoadingChangeDetail(false);
514 setExpandedCommit(null);
515 setCommitDetail(null);
516 setSelectionMenu(null);
517 setTreeMenu(null);
518 setFilter("");
519 setScopedFilePaths(null);
520 setScopedChangeRows(null);
521 setTreeVisible(true);
522 void loadDir("");
523 }, [cwd, loadDir, open]);
524
525 useEffect(() => {
526 if (!open) return;
527 if (lastWorkspaceScopeKeyRef.current === workspaceScopeKey) return;
528 lastWorkspaceScopeKeyRef.current = workspaceScopeKey;
529 workspaceChangesRequestIdRef.current += 1;
530 changeDetailRequestIdRef.current += 1;
531 gitHistoryRequestIdRef.current += 1;
532 commitDetailRequestIdRef.current += 1;
533 setWorkspaceChanges(null);
534 setChangeDetail(null);
535 setChangeDetailErr("");
536 setLoadingChangeDetail(false);
537 setGitHistory([]);
538 setCommitHistoryOpen(false);
539 setExpandedCommit(null);
540 setCommitDetail(null);
541 setScopedChangeRows(null);
542 lastChangeRevealRequestIdRef.current = null;
543 dismissedChangeRevealRequestIdRef.current = null;
544 lastChangeListRequestIdRef.current = null;
545 dismissedChangeListRequestIdRef.current = null;
546 if (viewMode === "changed") {
547 setSelectedPath(null);
548 setOpenTabs([]);
549 setPreview(null);
550 }
551 }, [open, viewMode, workspaceScopeKey]);
552
553 // A tab/scope switch must discard the floating menus: their text and paths
554 // were captured from the previous scope, while add-to-chat routes to
555 // whatever tab is active at click time — a menu surviving a keyboard tab
556 // switch would add the old scope's selection to the new session.
557 useEffect(() => {
558 setSelectionMenu(null);
559 setTreeMenu(null);
560 setTreeBlankMenuPoint(null);
561 }, [tabId, workspaceScopeKey]);
562
563 useEffect(() => {
564 if (!open) return;
565 setViewMode(initialViewMode);
566 setCommitHistoryOpen(false);
567 setExpandedCommit(null);
568 setCommitDetail(null);
569 changeDetailRequestIdRef.current += 1;
570 setChangeDetail(null);
571 setChangeDetailErr("");
572 setLoadingChangeDetail(false);
573 setSelectionMenu(null);
574 setTreeMenu(null);
575 setRecentOpen(false);
576 if (initialViewMode === "changed") {
577 setScopedFilePaths(null);
578 setSelectedPath(null);
579 setOpenTabs([]);
580 setPreview(null);
581 return;
582 }
583 setScopedChangeRows(null);
584 setTreeVisible(true);
585 }, [initialViewMode, open]);
586
587 useEffect(() => {
588 if (!open || fileListRequest) return;
589 lastFileListRequestIdRef.current = null;
590 dismissedFileListRequestIdRef.current = null;
591 setScopedFilePaths(null);
592 }, [fileListRequest, open]);
593
594 useEffect(() => {
595 if (!open || !fileListRequest) return;
596 const paths = Array.from(new Set(fileListRequest.paths.map((path) => path.trim()).filter(Boolean)));
597 const scopedPathsSettled =
598 scopedFilePaths !== null &&
599 scopedFilePaths.length === paths.length &&
600 scopedFilePaths.every((path, index) => path === paths[index]);
601 if (dismissedFileListRequestIdRef.current === fileListRequest.id) return;
602 if (lastFileListRequestIdRef.current === fileListRequest.id && viewMode === "files" && scopedPathsSettled) return;
603 lastFileListRequestIdRef.current = fileListRequest.id;
604 dismissedFileListRequestIdRef.current = null;
605 if (paths.length === 0) {
606 setScopedFilePaths(null);
607 return;
608 }
609 setViewMode("files");
610 setTreeVisible(true);
611 setScopedFilePaths(paths);
612 setSelectedPath(null);
613 setOpenTabs([]);
614 setPreview(null);
615 setFilter("");
616 setExpandedCommit(null);
617 setCommitDetail(null);
618 setSelectionMenu(null);
619 setTreeMenu(null);
620 const dirs = Array.from(new Set(paths.flatMap(parentDirs)));
621 updateOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
622 dirs.forEach((dir) => void loadDir(dir));
623 }, [fileListRequest, loadDir, open, scopedFilePaths, updateOpenDirs, viewMode]);
624
625 useEffect(() => {
626 if (!open || changeListRequest) return;
627 lastChangeListRequestIdRef.current = null;
628 dismissedChangeListRequestIdRef.current = null;
629 setScopedChangeRows(null);
630 }, [changeListRequest, open]);
631
632 useEffect(() => {
633 if (!open || !changeListRequest) return;
634 const changes = changeListRequest.changes
635 .map((change) => ({ ...change, path: change.path.trim() }))
636 .filter((change) => change.path.length > 0);
637 const scopedChangesSettled =
638 scopedChangeRows !== null &&
639 scopedChangeRows.length === changes.length &&
640 scopedChangeRows.every((change, index) => change.path === changes[index]?.path);
641 if (dismissedChangeListRequestIdRef.current === changeListRequest.id) return;
642 if (lastChangeListRequestIdRef.current === changeListRequest.id && viewMode === "changed" && scopedChangesSettled) return;
643 lastChangeListRequestIdRef.current = changeListRequest.id;
644 dismissedChangeListRequestIdRef.current = null;
645 if (changes.length === 0) {
646 setScopedChangeRows(null);
647 return;
648 }
649 setViewMode("changed");
650 setScopedChangeRows(changes);
651 setScopedFilePaths(null);
652 setSelectedPath(null);
653 setOpenTabs([]);
654 setPreview(null);
655 setFilter("");
656 setExpandedCommit(null);
657 setCommitDetail(null);
658 setSelectionMenu(null);
659 setTreeMenu(null);
660 void loadGitHistory();
661 }, [changeListRequest, loadGitHistory, open, scopedChangeRows, viewMode]);
662
663 useEffect(() => {
664 if (!open || revealPathRequest) return;
665 lastRevealRequestIdRef.current = null;
666 dismissedRevealRequestIdRef.current = null;
667 }, [open, revealPathRequest]);
668
669 useEffect(() => {
670 if (!open || !revealPathRequest) return;
671 if (dismissedRevealRequestIdRef.current === revealPathRequest.id) return;
672 if (
673 lastRevealRequestIdRef.current === revealPathRequest.id &&
674 selectedPath === revealPathRequest.path &&
675 viewMode === "files"
676 ) {
677 return;
678 }
679 lastRevealRequestIdRef.current = revealPathRequest.id;
680 dismissedRevealRequestIdRef.current = null;
681 setViewMode("files");
682 setTreeVisible(true);
683 setScopedFilePaths(null);
684 setScopedChangeRows(null);
685 setExpandedCommit(null);
686 setCommitDetail(null);
687 selectFile(revealPathRequest.path);
688 }, [open, revealPathRequest, selectFile, selectedPath, viewMode]);
689
690 useEffect(() => {
691 if (!open || changeRevealRequest) return;
692 lastChangeRevealRequestIdRef.current = null;
693 dismissedChangeRevealRequestIdRef.current = null;
694 }, [changeRevealRequest, open]);
695
696 useEffect(() => {
697 if (!open || !changeRevealRequest) return;
698 if (dismissedChangeRevealRequestIdRef.current === changeRevealRequest.id) return;
699 if (
700 lastChangeRevealRequestIdRef.current === changeRevealRequest.id &&
701 selectedPath === changeRevealRequest.path &&
702 viewMode === "changed"
703 ) {
704 return;
705 }
706 lastChangeRevealRequestIdRef.current = changeRevealRequest.id;
707 dismissedChangeRevealRequestIdRef.current = null;
708 setViewMode("changed");
709 setScopedFilePaths(null);
710 setScopedChangeRows(null);
711 setSelectedPath(changeRevealRequest.path);
712 setOpenTabs([]);
713 setPreview(null);
714 setFilter("");
715 setExpandedCommit(null);
716 setCommitDetail(null);
717 setSelectionMenu(null);
718 setTreeMenu(null);
719 }, [changeRevealRequest, open, selectedPath, viewMode]);
720
721 useEffect(() => {
722 if (!open) return;
723 if (viewMode === "changed") {
724 void loadGitHistory();
725 void loadWorkspaceChanges();
726 if (selectedPath) void loadChangeDetail();
727 } else {
728 changeDetailRequestIdRef.current += 1;
729 setChangeDetail(null);
730 setChangeDetailErr("");
731 setLoadingChangeDetail(false);
732 }
733 }, [selectedPath, viewMode, loadChangeDetail, loadGitHistory, loadWorkspaceChanges, open]);
734
735 useEffect(() => {
736 if (!open || !refreshKey) return;
737 if (viewMode === "changed") {
738 void loadGitHistory();
739 void loadWorkspaceChanges();
740 if (selectedPath) void loadChangeDetail();
741 }
742 openDirsRef.current.forEach((dir) => void loadDir(dir));
743 }, [loadChangeDetail, loadGitHistory, loadWorkspaceChanges, loadDir, open, refreshKey, selectedPath, viewMode]);
744
745 useEffect(() => {
746 if (!selectionMenu && !treeMenu) return;
747 const close = () => {
748 setSelectionMenu(null);
749 setTreeMenu(null);
750 };
751 const onKey = (event: globalThis.KeyboardEvent) => {
752 if (event.key === "Escape") close();
753 };
754 // Dismiss on mousedown rather than click: the trailing click a drag-selection
755 // emits would otherwise close the toolbar the instant mouseup opens it. A fresh
756 // mousedown only fires when the user starts another interaction, and FloatingMenu
757 // stops propagation so pressing its buttons never counts as an outside press.
758 window.addEventListener("mousedown", close);
759 window.addEventListener("scroll", close, true);
760 window.addEventListener("resize", close);
761 window.addEventListener("keydown", onKey);
762 return () => {
763 window.removeEventListener("mousedown", close);
764 window.removeEventListener("scroll", close, true);
765 window.removeEventListener("resize", close);
766 window.removeEventListener("keydown", onKey);
767 };
768 }, [selectionMenu, treeMenu]);
769
770 const refreshWorkspaceList = useCallback(() => {
771 setTreeBlankMenuPoint(null);
772 setSelectionMenu(null);
773 setTreeMenu(null);
774 if (viewMode === "changed") {
775 void loadGitHistory();
776 void loadWorkspaceChanges();
777 if (selectedPath) void loadChangeDetail();
778 return;
779 }
780 onFileTreeRefresh?.();
781 const dirs = Array.from(openDirsRef.current);
782 dirs.forEach((dir) => void loadDir(dir));
783 }, [loadChangeDetail, loadGitHistory, loadWorkspaceChanges, loadDir, onFileTreeRefresh, selectedPath, viewMode]);
784
785 const refreshSelected = useCallback(() => {
786 if (!selectedPath) return;
787 let live = true;
788 setLoadingPreview(true);
789 app
790 .ReadFileForTab(workspaceTabId, selectedPath)
791 .then((next) => {
792 if (live) setPreview(next);
793 })
794 .catch((err) => {
795 if (live) {
796 setPreview({
797 path: selectedPath,
798 body: "",
799 size: 0,
800 truncated: false,
801 binary: false,
802 err: String(err?.message ?? err),
803 });
804 }
805 })
806 .finally(() => {
807 if (live) setLoadingPreview(false);
808 });
809 return () => {
810 live = false;
811 };
812 }, [selectedPath, workspaceScopeKey, workspaceTabId]);
813
814
815
816 useEffect(() => {
817 if (!open || !selectedPath) return;
818 return refreshSelected();
819 }, [open, refreshSelected, selectedPath]);
820
821 const toggleDir = useCallback(
822 (dir: string, compactPaths: string[] = [dir]) => {
823 const firstPath = compactPaths[0] ?? dir;
824 const rootPath = topLevelDirPath(firstPath);
825 if (revealedRootPaths !== null && !revealedRootPaths.has(rootPath)) {
826 setRevealedRootPaths((current) => new Set([...(current ?? []), rootPath]));
827 const remembered = openDirsRef.current;
828 if (!remembered.has(firstPath)) {
829 updateOpenDirs((previous) => new Set([...previous, ...compactPaths]));
830 compactPaths.forEach((path) => void loadDir(path));
831 return;
832 }
833 remembered.forEach((path) => {
834 if (path === rootPath || path.startsWith(rootPath)) void loadDir(path);
835 });
836 return;
837 }
838
839 const isOpen = openDirsRef.current.has(firstPath);
840 updateOpenDirs((previous) => {
841 const next = new Set(previous);
842 compactPaths.forEach((path) => {
843 if (isOpen) next.delete(path);
844 else next.add(path);
845 });
846 return next;
847 });
848 if (!isOpen) compactPaths.forEach((path) => void loadDir(path));
849 },
850 [loadDir, revealedRootPaths, updateOpenDirs],
851 );
852
853 const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
854 const pathParts = selectedPath?.split("/").filter(Boolean) ?? [];
855 const sessionChanges = useMemo(
856 () => workspaceChanges?.files.filter((c) => c.sources.includes("session")) ?? [],
857 [workspaceChanges],
858 );
859 /** Working-tree files that are not already covered by the session section. */
860 const gitWorkingChanges = useMemo(
861 () =>
862 workspaceChanges?.files.filter(
863 (c) => c.sources.includes("git") && !c.sources.includes("session"),
864 ) ?? [],
865 [workspaceChanges],
866 );
867 const hasFileChanges = sessionChanges.length > 0 || gitWorkingChanges.length > 0;
868 const workspaceGitWarning = workspaceChanges && (!workspaceChanges.gitAvailable || workspaceChanges.gitErr?.trim())
869 ? t("workspace.gitUnavailable")
870 : null;
871
872 const renderChangeScope = (title: string, changes: typeof sessionChanges) => (
873 <div className="workspace-change-scope">
874 <div className="workspace-change-scope__head">
875 <span className="workspace-change-scope__title">{title}</span>
876 <span className="workspace-change-scope__meta">{t("context.changedMeta", { count: changes.length })}</span>
877 </div>
878 <div className="workspace-change-scope__list">
879 {changes.map((change) => {
880 const dir = parentPath(change.path);
881 return (
882 <div key={change.path} className="workspace-change-row">
883 <button
884 className="workspace-change"
885 type="button"
886 onClick={() => selectFile(change.path)}
887 >
888 <FileText size={14} />
889 <span className="workspace-change__body">
890 <span className="workspace-change__name">{basename(change.path)}</span>
891 {dir && <span className="workspace-change__path">{dir}</span>}
892 {change.latestPrompt && <span className="workspace-change__detail">{change.latestPrompt}</span>}
893 </span>
894 <span className="workspace-change__meta">
895 {change.gitStatus && <span className="workspace-change__badge workspace-change__badge--git">{workspaceGitStatusLabel(change.gitStatus, t)}</span>}
896 </span>
897 </button>
898 {change.canSessionRevert && change.sources.includes("session") && (
899 <button
900 type="button"
901 className="workspace-change__revert"
902 title={t("workspace.revertSessionFile")}
903 onClick={(e) => {
904 e.stopPropagation();
905 void (async () => {
906 const plan = await app.PreviewWorkspaceFileRevertForTab(workspaceTabId, change.path);
907 if (!plan?.ok && !plan?.canFiles && !(plan?.conflicts?.length)) {
908 return;
909 }
910 const resolution = plan?.conflicts?.length ? "overwrite_checkpoint" : "";
911 if (plan?.conflicts?.length) {
912 const ok = window.confirm(
913 t("workspace.revertSessionFileConflict", {
914 path: change.path,
915 conflicts: (plan.conflicts || []).join("\n"),
916 }),
917 );
918 if (!ok) return;
919 }
920 const result = await app.CommitWorkspaceFileRevertForTab(
921 workspaceTabId,
922 plan.planId || "",
923 resolution,
924 );
925 if (result?.ok) {
926 onSessionRevertCommitted?.(workspaceTabId, result);
927 void loadWorkspaceChanges();
928 if (selectedPath === change.path) void loadChangeDetail();
929 }
930 })();
931 }}
932 >
933 {t("workspace.revertSessionFileShort")}
934 </button>
935 )}
936 </div>
937 );
938 })}
939 </div>
940 </div>
941 );
942
943 const changedMode = viewMode === "changed";
944 const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile");
945 const currentFileDir = selectedPath ? parentPath(selectedPath) : "";
946 const previewTitle = changedMode && !selectedPath
947 ? scopedChangeRows ? t("context.sessionChanges") : t("workspace.changedTab")
948 : currentFileName;
949 const previewSubtitle = changedMode && !selectedPath
950 ? scopedChangeRows ? t("context.changedMeta", { count: scopedChangeRows.length }) : shortCwd(cwd) || t("workspace.title")
951 : currentFileDir;
952 const recentFiles = useMemo(() => [...openTabs].reverse(), [openTabs]);
953
954 useEffect(() => {
955 const q = filter.trim();
956 if (!open || viewMode === "changed" || !q || scopedFilePaths) {
957 setSearchResults(null);
958 return;
959 }
960 let cancelled = false;
961 app.SearchFileRefsForTab(workspaceTabId, q).then((results) => {
962 if (!cancelled) setSearchResults(asArray(results));
963 }).catch(() => {
964 if (!cancelled) setSearchResults(null);
965 });
966 return () => { cancelled = true; };
967 }, [filter, viewMode, scopedFilePaths, open, workspaceScopeKey, workspaceTabId]);
968
969 const flattened = useMemo(() => {
970 const q = filter.trim().toLowerCase();
971 if (scopedFilePaths) {
972 return scopedFilePaths
973 .map((path) => ({ path, entry: { name: basename(path), isDir: false } }))
974 .filter((row) => !q || row.path.toLowerCase().includes(q))
975 .sort((a, b) => a.path.localeCompare(b.path));
976 }
977 const rows: { path: string; entry: DirEntry }[] = [];
978 for (const [dir, entries] of Object.entries(entriesByDir)) {
979 for (const entry of entries) {
980 rows.push({ path: entryPath(dir, entry), entry });
981 }
982 }
983 if (!q) return null;
984 return mergeWorkspaceSearchResults(rows, searchResults)
985 .filter((row) => row.path.toLowerCase().includes(q))
986 .sort((a, b) => a.path.localeCompare(b.path));
987 }, [entriesByDir, filter, scopedFilePaths, searchResults]);
988
989 const treeRows = useMemo<TreeRow[]>(() => {
990 if (flattened) {
991 return flattened.map(({ path, entry }) => ({
992 key: path,
993 path,
994 depth: 0,
995 entry,
996 active: selectedPath === path,
997 isSearch: true,
998 }));
999 }
1000 const acc: TreeRow[] = [];
1001 const build = (dir: string, depth: number) => {
1002 const entries = entriesByDir[dir] ?? [];
1003 for (const entry of entries) {
1004 const firstPath = entryPath(dir, entry);
1005 if (!entry.isDir) {
1006 acc.push({
1007 key: firstPath,
1008 path: firstPath,
1009 depth,
1010 entry,
1011 active: selectedPath === firstPath,
1012 isOpen: false,
1013 });
1014 continue;
1015 }
1016
1017 const compactPaths = [firstPath];
1018 const compactNames = [entry.name];
1019 let lastPath = firstPath;
1020 let lastEntry = entry;
1021 while (true) {
1022 const children = entriesByDir[lastPath];
1023 if (!children || children.length !== 1 || !children[0]?.isDir) break;
1024 lastEntry = children[0];
1025 lastPath = entryPath(lastPath, lastEntry);
1026 compactPaths.push(lastPath);
1027 compactNames.push(lastEntry.name);
1028 }
1029 const rootPath = topLevelDirPath(firstPath);
1030 const isRevealed = revealedRootPaths === null || revealedRootPaths.has(rootPath);
1031 const isOpen = isRevealed && openDirs.has(firstPath);
1032 acc.push({
1033 key: lastPath,
1034 path: lastPath,
1035 depth,
1036 entry: lastEntry,
1037 active: selectedPath === lastPath,
1038 isOpen,
1039 compactPaths,
1040 displayName: compactNames.join(" / "),
1041 });
1042 if (isOpen) {
1043 build(lastPath, depth + 1);
1044 }
1045 }
1046 };
1047 build("", 0);
1048 return acc;
1049 }, [flattened, entriesByDir, openDirs, revealedRootPaths, selectedPath]);
1050 const getTreeRowKey = useCallback((index: number) => treeRows[index]?.key ?? index, [treeRows]);
1051
1052 const virtualizer = useVirtualizer({
1053 count: treeRows.length,
1054 getScrollElement: () => treeRef.current,
1055 estimateSize: () => 24,
1056 getItemKey: getTreeRowKey,
1057 overscan: 10,
1058 directDomUpdates: true,
1059 });
1060 const virtualTreeItems = virtualizer.getVirtualItems();
1061 const compactProbePaths = virtualTreeItems
1062 .map((item) => treeRows[item.index])
1063 .filter((row): row is TreeRow => Boolean(row?.entry.isDir && entriesByDir[row.path] === undefined))
1064 .map((row) => row.path);
1065 const compactProbeKey = compactProbePaths.join("\u0000");
1066
1067 useEffect(() => {
1068 if (!open || !compactProbeKey) return;
1069 compactProbePaths.forEach((path) => {
1070 if (compactProbeInFlightRef.current.has(path)) return;
1071 compactProbeInFlightRef.current.add(path);
1072 void loadDir(path).finally(() => compactProbeInFlightRef.current.delete(path));
1073 });
1074 }, [compactProbeKey, loadDir, open]);
1075
1076 const searchPlaceholder = t(scopedFilePaths ? "workspace.filterReferencedFiles" : changedMode ? "workspace.filterChanges" : "workspace.filter");
1077
1078 const filePreviewActive = openTabs.length > 0 || selectedPath !== null;
1079 const changeDetailActive = changedMode && expandedCommit !== null;
1080 const previewVisible = changedMode || filePreviewActive;
1081 const showTreeRail = previewVisible && !changedMode;
1082 const splitPanesFit = useMemo(
1083 () =>
1084 workspaceSplitCanFit({
1085 panelWidth,
1086 railWidth: WORKSPACE_TREE_RAIL_WIDTH,
1087 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
1088 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
1089 }),
1090 [panelWidth],
1091 );
1092 const actualTreeVisible = changedMode ? false : treeVisible && (!previewVisible || splitPanesFit);
1093 const previewModeActive = open && (filePreviewActive || changeDetailActive);
1094 const embeddedDockMode = !showViewTabs;
1095 const showFileTools = showViewTabs || filePreviewActive;
1096 const effectiveTreeWidth = useMemo(
1097 () =>
1098 resolveWorkspaceSplitTreeWidth({
1099 mode: treeWidthMode,
1100 currentTreeWidth: treeWidth,
1101 panelWidth,
1102 railWidth: WORKSPACE_TREE_RAIL_WIDTH,
1103 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
1104 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
1105 }),
1106 [panelWidth, treeWidth, treeWidthMode],
1107 );
1108 const maxTreeWidthForPanel = useMemo(
1109 () => Math.max(WORKSPACE_TREE_MIN_WIDTH, (panelWidth ?? WORKSPACE_DUAL_PANEL_TARGET_WIDTH) - WORKSPACE_TREE_RAIL_WIDTH - WORKSPACE_PREVIEW_MIN_WIDTH),
1110 [panelWidth],
1111 );
1112
1113 useEffect(() => {
1114 const pendingRevealPath = pendingTreeRevealPathRef.current;
1115 if (!pendingRevealPath) return;
1116 if (!selectedPath || pendingRevealPath !== selectedPath) {
1117 pendingTreeRevealPathRef.current = null;
1118 return;
1119 }
1120 const selectedIndex = treeRows.findIndex((row) => row.path === selectedPath);
1121 if (!shouldScrollWorkspaceTreeSelection({ selectedPath, pendingRevealPath, actualTreeVisible, selectedIndex })) return;
1122 virtualizer.scrollToIndex(selectedIndex, { align: "auto" });
1123 pendingTreeRevealPathRef.current = null;
1124 }, [selectedPath, actualTreeVisible, treeRows, virtualizer]);
1125
1126 const panelStyle = useMemo(
1127 () =>
1128 ({
1129 "--workspace-tree-width": `${effectiveTreeWidth}px`,
1130 "--workspace-preview-min-width": `${WORKSPACE_PREVIEW_MIN_WIDTH}px`,
1131 }) as CSSProperties,
1132 [effectiveTreeWidth],
1133 );
1134
1135 useEffect(() => {
1136 if (lastPreviewModeActiveRef.current === previewModeActive) return;
1137 lastPreviewModeActiveRef.current = previewModeActive;
1138 onPreviewModeChange?.(previewModeActive);
1139 }, [onPreviewModeChange, previewModeActive]);
1140
1141 useEffect(() => {
1142 if (open && !treeVisible && !previewVisible) onClose();
1143 }, [onClose, open, previewVisible, treeVisible]);
1144
1145 const hideTreeOrClosePanel = useCallback(() => {
1146 if (previewVisible) {
1147 setTreeVisible(false);
1148 } else {
1149 onClose();
1150 }
1151 }, [onClose, previewVisible]);
1152
1153 const showTreeEvenSplit = useCallback(() => {
1154 setTreeWidth(initialWorkspaceSplitTreeWidth({
1155 panelWidth,
1156 railWidth: WORKSPACE_TREE_RAIL_WIDTH,
1157 savedTreeWidth: null,
1158 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
1159 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
1160 }));
1161 setTreeWidthMode("even");
1162 setTreeVisible(true);
1163 }, [panelWidth]);
1164
1165 const toggleTreeRail = useCallback(() => {
1166 if (actualTreeVisible) {
1167 setTreeVisible(false);
1168 return;
1169 }
1170 showTreeEvenSplit();
1171 }, [actualTreeVisible, showTreeEvenSplit]);
1172
1173 const closePreviewArea = useCallback(() => {
1174 if (lastRevealRequestIdRef.current === revealPathRequest?.id) {
1175 dismissedRevealRequestIdRef.current = revealPathRequest.id;
1176 }
1177 if (lastChangeRevealRequestIdRef.current === changeRevealRequest?.id) {
1178 dismissedChangeRevealRequestIdRef.current = changeRevealRequest.id;
1179 }
1180 const nextPreviewTabs = closeWorkspacePreviewTab(openTabs, selectedPath);
1181 setSelectedPath(nextPreviewTabs.selectedPath);
1182 setOpenTabs(nextPreviewTabs.openTabs);
1183 setPreview(null);
1184 setSelectionMenu(null);
1185 setTreeMenu(null);
1186 setRecentOpen(false);
1187 setTreeVisible(true);
1188 }, [changeRevealRequest, openTabs, revealPathRequest, selectedPath]);
1189
1190 const setSavedTreeWidth = useCallback(
1191 (width: number) => {
1192 const next = clampWorkspaceTreeWidth(width, panelWidth);
1193 setTreeWidth(next);
1194 setTreeWidthMode("manual");
1195 },
1196 [panelWidth],
1197 );
1198
1199 const startTreeResize = useCallback(
1200 (event: ReactPointerEvent<HTMLButtonElement>) => {
1201 if (!treeVisible) return;
1202 const panel = panelRef.current;
1203 const rect = panel?.getBoundingClientRect();
1204 if (!panel || !rect) return;
1205 event.preventDefault();
1206 const committedTreeWidth = clampWorkspaceTreeWidth(effectiveTreeWidth, panelWidth);
1207 setTreeWidth(committedTreeWidth);
1208 setTreeWidthMode("manual");
1209 setTreeResizing(true);
1210 let nextWidth = committedTreeWidth;
1211 const liveResize = createRafResizeUpdater({
1212 target: panel,
1213 separator: event.currentTarget,
1214 cssVar: "--workspace-tree-width",
1215 });
1216 const onMove = (moveEvent: PointerEvent) => {
1217 nextWidth = workspaceSplitTreeWidthFromPointer({
1218 clientX: moveEvent.clientX,
1219 panelLeft: rect.left,
1220 panelWidth: rect.width,
1221 railWidth: WORKSPACE_TREE_RAIL_WIDTH,
1222 treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
1223 previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
1224 });
1225 liveResize.schedule(nextWidth);
1226 };
1227 const onDone = () => {
1228 liveResize.flush();
1229 setTreeWidth(nextWidth);
1230 setTreeResizing(false);
1231 window.removeEventListener("pointermove", onMove);
1232 window.removeEventListener("pointerup", onDone);
1233 window.removeEventListener("pointercancel", onDone);
1234 document.body.style.cursor = "";
1235 document.body.style.userSelect = "";
1236 };
1237 document.body.style.cursor = "col-resize";
1238 document.body.style.userSelect = "none";
1239 window.addEventListener("pointermove", onMove);
1240 window.addEventListener("pointerup", onDone);
1241 window.addEventListener("pointercancel", onDone);
1242 },
1243 [effectiveTreeWidth, panelWidth, treeVisible],
1244 );
1245
1246 const resizeTreeWithKeyboard = useCallback(
1247 (event: KeyboardEvent<HTMLButtonElement>) => {
1248 if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
1249 event.preventDefault();
1250 setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowRight" ? 16 : -16));
1251 } else if (event.key === "Home") {
1252 event.preventDefault();
1253 setSavedTreeWidth(WORKSPACE_TREE_MIN_WIDTH);
1254 } else if (event.key === "End") {
1255 event.preventDefault();
1256 setSavedTreeWidth(maxTreeWidthForPanel);
1257 }
1258 },
1259 [effectiveTreeWidth, maxTreeWidthForPanel, setSavedTreeWidth],
1260 );
1261
1262 useEffect(() => {
1263 setCodeSearchRequestPending(false);
1264 setCodeSearchRequestPath(null);
1265 }, [selectedPath]);
1266
1267 const consumeCodeSearchRequest = useCallback(() => {
1268 setCodeSearchRequestPending(false);
1269 }, []);
1270
1271 if (!open) return null;
1272
1273 const selectedTextFromPreview = (): string => {
1274 const root = previewBodyRef.current;
1275 const selection = typeof window === "undefined" ? null : window.getSelection();
1276 if (!root || !selection || selection.rangeCount === 0) return "";
1277 const range = selection.getRangeAt(0);
1278 const container = range.commonAncestorContainer;
1279 const node = container instanceof Element ? container : container.parentElement;
1280 if (!node || !root.contains(node)) return "";
1281 return selection.toString();
1282 };
1283
1284 const openSelectionMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
1285 if (!selectedPath || loadingPreview || preview?.err || preview?.binary || preview?.kind) return;
1286 const text = selectedTextFromPreview();
1287 if (text.trim() === "") return;
1288 event.preventDefault();
1289 event.stopPropagation();
1290 setSelectionMenu({ x: event.clientX, y: event.clientY, text, path: selectedPath });
1291 };
1292
1293 // Selecting code with the mouse pops the "Add to Chat" button right away,
1294 // so a snippet is one click from the composer instead of right-click →
1295 // menu item. The right-click menu (openSelectionMenu) stays as a fallback.
1296 const showSelectionToolbar = (event: ReactMouseEvent<HTMLDivElement>) => {
1297 // Mouseup on the floating button bubbles back here through the portal's
1298 // React tree; let the button handle it.
1299 if ((event.target as HTMLElement | null)?.closest(".floating-menu")) return;
1300 if (!selectedPath || loadingPreview || preview?.err || preview?.binary || preview?.kind) return;
1301 const text = selectedTextFromPreview();
1302 if (text.trim() === "") return;
1303 setSelectionMenu({ x: event.clientX, y: event.clientY + 8, text, path: selectedPath });
1304 };
1305
1306 const addSelectionToChat = () => {
1307 if (!selectionMenu) return;
1308 if (onAddCodeToChat) onAddCodeToChat(selectionMenu.path, selectionMenu.text);
1309 else onAddToChat?.(formatSelectionReference(selectionMenu.path, selectionMenu.text));
1310 setSelectionMenu(null);
1311 };
1312
1313 const openTreeMenu = (event: ReactMouseEvent<HTMLElement>, path: string, isDir: boolean) => {
1314 event.preventDefault();
1315 event.stopPropagation();
1316 setTreeBlankMenuPoint(null);
1317 setSelectionMenu(null);
1318 setTreeMenu({ x: event.clientX, y: event.clientY, path, isDir });
1319 };
1320
1321 const openTreeBlankMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
1322 const target = event.target as HTMLElement | null;
1323 if (target?.closest(".workspace-tree__row,.workspace-change,button,input,textarea,select")) return;
1324 event.preventDefault();
1325 event.stopPropagation();
1326 setSelectionMenu(null);
1327 setTreeMenu(null);
1328 setTreeBlankMenuPoint(contextMenuPointFromEvent(event));
1329 };
1330
1331 const startTreeDrag = (event: ReactDragEvent<HTMLElement>, path: string, isDir: boolean) => {
1332 const ref = formatWorkspaceReference(path, isDir);
1333 event.dataTransfer.effectAllowed = "copy";
1334 event.dataTransfer.setData(WORKSPACE_REF_DRAG_TYPE, JSON.stringify({ path, isDir }));
1335 event.dataTransfer.setData("text/plain", ref);
1336 };
1337
1338 const addTreeReferenceToChat = () => {
1339 if (!treeMenu) return;
1340 onAddToChat?.(formatWorkspaceReference(treeMenu.path, treeMenu.isDir));
1341 setTreeMenu(null);
1342 };
1343
1344 const addTreeFileToChat = async () => {
1345 if (!treeMenu || treeMenu.isDir) return;
1346 const target = treeMenu;
1347 const requestTabId = workspaceTabId;
1348 const requestScopeKey = workspaceScopeKey;
1349 setTreeMenu(null);
1350 try {
1351 const file = await app.ReadFileForTab(requestTabId, target.path);
1352 if (currentWorkspaceScopeKeyRef.current !== requestScopeKey) return;
1353 if (file.err || file.binary || file.kind) {
1354 onAddToChat?.(formatWorkspaceReference(target.path, false));
1355 return;
1356 }
1357 const body = file.truncated ? `${file.body}\n\n${t("workspace.truncated")}` : file.body;
1358 if (onAddCodeToChat) onAddCodeToChat(target.path, body);
1359 else onAddToChat?.(formatSelectionReference(target.path, body));
1360 } catch {
1361 if (currentWorkspaceScopeKeyRef.current !== requestScopeKey) return;
1362 onAddToChat?.(formatWorkspaceReference(target.path, false));
1363 }
1364 };
1365
1366 const revealInFileManager = () => {
1367 if (!treeMenu) return;
1368 setTreeMenu(null);
1369 void app.RevealWorkspacePathForTab(workspaceTabId, treeMenu.path).catch(() => {});
1370 };
1371
1372 const renderNormalRow = (row: TreeRow) => {
1373 const { path, depth, entry, isOpen, active, compactPaths = [path], displayName = entry.name } = row;
1374 return (
1375 <button
1376 key={path}
1377 className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
1378 data-workspace-path={path}
1379 draggable
1380 onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
1381 onClick={() => {
1382 if (entry.isDir) {
1383 toggleDir(path, compactPaths);
1384 } else {
1385 if (selectedPath === path) {
1386 setSelectedPath(null);
1387 } else {
1388 selectFile(path);
1389 }
1390 }
1391 }}
1392 onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
1393 style={{ paddingLeft: 8 + depth * 14 }}
1394 >
1395 {depth > 0 && (
1396 <span className="workspace-tree__guides" aria-hidden="true">
1397 {Array.from({ length: depth }, (_, index) => (
1398 <span
1399 className="workspace-tree__guide"
1400 key={index}
1401 style={{ left: 14 + index * 14 }}
1402 />
1403 ))}
1404 </span>
1405 )}
1406 {entry.isDir ? (
1407 <ChevronRight
1408 size={13}
1409 className={`workspace-tree__chev ${isOpen ? "workspace-tree__chev--open" : ""}`}
1410 style={{
1411 transition: "transform 0.15s ease",
1412 transform: isOpen ? "rotate(90deg)" : "rotate(0deg)",
1413 }}
1414 />
1415 ) : (
1416 <span className="workspace-tree__chev" />
1417 )}
1418 {entry.isDir ? (
1419 <Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
1420 ) : (
1421 <WorkspaceFileIcon fileName={entry.name} />
1422 )}
1423 <span className="workspace-tree__name">{displayName}</span>
1424 </button>
1425 );
1426 };
1427
1428 const renderSearchRow = (row: TreeRow) => {
1429 const { path, entry, active } = row;
1430 const dir = parentPath(path);
1431 return (
1432 <button
1433 key={path}
1434 className={`workspace-tree__row workspace-tree__row--search${active ? " workspace-tree__row--active" : ""}`}
1435 data-workspace-path={path}
1436 draggable
1437 onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
1438 onClick={() => {
1439 if (entry.isDir) {
1440 toggleDir(path);
1441 } else {
1442 if (selectedPath === path) {
1443 setSelectedPath(null);
1444 } else {
1445 selectFile(path);
1446 }
1447 }
1448 }}
1449 onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
1450 >
1451 {entry.isDir ? (
1452 <Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
1453 ) : (
1454 <WorkspaceFileIcon fileName={entry.name} />
1455 )}
1456 <span className="workspace-tree__result">
1457 <span className="workspace-tree__result-name">{basename(path)}</span>
1458 {dir && <span className="workspace-tree__result-dir">{dir}</span>}
1459 </span>
1460 </button>
1461 );
1462 };
1463
1464 const isMarkdown = selectedPath?.toLowerCase().endsWith(".md") ?? false;
1465 const codePreviewActive = Boolean(
1466 selectedPath &&
1467 !changedMode &&
1468 preview &&
1469 !loadingPreview &&
1470 !preview.err &&
1471 !preview.kind &&
1472 !preview.binary &&
1473 !isMarkdown,
1474 );
1475 const openCodeSearch = () => {
1476 if (!codePreviewActive || !selectedPath) return;
1477 setCodeSearchRequestPath(selectedPath);
1478 setCodeSearchRequestPending(true);
1479 };
1480 const treeBlankMenuItems: ContextMenuItem[] = [
1481 {
1482 key: "refresh-tree",
1483 icon: <RefreshCw size={13} />,
1484 label: t(viewMode === "changed" ? "workspace.refreshChanges" : "workspace.refreshTree"),
1485 onSelect: refreshWorkspaceList,
1486 },
1487 ];
1488
1489 return (
1490 <aside
1491 ref={panelRef}
1492 className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${showTreeRail ? " workspace-panel--with-tree-rail" : ""}${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" : ""}`}
1493 aria-label={t("workspace.title")}
1494 style={panelStyle}
1495 onKeyDownCapture={(event) => {
1496 if (
1497 codePreviewActive
1498 && (event.ctrlKey || event.metaKey)
1499 && event.key.toLowerCase() === "f"
1500 ) {
1501 event.preventDefault();
1502 event.stopPropagation();
1503 openCodeSearch();
1504 }
1505 }}
1506 >
1507 {previewVisible && <section className="workspace-preview">
1508 <header className="workspace-preview__head">
1509 <div className="workspace-current-file" aria-label={t("workspace.currentFile")}>
1510 {changedMode && !selectedPath ? (
1511 <GitBranch size={15} className="workspace-current-file__icon" />
1512 ) : (
1513 <FileText size={15} className="workspace-current-file__icon" />
1514 )}
1515 <div className="workspace-current-file__text">
1516 <Tooltip label={selectedPath ?? undefined}>
1517 <span className="workspace-current-file__name">{previewTitle}</span>
1518 </Tooltip>
1519 {previewSubtitle && <span className="workspace-current-file__path">{previewSubtitle}</span>}
1520 </div>
1521 <Tooltip label={t("workspace.recentFiles")}>
1522 <button
1523 ref={recentAnchorRef}
1524 className={`workspace-current-file__recent${recentOpen ? " workspace-current-file__recent--open" : ""}`}
1525 type="button"
1526 aria-label={t("workspace.recentFiles")}
1527 aria-expanded={recentOpen}
1528 onClick={() => setRecentOpen((open) => !open)}
1529 >
1530 <ChevronDown size={13} />
1531 </button>
1532 </Tooltip>
1533 </div>
1534
1535 <div className="workspace-preview__window-actions">
1536 {codePreviewActive && (
1537 <Tooltip label={t("workspace.searchPlaceholder")}>
1538 <button
1539 className="workspace-iconbtn"
1540 type="button"
1541 aria-label={t("workspace.searchPlaceholder")}
1542 aria-keyshortcuts="Control+F Meta+F"
1543 onClick={openCodeSearch}
1544 >
1545 <Search size={15} />
1546 </button>
1547 </Tooltip>
1548 )}
1549 <Tooltip label={maximized ? t("workspace.restore") : t("workspace.maximize")}>
1550 <button className="workspace-iconbtn" onClick={onToggleMaximized}>
1551 {maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
1552 </button>
1553 </Tooltip>
1554 {selectedPath && (
1555 <Tooltip label={t("workspace.closePreview")}>
1556 <button className="workspace-iconbtn" onClick={closePreviewArea}>
1557 <X size={15} />
1558 </button>
1559 </Tooltip>
1560 )}
1561 </div>
1562 <AnchoredPopover
1563 open={recentOpen}
1564 anchorRef={recentAnchorRef}
1565 onClose={() => setRecentOpen(false)}
1566 className="workspace-recent-menu"
1567 align="start"
1568 offset={6}
1569 placement="bottom"
1570 >
1571 <div className="workspace-recent-menu__title">{t("workspace.recentFiles")}</div>
1572 <div className="workspace-recent-menu__list">
1573 {recentFiles.map((path) => (
1574 <button
1575 key={path}
1576 type="button"
1577 className={`workspace-recent-menu__item${path === selectedPath ? " workspace-recent-menu__item--active" : ""}`}
1578 onClick={() => {
1579 setSelectedPath(path);
1580 setRecentOpen(false);
1581 }}
1582 >
1583 <FileText size={14} />
1584 <span>
1585 <span className="workspace-recent-menu__name">{basename(path)}</span>
1586 <span className="workspace-recent-menu__path">{parentPath(path)}</span>
1587 </span>
1588 </button>
1589 ))}
1590 </div>
1591 </AnchoredPopover>
1592 </header>
1593
1594 <div className="workspace-preview__meta">
1595 <Tooltip label={cwd}>
1596 <button
1597 className="workspace-crumb"
1598 onClick={() => {
1599 setFilter("");
1600 showTreeEvenSplit();
1601 updateOpenDirs((prev) => new Set([...Array.from(prev), ""]));
1602 void loadDir("");
1603 }}
1604 >
1605 {shortCwd(cwd) || t("workspace.title")}
1606 </button>
1607 </Tooltip>
1608 {pathParts.map((part, index) => {
1609 const isLast = index === pathParts.length - 1;
1610 const dir = pathParts.slice(0, index + 1).join("/") + "/";
1611 return (
1612 <span className="workspace-crumb-group" key={`${part}-${index}`}>
1613 <span>›</span>
1614 <Tooltip label={isLast ? (selectedPath ?? undefined) : dir}>
1615 <button
1616 className={`workspace-crumb${isLast ? " workspace-crumb--current" : ""}`}
1617 onClick={() => {
1618 if (isLast) return;
1619 showTreeEvenSplit();
1620 setFilter("");
1621 updateOpenDirs((prev) => new Set([...Array.from(prev), ...breadcrumbDirs, dir]));
1622 void loadDir(dir);
1623 }}
1624 >
1625 {part}
1626 </button>
1627 </Tooltip>
1628 </span>
1629 );
1630 })}
1631 {preview && preview.size > 0 && <span className="workspace-preview__size">{formatBytes(preview.size)}</span>}
1632 </div>
1633
1634 <div
1635 className={`workspace-preview__body${codePreviewActive ? " workspace-preview__body--code" : ""}`}
1636 ref={previewBodyRef}
1637 onContextMenu={openSelectionMenu}
1638 onMouseUp={showSelectionToolbar}
1639 >
1640 {viewMode === "changed" && scopedChangeRows ? (
1641 <div className="workspace-change-scope">
1642 <div className="workspace-change-scope__head">
1643 <span className="workspace-change-scope__title">{t("context.sessionChanges")}</span>
1644 <span className="workspace-change-scope__meta">{t("context.changedMeta", { count: scopedChangeRows.length })}</span>
1645 <Tooltip label={t("workspace.clearChangeScope")}>
1646 <button
1647 type="button"
1648 aria-label={t("workspace.clearChangeScope")}
1649 onClick={() => {
1650 dismissedChangeListRequestIdRef.current = lastChangeListRequestIdRef.current;
1651 setScopedChangeRows(null);
1652 setSelectedPath(null);
1653 setExpandedCommit(null);
1654 setCommitDetail(null);
1655 void loadGitHistory();
1656 }}
1657 >
1658 <X size={12} />
1659 </button>
1660 </Tooltip>
1661 </div>
1662 <div className="workspace-change-scope__list">
1663 {scopedChangeRows.map((change) => {
1664 const dir = parentPath(change.path);
1665 return (
1666 <button
1667 key={change.key}
1668 className="workspace-change"
1669 type="button"
1670 onClick={() => {
1671 dismissedChangeListRequestIdRef.current = lastChangeListRequestIdRef.current;
1672 setScopedChangeRows(null);
1673 selectFile(change.path);
1674 }}
1675 >
1676 <FileText size={14} />
1677 <span className="workspace-change__body">
1678 <span className="workspace-change__name">{basename(change.path)}</span>
1679 {dir && <span className="workspace-change__path">{dir}</span>}
1680 {change.detail && <span className="workspace-change__detail">{change.detail}</span>}
1681 </span>
1682 <span className="workspace-change__meta">
1683 <span className="workspace-change__badge workspace-change__badge--git">{change.meta}</span>
1684 {change.time && <span className="workspace-change__badge">{change.time}</span>}
1685 </span>
1686 </button>
1687 );
1688 })}
1689 </div>
1690 </div>
1691 ) : viewMode === "changed" && !selectedPath ? (
1692 <div className="workspace-git-history">
1693 {workspaceGitWarning && (
1694 <div className="workspace-note workspace-note--warning" role="status">
1695 {workspaceGitWarning}
1696 </div>
1697 )}
1698 {creationMode ? (
1699 <>
1700 {sessionChanges.length > 0 && renderChangeScope(t("context.sessionChanges"), sessionChanges)}
1701 {gitWorkingChanges.length > 0 && renderChangeScope(t("workspace.workingChanges"), gitWorkingChanges)}
1702 {!loadingHistory && !hasFileChanges && !workspaceGitWarning && (
1703 <div className="workspace-empty">{t("context.noChanges")}</div>
1704 )}
1705 {loadingHistory ? (
1706 <div className="workspace-empty">{t("workspace.loading")}</div>
1707 ) : gitHistory.length > 0 ? (
1708 <section className={`workspace-commit-history${commitHistoryOpen ? " workspace-commit-history--open" : ""}`}>
1709 <button
1710 className="workspace-commit-history__toggle"
1711 type="button"
1712 aria-expanded={commitHistoryOpen}
1713 onClick={() => {
1714 setCommitHistoryOpen((open) => !open);
1715 setExpandedCommit(null);
1716 }}
1717 >
1718 {commitHistoryOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
1719 <span>{t("workspace.commitHistory")}</span>
1720 <small>{t("workspace.commitHistoryMeta", { count: gitHistory.length })}</small>
1721 </button>
1722 {commitHistoryOpen && (
1723 <div className="workspace-git-history__list">
1724 {gitHistory.map((commit) => (
1725 <div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
1726 <button
1727 className="workspace-git-history__head"
1728 onClick={() => void toggleCommit(commit.hash)}
1729 >
1730 <div className="workspace-git-history__head-top">
1731 {expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
1732 <span className="workspace-git-history__message">{commit.message}</span>
1733 </div>
1734 <div className="workspace-git-history__head-bottom">
1735 <span className="workspace-git-history__author">{commit.author}</span>
1736 <span className="workspace-git-history__date">
1737 {formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
1738 </span>
1739 </div>
1740 </button>
1741 {expandedCommit === commit.hash && (
1742 <div className="workspace-git-history__detail">
1743 {loadingCommit ? (
1744 <div className="workspace-empty">{t("workspace.loading")}</div>
1745 ) : commitDetail?.diff ? (
1746 <CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
1747 ) : commitDetail?.files ? (
1748 <div className="workspace-git-history__files">
1749 {commitDetail.files.map((file) => (
1750 <button
1751 key={file}
1752 className="workspace-git-history__file"
1753 onClick={() => selectFile(file)}
1754 >
1755 <FileText size={14} /> {file}
1756 </button>
1757 ))}
1758 </div>
1759 ) : (
1760 <div className="workspace-empty">No details available</div>
1761 )}
1762 </div>
1763 )}
1764 </div>
1765 ))}
1766 </div>
1767 )}
1768 </section>
1769 ) : null}
1770 </>
1771 ) : (
1772 <>
1773 {sessionChanges.length > 0 && renderChangeScope(t("workspace.changedTab"), sessionChanges)}
1774 {gitWorkingChanges.length > 0 && renderChangeScope(t("workspace.workingChanges"), gitWorkingChanges)}
1775 {loadingHistory ? (
1776 <div className="workspace-empty">{t("workspace.loading")}</div>
1777 ) : gitHistory.length === 0 && !hasFileChanges ? (
1778 <div className="workspace-empty">{workspaceGitWarning ? t("workspace.gitChangesUnknown") : t("workspace.noChanges")}</div>
1779 ) : (
1780 <div className="workspace-git-history__list">
1781 {gitHistory.map((commit) => (
1782 <div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
1783 <button
1784 className="workspace-git-history__head"
1785 onClick={() => void toggleCommit(commit.hash)}
1786 >
1787 <div className="workspace-git-history__head-top">
1788 {expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
1789 <span className="workspace-git-history__message">{commit.message}</span>
1790 </div>
1791 <div className="workspace-git-history__head-bottom">
1792 <span className="workspace-git-history__author">{commit.author}</span>
1793 <span className="workspace-git-history__date">
1794 {formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
1795 </span>
1796 </div>
1797 </button>
1798 {expandedCommit === commit.hash && (
1799 <div className="workspace-git-history__detail">
1800 {loadingCommit ? (
1801 <div className="workspace-empty">{t("workspace.loading")}</div>
1802 ) : commitDetail?.diff ? (
1803 <CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
1804 ) : commitDetail?.files ? (
1805 <div className="workspace-git-history__files">
1806 {commitDetail.files.map((file) => (
1807 <button
1808 key={file}
1809 className="workspace-git-history__file"
1810 onClick={() => selectFile(file)}
1811 >
1812 <FileText size={14} /> {file}
1813 </button>
1814 ))}
1815 </div>
1816 ) : (
1817 <div className="workspace-empty">No details available</div>
1818 )}
1819 </div>
1820 )}
1821 </div>
1822 ))}
1823 </div>
1824 )}
1825 </>
1826 )}
1827 </div>
1828 ) : viewMode === "changed" && selectedPath ? (
1829 <div className="workspace-git-history">
1830 <section className="workspace-current-change">
1831 <header className="workspace-current-change__head">
1832 <div>
1833 <strong>{t("workspace.currentChanges")}</strong>
1834 {changeDetail?.source && (
1835 <span>{t(changeDetail.source === "git" ? "workspace.currentChangesSourceGit" : "workspace.currentChangesSourceSession")}</span>
1836 )}
1837 </div>
1838 {(changeDetail?.added || changeDetail?.removed) ? (
1839 <small>
1840 <span className="workspace-current-change__added">+{changeDetail.added ?? 0}</span>
1841 <span className="workspace-current-change__removed">-{changeDetail.removed ?? 0}</span>
1842 </small>
1843 ) : null}
1844 </header>
1845 <div className="workspace-current-change__body">
1846 {loadingChangeDetail ? (
1847 <div className="workspace-empty">{t("workspace.loading")}</div>
1848 ) : changeDetailErr ? (
1849 <div className="workspace-empty workspace-empty--error">{t("workspace.changeDetailUnavailable")}: {changeDetailErr}</div>
1850 ) : changeDetail?.truncated ? (
1851 <div className="workspace-empty">{t("workspace.changeDetailTooLarge")}</div>
1852 ) : changeDetail?.binary ? (
1853 <div className="workspace-empty">{t("workspace.binaryChange")}</div>
1854 ) : changeDetail?.diff ? (
1855 changeDetail.diff.includes("@@") ? (
1856 <DiffView diff={changeDetail.diff} language={languageFor(selectedPath)} />
1857 ) : (
1858 <CodeViewer value={changeDetail.diff} language="diff" />
1859 )
1860 ) : (
1861 <div className="workspace-empty">{t("workspace.noCurrentDiff")}</div>
1862 )}
1863 </div>
1864 </section>
1865 <section className={`workspace-commit-history${commitHistoryOpen ? " workspace-commit-history--open" : ""}`}>
1866 <button
1867 className="workspace-commit-history__toggle"
1868 type="button"
1869 aria-expanded={commitHistoryOpen}
1870 onClick={() => {
1871 setCommitHistoryOpen((value) => !value);
1872 setExpandedCommit(null);
1873 }}
1874 >
1875 {commitHistoryOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
1876 <span>{t("workspace.commitHistory")}</span>
1877 <small>{loadingHistory ? t("workspace.loading") : t("workspace.commitHistoryMeta", { count: gitHistory.length })}</small>
1878 </button>
1879 {commitHistoryOpen && (
1880 loadingHistory ? (
1881 <div className="workspace-empty">{t("workspace.loading")}</div>
1882 ) : gitHistory.length === 0 ? (
1883 <div className="workspace-empty">{t("workspace.noCommitHistory")}</div>
1884 ) : (
1885 <div className="workspace-git-history__list">
1886 {gitHistory.map((commit) => (
1887 <div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
1888 <button className="workspace-git-history__head" onClick={() => void toggleCommit(commit.hash)}>
1889 <div className="workspace-git-history__head-top">
1890 {expandedCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
1891 <span className="workspace-git-history__message">{commit.message}</span>
1892 </div>
1893 <div className="workspace-git-history__head-bottom">
1894 <span className="workspace-git-history__author">{commit.author}</span>
1895 <span className="workspace-git-history__date">
1896 {formatCommitDate(commit.date)} <span className="workspace-git-history__hash">{commit.hash.substring(0, 7)}</span>
1897 </span>
1898 </div>
1899 </button>
1900 {expandedCommit === commit.hash && (
1901 <div className="workspace-git-history__detail">
1902 {loadingCommit ? (
1903 <div className="workspace-empty">{t("workspace.loading")}</div>
1904 ) : commitDetail?.diff ? (
1905 <CodeViewer value={cleanGitDiff(commitDetail.diff)} language="diff" />
1906 ) : (
1907 <div className="workspace-empty">{t("workspace.noCommitDetail")}</div>
1908 )}
1909 </div>
1910 )}
1911 </div>
1912 ))}
1913 </div>
1914 )
1915 )}
1916 </section>
1917 </div>
1918 ) : !selectedPath ? (
1919 <div className="workspace-empty">{t("workspace.pickFile")}</div>
1920 ) : loadingPreview ? (
1921 <div className="workspace-empty">{t("workspace.loading")}</div>
1922 ) : preview?.err ? (
1923 <div className="workspace-empty workspace-empty--error">{preview.err}</div>
1924 ) : preview?.kind ? (
1925 renderMediaPreview(preview)
1926 ) : preview?.binary ? (
1927 <div className="workspace-empty">{t("workspace.binary")}</div>
1928 ) : preview ? (
1929 <>
1930 {preview.truncated && <div className="workspace-note">{t("workspace.truncated")}</div>}
1931 {isMarkdown ? (
1932 <Markdown text={preview.body} />
1933 ) : (
1934 <CodeViewer
1935 value={preview.body || " "}
1936 language={languageFor(selectedPath)}
1937 sourceSize={preview.size}
1938 showLineNumbers
1939 searchRequestPending={codeSearchRequestPending && codeSearchRequestPath === selectedPath}
1940 onSearchRequestConsumed={consumeCodeSearchRequest}
1941 />
1942 )}
1943 </>
1944 ) : null}
1945 {selectionMenu && (
1946 <FloatingMenu x={selectionMenu.x} y={selectionMenu.y} estimatedHeight={WORKSPACE_CONTEXT_MENU_SELECTION_HEIGHT}>
1947 <FloatingMenuItems
1948 items={[
1949 {
1950 icon: <MessageSquarePlus size={14} />,
1951 label: t("workspace.addSelectionToChat"),
1952 onSelect: addSelectionToChat,
1953 },
1954 ]}
1955 />
1956 </FloatingMenu>
1957 )}
1958 </div>
1959 </section>}
1960
1961 {showTreeRail && (
1962 <section className="workspace-tree-rail" aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
1963 <Tooltip label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")} side="right">
1964 <button
1965 className={`workspace-tree-reveal workspace-iconbtn${actualTreeVisible ? " workspace-iconbtn--on" : ""}`}
1966 type="button"
1967 aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}
1968 aria-pressed={actualTreeVisible}
1969 onClick={toggleTreeRail}
1970 >
1971 <FolderTree size={15} />
1972 </button>
1973 </Tooltip>
1974 </section>
1975 )}
1976
1977 {actualTreeVisible && previewVisible && (
1978 <button
1979 className="workspace-tree-resizer"
1980 type="button"
1981 role="separator"
1982 aria-orientation="vertical"
1983 aria-label={t("workspace.resizeTree")}
1984 aria-valuemin={WORKSPACE_TREE_MIN_WIDTH}
1985 aria-valuemax={maxTreeWidthForPanel}
1986 aria-valuenow={effectiveTreeWidth}
1987 onPointerDown={startTreeResize}
1988 onKeyDown={resizeTreeWithKeyboard}
1989 onDoubleClick={() => setSavedTreeWidth(WORKSPACE_TREE_DEFAULT_WIDTH)}
1990 />
1991 )}
1992
1993 <section className="workspace-files">
1994 {showFileTools && (
1995 <div className={`workspace-files__tools${embeddedDockMode ? " workspace-files__tools--embedded" : ""}`}>
1996 {showViewTabs && (
1997 <Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
1998 <button
1999 className="workspace-iconbtn workspace-iconbtn--on"
2000 type="button"
2001 aria-label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}
2002 onClick={hideTreeOrClosePanel}
2003 >
2004 {previewVisible ? <FolderX size={15} /> : <X size={15} />}
2005 </button>
2006 </Tooltip>
2007 )}
2008 {showViewTabs && (
2009 <div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
2010 <button
2011 className={viewMode === "files" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
2012 onClick={() => setViewMode("files")}
2013 >
2014 {t("workspace.filesTab")}
2015 </button>
2016 <button
2017 className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
2018 onClick={() => {
2019 setViewMode("changed");
2020 void loadGitHistory();
2021 }}
2022 >
2023 <GitBranch size={13} />
2024 {t("workspace.changedTab")}
2025 </button>
2026 </div>
2027 )}
2028 {showViewTabs && (
2029 <Tooltip label={t("workspace.refreshChanges")}>
2030 <button className="workspace-iconbtn" onClick={() => { void loadGitHistory(); void loadWorkspaceChanges(); }}>
2031 <RefreshCw size={14} />
2032 </button>
2033 </Tooltip>
2034 )}
2035 </div>
2036 )}
2037
2038 <div className="workspace-search">
2039 <Search size={14} />
2040 <input ref={filterRef} value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={searchPlaceholder} />
2041 </div>
2042 {scopedFilePaths && (
2043 <div className="workspace-files__scope">
2044 <span className="workspace-files__scope-title">{t("context.referencedFiles")}</span>
2045 <span className="workspace-files__scope-meta">{t("context.readMeta", { count: scopedFilePaths.length })}</span>
2046 <Tooltip label={t("workspace.clearFileScope")}>
2047 <button
2048 type="button"
2049 aria-label={t("workspace.clearFileScope")}
2050 onClick={() => {
2051 dismissedFileListRequestIdRef.current = lastFileListRequestIdRef.current;
2052 setScopedFilePaths(null);
2053 setFilter("");
2054 }}
2055 >
2056 <X size={12} />
2057 </button>
2058 </Tooltip>
2059 </div>
2060 )}
2061 <div
2062 className="workspace-tree"
2063 ref={treeRef}
2064 onContextMenu={openTreeBlankMenu}
2065 style={{
2066 height: "100%",
2067 overflow: "auto",
2068 position: "relative",
2069 }}
2070 >
2071 {treeRows.length > 0 ? (
2072 <div
2073 ref={virtualizer.containerRef}
2074 className="workspace-tree__sizer"
2075 style={{
2076 width: "100%",
2077 position: "relative",
2078 }}
2079 >
2080 {virtualTreeItems.map((row) => {
2081 const item = treeRows[row.index];
2082 if (!item) return null;
2083 return (
2084 <div
2085 key={item.key}
2086 data-index={row.index}
2087 ref={virtualizer.measureElement}
2088 style={{
2089 position: "absolute",
2090 top: 0,
2091 left: 0,
2092 width: "100%",
2093 }}
2094 >
2095 {item.isSearch ? renderSearchRow(item) : renderNormalRow(item)}
2096 </div>
2097 );
2098 })}
2099 </div>
2100 ) : null}
2101 </div>
2102 </section>
2103 {treeMenu && (
2104 <FloatingMenu
2105 x={treeMenu.x}
2106 y={treeMenu.y}
2107 estimatedHeight={treeMenu.isDir ? WORKSPACE_CONTEXT_MENU_REF_HEIGHT : WORKSPACE_CONTEXT_MENU_FILE_HEIGHT}
2108 className="workspace-tree-menu"
2109 >
2110 <FloatingMenuItems
2111 items={[
2112 {
2113 icon: <MessageSquarePlus size={14} />,
2114 label: treeMenu.isDir ? t("workspace.addFolderReferenceToChat") : t("workspace.addFileReferenceToChat"),
2115 onSelect: addTreeReferenceToChat,
2116 },
2117 ...(treeMenu.isDir
2118 ? []
2119 : [
2120 {
2121 icon: <FileText size={14} />,
2122 label: t("workspace.addFileContentToChat"),
2123 onSelect: () => void addTreeFileToChat(),
2124 },
2125 ]),
2126 {
2127 icon: <FolderOpen size={14} />,
2128 label: t("workspace.revealInFileManager"),
2129 onSelect: revealInFileManager,
2130 },
2131 ...(onOpenInTerminal
2132 ? [{
2133 icon: <TerminalSquare size={14} />,
2134 label: t("workspace.openInTerminal"),
2135 onSelect: () => {
2136 const target = treeMenu;
2137 setTreeMenu(null);
2138 onOpenInTerminal(target.path);
2139 },
2140 }]
2141 : []),
2142 ]}
2143 />
2144 </FloatingMenu>
2145 )}
2146 <ContextMenu
2147 open={Boolean(treeBlankMenuPoint)}
2148 point={treeBlankMenuPoint}
2149 items={treeBlankMenuItems}
2150 minWidth={150}
2151 ariaLabel={t("workspace.treeMenu")}
2152 onClose={() => setTreeBlankMenuPoint(null)}
2153 />
2154 </aside>
2155 );
2156 }
2157
2157 lines Plain Text