返回 DeepSeek-Reasonix
ProjectTree.tsx
根目录 / desktop / frontend / src / components / ProjectTree.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { ProjectTreeSessionBadges } from "./ProjectTreeSessionBadges";
3 import { sessionLifecycleFences } from "../lib/sessionLifecycleFences";
4 import { projectSessionIdentity, projectSessionRowKey } from "../lib/projectSessionIdentity";
5 import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
6 import { Archive, Pencil, Plus, Folder, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, Check, ListCollapse, ListRestart, MessageSquare, Clock, Pin, MoreHorizontal, Minimize2, Maximize2, GitBranch, Sparkles, Cloud } from "lucide-react";
7 import { asArray } from "../lib/array";
8 import { useToast } from "../lib/toast";
9 import { app } from "../lib/bridge";
10 import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge";
11 import { sessionCatalogNotice } from "../lib/sessionCatalogPresentation";
12 import { sessionTitleErrorKey, sessionTitleTarget } from "../lib/sessionTitleOperation";
13 import { useSessionTitleOperation } from "../lib/useSessionTitleOperation";
14 import { isRuntimeSessionNode, isTopicNode, loadWorkbenchSortMode, mergeIncompleteProjectTopicPage, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShellSignature, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeTopicPageIsFresh, projectTreeWithoutSession, projectTreeWithoutTopic, projectTreeWithSessionTitle, projectTreeWithTopicTitle, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, WORKBENCH_SORT_KEY, workspaceDraftBadge, type ProjectTreePendingTopicOpen, type WorkbenchSortMode } from "../lib/projectTreeTopic";
15 export * from "../lib/projectTreeTopic";
16 import { arrangeWorkbenchTree, splitPinnedProjectTree, type PinnedTreeSections } from "../lib/projectTreePresentation";
17 export * from "../lib/projectTreePresentation";
18 import type { ProjectNode, SessionCatalogStatus } from "../lib/types";
19 import { useT, type Translator } from "../lib/i18n";
20 import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
21 import { projectTreeSessionArchiveTargetKey, projectTreeTopicArchiveTargetKey, projectTreeWithoutTopics, reloadProjectTreeTopics, useProjectTreeArchiveController, type ProjectTreeRefresh, type ProjectTreeRefreshOptions } from "../lib/projectTreeArchive";
22 import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts";
23 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
24 import { Tooltip } from "./Tooltip";
25 import { WorktreeBadge } from "./WorktreeBadge";
26 import { useProjectCreation } from "./useProjectCreation";
27 import { useProjectTreeRuntimeProjection } from "../lib/useProjectTreeRuntimeProjection";
28 import { useProjectTreeFrontendDiagnostics, type ProjectTreeDiagnosticSnapshot } from "../lib/useProjectTreeFrontendDiagnostics";
29 import { summarizeProjectTreeSessions } from "../lib/projectTreeDiagnostics";
30 import { GLOBAL_PROJECT_ORDER_KEY, ProjectTreeFolderActivity, ProjectTreeGroupRows, applyProjectOrder, projectTreeGroupContainsNode, projectTreeOrganizationKey, projectTreeProjectRoots, reorderedProjectRoots, useProjectTreeOrganization, type ProjectDropPosition } from "./ProjectTreeOrganization";
31 import { ProjectTreeSessionArchiveMenu } from "./ProjectTreeSessionArchiveMenu";
32 import { ProjectTreeHeaderAddControl, ProjectTreeRemoteAction, projectTreeHeaderAddItems } from "./ProjectTreeAddControls";
33 import { activeRemoteProjectAncestorKeys, buildRemoteProjectMenuItems, useRemoteRuntimeTree, openRemoteSessionNode, remoteProjectKey, remoteServeBadgeState, renameRemoteProjectTitle, RemoteProjectEmptyState, remoteSessionActionIdentity, remoteSessionArchiveBlocked, useRemoteProjectGroups, useRemoteSessionActions } from "./ProjectTreeRemoteGroups";
34 import type { ProjectTreeProps } from "./ProjectTreeProps";
35 import { PROJECT_TREE_SEARCH_PAGE, PROJECT_TREE_WINDOW_INITIAL, PROJECT_TREE_WINDOW_STEP, forgetProjectTreeWindowLimits, loadProjectTreePageWindow, projectTreeListKey, projectTreeListNeedsInitialization, projectTreeProjectsNeedingInitialLoad, projectTreeWindowRows, reloadProjectTreeTopicLists, rememberProjectTreeWindowLimit, type ProjectTreeListPageState } from "../lib/projectTreeWindow";
36 import { useProjectTreeReadActivity } from "./useProjectTreeReadActivity";
37 import { useProjectTreeListRuntime } from "../lib/useProjectTreeListRuntime";
38
39 function projectNodeKey(node: ProjectNode, depth: number): string {
40 if (node.session || node.sessionPath || node.source || node.remoteSession || node.tabId) return projectSessionRowKey(node);
41 return node.key || `${node.kind}-${node.root ?? ""}-${node.topicId ?? ""}-${node.sessionPath ?? ""}-${depth}`;
42 }
43
44 type WorkbenchHeaderMenu = "more" | "add" | null;
45
46 type CollapseSnapshot = {
47 expanded: Set<string>;
48 manuallyCollapsed: Set<string>;
49 };
50
51 function collapsibleFolderKeys(nodes: ProjectNode[], depth = 0): string[] {
52 const keys: string[] = [];
53 for (const node of nodes) {
54 if (!node) continue;
55 const children = asArray(node.children);
56 if ((node.kind === "project" || node.kind === "global_folder") && children.length > 0) {
57 keys.push(projectNodeKey(node, depth));
58 }
59 keys.push(...collapsibleFolderKeys(children, depth + 1));
60 }
61 return keys;
62 }
63
64 export function activeSessionAncestorKeys(
65 nodes: ProjectNode[],
66 activeScope?: string,
67 activeWorkspaceRoot?: string,
68 activeTopicId?: string,
69 activeSessionPath?: string,
70 ): string[] {
71 const walk = (nodeList: ProjectNode[], ancestors: string[]): string[] | null => {
72 for (const node of nodeList) {
73 if (!node) continue;
74 if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return ancestors;
75 const children = asArray(node.children);
76 if (children.length > 0) {
77 const next = walk(children, [...ancestors, projectNodeKey(node, ancestors.length)]);
78 if (next) return next;
79 }
80 }
81 return null;
82 };
83 const found = walk(nodes, []);
84 if (found) return found;
85 // Shell-only snapshots no longer embed topics. Expand the matching project or
86 // Global folder by workspace identity so the first lazy page can load.
87 const scope = (activeScope ?? "").trim();
88 const root = (activeWorkspaceRoot ?? "").trim();
89 for (const node of nodes) {
90 if (!node) continue;
91 if (scope === "global" && node.kind === "global_folder") {
92 return [projectNodeKey(node, 0)];
93 }
94 if (node.kind === "project" && root && (node.root === root || node.root === activeWorkspaceRoot)) {
95 return [projectNodeKey(node, 0)];
96 }
97 if (!scope && !root && activeTopicId && (node.kind === "project" || node.kind === "global_folder")) {
98 // Active topic without resolved scope still needs a folder open path.
99 return [projectNodeKey(node, 0)];
100 }
101 }
102 return [];
103 }
104
105 export function defaultExpandedProjectTreeKeys(
106 nodes: ProjectNode[],
107 activeScope?: string,
108 activeWorkspaceRoot?: string,
109 activeTopicId?: string,
110 activeSessionPath?: string,
111 ): string[] {
112 return activeSessionAncestorKeys(nodes, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
113 }
114
115 // Global rows use the same project tree recipe; the fallback supplies their non-workspace accent.
116 function projectAccentStyle(color?: string, fallbackValue?: string): CSSProperties | undefined {
117 const value = projectColorValue(color) || fallbackValue;
118 if (!value) return undefined;
119 return { "--project-accent": value } as CSSProperties;
120 }
121
122 function colorMenuLabel(label: string, color?: string, active = false) {
123 const value = projectColorValue(color);
124 return (
125 <span className="project-tree__color-option">
126 <span
127 className="project-tree__color-swatch"
128 style={value ? ({ "--project-accent": value } as CSSProperties) : undefined}
129 aria-hidden="true"
130 />
131 <span>{label}</span>
132 {active && <Check className="project-tree__color-check" size={12} />}
133 </span>
134 );
135 }
136
137 function menuLabelWithCheck(label: string, checked: boolean) {
138 return (
139 <span className="context-menu__label-with-check">
140 <span className="context-menu__label-text">{label}</span>
141 {checked && <Check className="context-menu__check" size={13} aria-hidden="true" />}
142 </span>
143 );
144 }
145
146 function revealLabelKey(platform: string): "projectTree.revealInFinder" | "projectTree.revealInExplorer" | "projectTree.revealInFileManager" {
147 if (platform === "darwin") return "projectTree.revealInFinder";
148 if (platform === "windows") return "projectTree.revealInExplorer";
149 return "projectTree.revealInFileManager";
150 }
151
152 function projectColorLabel(t: Translator, color?: string): string {
153 switch (color) {
154 case "red": return t("projectTree.colorRed");
155 case "orange": return t("projectTree.colorOrange");
156 case "amber": return t("projectTree.colorAmber");
157 case "green": return t("projectTree.colorGreen");
158 case "teal": return t("projectTree.colorTeal");
159 case "blue": return t("projectTree.colorBlue");
160 case "purple": return t("projectTree.colorPurple");
161 case "pink": return t("projectTree.colorPink");
162 default: return t("projectTree.colorDefault");
163 }
164 }
165
166 export function ProjectTree({
167 activeScope,
168 activeWorkspaceRoot,
169 activeTopicId,
170 activeSessionPath,
171 activeRemote,
172 imTopicSources = {},
173 variant = "workbench",
174 onOpenTopic,
175 onAddProject,
176 onCreateTopic,
177 onCreateIsolatedWorktree,
178 onRenameTopic,
179 onTopicsChanged,
180 refreshSignal,
181 searchExpanded = true,
182 searchFocusSignal = 0,
183 showShortcutBadges = false,
184 shortcutPlatform,
185 onVisibleTopicsChange,
186 draftSummaries = [],
187 onOpenDraft,
188 }: ProjectTreeProps) {
189 const t = useT();
190 const { showToast } = useToast();
191 const projectTreeRef = useRef<HTMLDivElement>(null);
192 const compactTopics = variant === "workbench";
193 const creationTopics = variant === "creation";
194 const [tree, setTree] = useState<ProjectNode[]>([]);
195 const treeRef = useRef<ProjectNode[]>([]);
196 const latestRevisionRef = useRef(0);
197 const [organizationRevision, setOrganizationRevision] = useState(0);
198 const {
199 topicRevisionRef, topicCompletePageRef, topicPageState, setTopicPageState, topicPageStateRef,
200 updateTopicPageState, topicWindowLimits, setTopicWindowLimits, topicWindowLimitsRef,
201 resetTopicWindowLimits, topicLoadSeqRef, topicLoadPendingRef, topicRequestLimiterRef,
202 topicLoadErrorRef, invalidateProjectTopicLists,
203 } = useProjectTreeListRuntime();
204 const [catalogStatus, setCatalogStatus] = useState<SessionCatalogStatus>({
205 state: "opening", revision: 0, indexed: 0, total: 0, repairPending: 0,
206 repairActive: 0, repairDeferred: 0, repairBlocked: 0,
207 });
208 const catalogStatusGenerationRef = useRef(0), rebuildingCatalogRef = useRef(false), catalogRebuildFailedRef = useRef(false);
209 const [expanded, setExpanded] = useState<Set<string>>(new Set());
210 const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<string>>(new Set());
211 const [creatingProject, setCreatingProject] = useState<string | null>(null);
212 const [query, setQuery] = useState("");
213 const [editingTopic, setEditingTopic] = useState<string | null>(null);
214 const [editingSession, setEditingSession] = useState<{ key: string; path: string; topicId: string } | null>(null);
215 const [topicDraft, setTopicDraft] = useState("");
216 const [menuNodeKey, setMenuNodeKey] = useState<string | null>(null);
217 const [menuProject, setMenuProject] = useState<{ key: string; root: string; path: string; scope: "global" | "project"; label: string } | null>(null);
218 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
219 const [editingProject, setEditingProject] = useState<{ key: string; root: string } | null>(null);
220 const [projectDraft, setProjectDraft] = useState("");
221 const [isolatingProject, setIsolatingProject] = useState<string | null>(null);
222 const [worktreeAvailability, setWorktreeAvailability] = useState<Record<string, { available: boolean; reason?: string }>>({});
223 const [confirmArchiveTarget, setConfirmArchiveTarget] = useState<string | null>(null);
224 const [confirmRemoveProject, setConfirmRemoveProject] = useState<string | null>(null);
225 const [dragProjectRoot, setDragProjectRoot] = useState<string | null>(null);
226 const [dropProject, setDropProject] = useState<{ root: string; position: ProjectDropPosition } | null>(null);
227 const [collapseSnapshot, setCollapseSnapshot] = useState<CollapseSnapshot | null>(null);
228 const [platform, setPlatform] = useState("");
229 const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
230 const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
231 const workbenchSortModeRef = useRef(workbenchSortMode);
232 const searchInputRef = useRef<HTMLInputElement>(null);
233 const topicIndexRef = useRef(0);
234 const visibleTopicsCollectorRef = useRef<TopicShortcutEntry[]>([]);
235 const creatingRef = useRef(false);
236 const closeMenu = useCallback(() => {
237 setMenuNodeKey(null);
238 setMenuProject(null);
239 setMenuPoint(null);
240 setConfirmArchiveTarget(null);
241 setConfirmRemoveProject(null);
242 setWorkbenchHeaderMenu(null);
243 }, []);
244 const topicRequestContextRef = useRef({ query: query.trim(), sortMode: creationTopics ? "updated" : workbenchSortMode });
245 topicRequestContextRef.current = { query: query.trim(), sortMode: creationTopics ? "updated" : workbenchSortMode };
246 const activeSummaryRequestRef = useRef("");
247 const refreshRef = useRef<ProjectTreeRefresh>(async () => {});
248 const { trashingTopics, trashingSessions, currentArchiveTombstones, trashTopic, trashSession } = useProjectTreeArchiveController({
249 treeRef, topicLoadSeqRef, topicLoadPendingRef, topicPageStateRef, updateTopicPageState, refreshRef,
250 optimisticallyRemoveTopic: (topicId) => setTree((current) => projectTreeWithoutTopic(current, topicId)),
251 optimisticallyRemoveSession: (node) => setTree((current) => projectTreeWithoutSession(current, node)),
252 closeMenu, onTopicsChanged, showToast,
253 sessionErrorMessage: (error) => t(sessionTitleErrorKey(error)),
254 });
255 const applyRuntimeProjection = useProjectTreeRuntimeProjection(setTree, currentArchiveTombstones);
256 const clickTimerRef = useRef<ProjectTreePendingTopicOpen | null>(null);
257 useEffect(() => {
258 const invalidatePendingOpen = (event: Event) => {
259 const pending = clickTimerRef.current;
260 if (!pending) return;
261 const target = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-topic-open-key]") : null;
262 if (event.type === "click" && target?.dataset.topicOpenKey === pending.rowKey) return;
263 const keyEvent = event as globalThis.KeyboardEvent;
264 if (event.type === "keydown" && !keyEvent.metaKey && !keyEvent.ctrlKey && keyEvent.key !== "Escape") return;
265 clearTimeout(pending.timer);
266 clickTimerRef.current = null;
267 };
268 document.addEventListener("click", invalidatePendingOpen, true);
269 document.addEventListener("keydown", invalidatePendingOpen, true);
270 return () => {
271 document.removeEventListener("click", invalidatePendingOpen, true);
272 document.removeEventListener("keydown", invalidatePendingOpen, true);
273 };
274 }, []);
275 useEffect(() => {
276 return () => {
277 if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current.timer);
278 };
279 }, []);
280 const manuallyCollapsedRef = useRef(manuallyCollapsed);
281
282 const updateManuallyCollapsed = useCallback((updater: (prev: Set<string>) => Set<string>) => {
283 setManuallyCollapsed((prev) => {
284 const next = updater(prev);
285 manuallyCollapsedRef.current = next;
286 return next;
287 });
288 }, []);
289
290 const loadProjectTopicsRef = useRef<(project: ProjectNode, append?: boolean, groupID?: string) => Promise<void>>(async () => {});
291
292 const loadProjectTopics = useCallback(async (project: ProjectNode, append = false, groupID = "") => {
293 if ((project.kind !== "project" && project.kind !== "global_folder") || project.remote) return;
294 const key = project.key;
295 const normalizedQuery = query.trim();
296 const listKey = projectTreeListKey(key, groupID, normalizedQuery);
297 if (topicLoadPendingRef.current[listKey] !== undefined) return;
298 const pageState = topicPageStateRef.current[listKey];
299 if (pageState?.loading) return;
300 const cursor = append ? pageState?.nextCursor ?? "" : "";
301 if (append && !cursor) return;
302 const sortMode = creationTopics ? "updated" : workbenchSortModeRef.current;
303 const windowKey = projectTreeListKey(key, groupID);
304 const limit = normalizedQuery
305 ? PROJECT_TREE_SEARCH_PAGE
306 : append
307 ? PROJECT_TREE_WINDOW_STEP
308 : topicWindowLimitsRef.current[windowKey] ?? PROJECT_TREE_WINDOW_INITIAL;
309 const excludePinned = !creationTopics && !project.pinned;
310 // Completeness belongs to the logical list, not to the size of the page
311 // used to paint it. This lets an incomplete refresh retain a previously
312 // complete 5/15/25-row screen while the catalog repairs itself.
313 const requestSignature = [
314 normalizedQuery,
315 groupID,
316 sortMode,
317 excludePinned ? "exclude-pinned" : "include-pinned",
318 ].join("\u001f");
319 // Last-query-wins: stale completions cannot overwrite a newer first page.
320 const seq = (topicLoadSeqRef.current[listKey] ?? 0) + 1;
321 topicLoadSeqRef.current[listKey] = seq;
322 topicLoadPendingRef.current[listKey] = seq;
323 updateTopicPageState(listKey, { ...pageState, loading: true, error: undefined });
324 try {
325 const page = await topicRequestLimiterRef.current.run(() => {
326 const context = topicRequestContextRef.current;
327 if (topicLoadSeqRef.current[listKey] !== seq || context.query !== normalizedQuery || context.sortMode !== sortMode) return Promise.resolve(null);
328 return loadProjectTreePageWindow(cursor, limit, (pageCursor, pageLimit) => app.ListProjectTopics({
329 scope: project.kind === "global_folder" ? "global" : "project",
330 workspaceRoot: project.kind === "global_folder" ? "" : project.root ?? "",
331 cursor: pageCursor,
332 limit: pageLimit,
333 query: normalizedQuery,
334 sortMode,
335 groupFilter: normalizedQuery ? "all" : groupID ? "group" : "ungrouped",
336 groupId: groupID || undefined,
337 // Individually pinned topics live in the standalone pinned section
338 // for ordinary projects. A pinned project is itself that section's
339 // folder, so its children must remain available inside it.
340 excludePinned,
341 }));
342 });
343 if (!page) return;
344 if (topicLoadSeqRef.current[listKey] !== seq) return;
345 const currentContext = topicRequestContextRef.current;
346 if (currentContext.query !== normalizedQuery || currentContext.sortMode !== sortMode) return;
347 delete topicLoadErrorRef.current[listKey];
348 if (!projectTreeTopicPageIsFresh(topicRevisionRef.current, listKey, page.revision)) {
349 updateTopicPageState(listKey, { ...topicPageStateRef.current[listKey], loading: false });
350 return;
351 }
352 topicRevisionRef.current[listKey] = Math.max(topicRevisionRef.current[listKey] ?? 0, page.revision);
353 sessionLifecycleFences.observeDirectory(asArray(page.items));
354 const items = projectTreeWithoutTopics(asArray(page.items), currentArchiveTombstones());
355 const completeBaseline = topicCompletePageRef.current[listKey];
356 const preserveCompletePage = page.complete === false && completeBaseline?.signature === requestSignature;
357 const incomingKeys = items.map((item) => item.key);
358 const previousKeys = topicPageStateRef.current[listKey]?.itemKeys ?? [];
359 const itemKeys = append || preserveCompletePage
360 ? [...new Set([...previousKeys, ...incomingKeys])]
361 : incomingKeys;
362 if (page.complete !== false) {
363 topicCompletePageRef.current[listKey] = { signature: requestSignature, revision: page.revision };
364 }
365 setTree((current) => applyRuntimeProjection(current.map((node) => {
366 if (node.key !== key) return node;
367 const children = preserveCompletePage
368 ? mergeIncompleteProjectTopicPage(asArray(node.children), items)
369 : mergeProjectTopicPage(asArray(node.children), items, true);
370 return children === node.children ? node : { ...node, children };
371 })));
372 updateTopicPageState(listKey, preserveCompletePage
373 ? { ...topicPageStateRef.current[listKey], itemKeys, loading: false, initialized: true }
374 : { itemKeys, nextCursor: page.nextCursor, loading: false, initialized: true });
375 } catch (error) {
376 if (topicLoadSeqRef.current[listKey] !== seq) return;
377 const message = error instanceof Error ? error.message : String(error);
378 if (cursor && ((error as { code?: string })?.code === "stale_cursor" || (error as { data?: { sessionCode?: string } })?.data?.sessionCode === "stale_cursor" || message.includes("stale_cursor"))) {
379 delete topicCompletePageRef.current[listKey];
380 delete topicRevisionRef.current[listKey];
381 updateTopicPageState(listKey, { itemKeys: [], loading: false, initialized: false });
382 if (topicLoadPendingRef.current[listKey] === seq) delete topicLoadPendingRef.current[listKey];
383 void loadProjectTopicsRef.current(project, false, groupID);
384 return;
385 }
386 updateTopicPageState(listKey, { ...topicPageStateRef.current[listKey], loading: false, initialized: true, error: message });
387 if (topicLoadErrorRef.current[listKey] !== message) {
388 topicLoadErrorRef.current[listKey] = message;
389 showToast(message, "error", { durationMs: 6000 });
390 }
391 } finally {
392 if (topicLoadPendingRef.current[listKey] === seq) delete topicLoadPendingRef.current[listKey];
393 }
394 }, [applyRuntimeProjection, creationTopics, currentArchiveTombstones, query, showToast, updateTopicPageState]);
395 loadProjectTopicsRef.current = loadProjectTopics;
396
397 const topicListState = useCallback((project: ProjectNode, groupID = "") => (
398 topicPageState[projectTreeListKey(project.key, groupID, query)]
399 ), [query, topicPageState]);
400 const topicListLimit = useCallback((project: ProjectNode, groupID = "") => (
401 topicWindowLimits[projectTreeListKey(project.key, groupID)] ?? PROJECT_TREE_WINDOW_INITIAL
402 ), [topicWindowLimits]);
403 const ensureTopicList = useCallback((project: ProjectNode, groupID = "") => {
404 const state = topicPageStateRef.current[projectTreeListKey(project.key, groupID, query)];
405 if (!projectTreeListNeedsInitialization(state)) return Promise.resolve();
406 return loadProjectTopics(project, false, groupID);
407 }, [loadProjectTopics, query]);
408 const ensureTopicListRef = useRef(ensureTopicList);
409 ensureTopicListRef.current = ensureTopicList;
410 const expandTopicList = useCallback((project: ProjectNode, groupID = "", loadedCount = 0) => {
411 const key = projectTreeListKey(project.key, groupID);
412 const requestKey = projectTreeListKey(project.key, groupID, query);
413 if (!project.remote && (topicLoadPendingRef.current[requestKey] !== undefined || topicPageStateRef.current[requestKey]?.loading)) return;
414 const nextLimit = (topicWindowLimitsRef.current[key] ?? PROJECT_TREE_WINDOW_INITIAL) + PROJECT_TREE_WINDOW_STEP;
415 setTopicWindowLimits((current) => {
416 const next = { ...current, [key]: nextLimit };
417 rememberProjectTreeWindowLimit(key, next[key]);
418 topicWindowLimitsRef.current = next;
419 return next;
420 });
421 if (!project.remote && loadedCount < nextLimit && topicPageStateRef.current[requestKey]?.nextCursor) {
422 void loadProjectTopics(project, true, groupID);
423 }
424 }, [loadProjectTopics, query]);
425
426 const retryTopicList = useCallback((project: ProjectNode, groupID = "") => {
427 const state = topicPageStateRef.current[projectTreeListKey(project.key, groupID, query)];
428 void loadProjectTopics(project, Boolean(state?.nextCursor), groupID);
429 }, [loadProjectTopics, query]);
430 const forgetTopicList = useCallback((project: ProjectNode, groupID: string) => {
431 const listKey = projectTreeListKey(project.key, groupID);
432 topicLoadSeqRef.current[listKey] = (topicLoadSeqRef.current[listKey] ?? 0) + 1;
433 delete topicLoadPendingRef.current[listKey];
434 delete topicRevisionRef.current[listKey];
435 delete topicCompletePageRef.current[listKey];
436 delete topicLoadErrorRef.current[listKey];
437 const nextPages = { ...topicPageStateRef.current };
438 delete nextPages[listKey];
439 topicPageStateRef.current = nextPages;
440 setTopicPageState(nextPages);
441 const nextLimits = { ...topicWindowLimitsRef.current };
442 delete nextLimits[listKey];
443 topicWindowLimitsRef.current = nextLimits;
444 setTopicWindowLimits(nextLimits);
445 rememberProjectTreeWindowLimit(listKey, PROJECT_TREE_WINDOW_INITIAL);
446 }, []);
447 const reloadProjectTopicLists = useCallback((project: ProjectNode) => {
448 invalidateProjectTopicLists(project.key);
449 return reloadProjectTreeTopicLists(project,
450 topicRequestContextRef.current.query, topicPageStateRef.current,
451 (target, groupID) => ensureTopicListRef.current(target, groupID));
452 }, [invalidateProjectTopicLists]);
453
454 const changeQuery = useCallback((value: string) => {
455 if (value.trim() !== topicRequestContextRef.current.query) {
456 // Retain painted rows, but retire requests/cursors from the old search
457 // before any completion can run between this event and the next render.
458 topicRequestContextRef.current = { ...topicRequestContextRef.current, query: value.trim() };
459 for (const project of treeRef.current) invalidateProjectTopicLists(project.key);
460 }
461 setQuery(value);
462 }, [invalidateProjectTopicLists]);
463
464 const selectWorkbenchSortMode = useCallback((sortMode: WorkbenchSortMode) => {
465 if (workbenchSortModeRef.current === sortMode) {
466 closeMenu();
467 return;
468 }
469 // Invalidate before scheduling React state so an already-resolved older
470 // request cannot write back during the render/effect gap. Its pagination
471 // cursor belongs to the old order and must not be reused by the new query.
472 workbenchSortModeRef.current = sortMode;
473 topicRequestContextRef.current = { query: query.trim(), sortMode };
474 for (const project of treeRef.current) invalidateProjectTopicLists(project.key);
475 setWorkbenchSortMode(sortMode);
476 closeMenu();
477
478 const filtering = query.trim() !== "";
479 for (const project of treeRef.current) {
480 const key = projectNodeKey(project, 0);
481 if (filtering || expanded.has(key)) void ensureTopicList(project);
482 }
483 }, [closeMenu, ensureTopicList, expanded, invalidateProjectTopicLists, query]);
484 // Snapshot carries project shells plus lightweight pinned topic shells.
485 // Preserve already loaded pages by project key while reconciling pins, so a
486 // metadata refresh does not collapse or blank the sidebar.
487 const refresh = useCallback(async (options?: ProjectTreeRefreshOptions) => {
488 const reloadRequestedProjects = (projects: ProjectNode[]) => reloadProjectTreeTopics(projects, options, reloadProjectTopicLists), catalogStatusGeneration = catalogStatusGenerationRef.current;
489 try {
490 const snapshot = await app.GetProjectTreeSnapshot();
491 const rev = snapshot.revision ?? 0, empty = treeRef.current.length === 0;
492 if (!projectTreeShouldApplyShellSnapshot({ currentRevision: latestRevisionRef.current, incomingRevision: rev, treeEmpty: empty })) {
493 await reloadRequestedProjects(treeRef.current);
494 return;
495 }
496 if (projectTreeRevisionIsFresh(latestRevisionRef.current, rev)) latestRevisionRef.current = Math.max(latestRevisionRef.current, rev);
497 const projects = asArray(snapshot.projects);
498 if (!catalogRebuildFailedRef.current && catalogStatusGeneration === catalogStatusGenerationRef.current) setCatalogStatus(snapshot.catalog);
499 setTree((current) => applyRuntimeProjection(projects.map((project) => {
500 const previous = current.find((node) => node.key === project.key);
501 // Topic pages reload asynchronously. Keep the last painted children
502 // until their replacement arrives so a mutation cannot blank every
503 // expanded folder for the duration of a catalog scan.
504 return { ...project, children: projectTreeShellChildren(previous?.children, project.children) };
505 })));
506 await reloadRequestedProjects(projects);
507 } catch {
508 // A shell snapshot is metadata-only. If it fails, the resident folder
509 // identity can still drive the requested canonical topic reload.
510 await reloadRequestedProjects(treeRef.current);
511 }
512 }, [applyRuntimeProjection, reloadProjectTopicLists]);
513 refreshRef.current = refresh;
514 const { openRemoteProject, openRemoteWindow, remoteSessions, setRemoteSessions, remoteServers, remoteGroupBusy, remoteGroupError, ensureRemoteGroupSessions, refreshRemoteSessions } = useRemoteProjectGroups(tree, showToast, expanded, query);
515 const treeWithRemoteSessions = useRemoteRuntimeTree(tree, remoteSessions, t);
516 const remoteSessionActions = useRemoteSessionActions(remoteSessions, refreshRemoteSessions, (error) => showToast(error instanceof Error ? error.message : String(error), "error"));
517 const { addingProject, handleAddProject, openBlankProjectFlow, blankProjectFlow, openRemoteConnectFlow, remoteConnectFlow } = useProjectCreation({
518 onAddProject,
519 onRefresh: refresh,
520 showToast,
521 });
522
523 const rebuildSessionCatalog = useCallback(async () => {
524 if (rebuildingCatalogRef.current || catalogStatus.canRebuild !== true) return;
525 rebuildingCatalogRef.current = true; catalogRebuildFailedRef.current = false; catalogStatusGenerationRef.current += 1;
526 setCatalogStatus({ ...catalogStatus, state: "rebuilding", canRebuild: false });
527 try {
528 await app.RebuildSessionCatalog(); catalogStatusGenerationRef.current += 1;
529 await refresh();
530 } catch {
531 catalogRebuildFailedRef.current = true; catalogStatusGenerationRef.current += 1; setCatalogStatus(catalogStatus);
532 } finally {
533 rebuildingCatalogRef.current = false;
534 }
535 }, [catalogStatus, refresh]);
536
537 useEffect(() => {
538 treeRef.current = tree;
539 }, [tree]);
540
541 useEffect(() => {
542 if (activeRemote || !activeTopicId || (activeScope !== "global" && activeScope !== "project")) return;
543 const workspaceRoot = activeScope === "global" ? "" : activeWorkspaceRoot ?? "";
544 const project = tree.find((node) => activeScope === "global"
545 ? node.kind === "global_folder"
546 : node.kind === "project" && node.root === workspaceRoot);
547 if (!project) return;
548 if (asArray(project.children).some((node) => node.topicId === activeTopicId)) return;
549 const requestKey = [activeScope, workspaceRoot, activeTopicId].join("\u001f");
550 if (activeSummaryRequestRef.current === requestKey) return;
551 activeSummaryRequestRef.current = requestKey;
552 void app.GetTopicSummary({ scope: activeScope, workspaceRoot, topicId: activeTopicId }).then((summary) => {
553 if (!summary?.topicId || activeSummaryRequestRef.current !== requestKey) return;
554 setTree((current) => current.map((node) => node.key === project.key
555 ? { ...node, children: mergeProjectTopicPage(asArray(node.children), [summary], true) }
556 : node));
557 }).catch(() => {}).finally(() => {
558 if (activeSummaryRequestRef.current === requestKey) activeSummaryRequestRef.current = "";
559 });
560 }, [activeRemote, activeScope, activeTopicId, activeWorkspaceRoot, tree]);
561
562 useEffect(() => {
563 manuallyCollapsedRef.current = manuallyCollapsed;
564 }, [manuallyCollapsed]);
565
566 const searchVisible = searchExpanded || query.trim().length > 0;
567
568 useEffect(() => {
569 if (!searchVisible || searchFocusSignal <= 0) return;
570 searchInputRef.current?.focus();
571 }, [searchFocusSignal, searchVisible]);
572
573 useEffect(() => {
574 void refresh();
575 }, [refresh, refreshSignal]);
576
577 useEffect(() => onProjectTreeChangedV2((event) => {
578 // A stale or missed revision means the tree may have drifted from the
579 // catalog; refetch the full snapshot instead of dropping the event.
580 if (!projectTreeRevisionIsFresh(latestRevisionRef.current, event.revision)) {
581 void refresh();
582 }
583 latestRevisionRef.current = Math.max(latestRevisionRef.current, event.revision);
584 if (event.reason === "metadata") setOrganizationRevision((current) => Math.max(current, event.revision));
585 const catalogStatusGeneration = catalogStatusGenerationRef.current;
586 void app.GetSessionCatalogStatus().then((status) => { if (!catalogRebuildFailedRef.current && catalogStatusGeneration === catalogStatusGenerationRef.current) setCatalogStatus(status); }).catch(() => {});
587 if (treeRef.current.length === 0) { void refresh(); return; } // race: event before shell
588 const affected = asArray(event.roots);
589 for (const project of treeRef.current) {
590 const key = projectNodeKey(project, 0);
591 if (projectTreeEventAffectsFolder(project, affected)) {
592 if (topicRequestContextRef.current.query || expanded.has(key)) void reloadProjectTopicLists(project);
593 else invalidateProjectTopicLists(project.key);
594 }
595 }
596 }), [expanded, invalidateProjectTopicLists, refresh, reloadProjectTopicLists]);
597 // Debounce query reloads so typing does not stampede the catalog.
598 // Dependency is the project-shell signature, not tree: topic page loads
599 // rewrite children and would otherwise re-arm this effect in a loop.
600 const projectShellSignature = useMemo(() => projectTreeShellSignature(tree), [tree]);
601 useEffect(() => {
602 const projectKeys = new Set(tree.map((project) => project.key));
603 forgetProjectTreeWindowLimits(projectKeys);
604 const keep = (key: string) => {
605 const separator = key.indexOf("\u001f");
606 return projectKeys.has(separator >= 0 ? key.slice(0, separator) : key);
607 };
608 let changed = false;
609 const nextPages: Record<string, ProjectTreeListPageState> = {};
610 for (const [key, state] of Object.entries(topicPageStateRef.current)) {
611 if (keep(key)) nextPages[key] = state;
612 else changed = true;
613 }
614 if (changed) {
615 topicPageStateRef.current = nextPages;
616 setTopicPageState(nextPages);
617 }
618 for (const records of [topicLoadSeqRef.current, topicLoadPendingRef.current, topicRevisionRef.current, topicCompletePageRef.current, topicLoadErrorRef.current]) {
619 for (const key of Object.keys(records)) if (!keep(key)) delete records[key];
620 }
621 }, [projectShellSignature, tree]);
622 // Only search is debounced. All first-page consumers share ensureTopicList;
623 // a delayed search must not reload a page already fetched by an event.
624 useEffect(() => {
625 if (!query.trim()) return;
626 const timer = setTimeout(() => {
627 for (const project of treeRef.current) {
628 void ensureTopicListRef.current(project);
629 }
630 }, 200);
631 return () => clearTimeout(timer);
632 }, [projectShellSignature, query]);
633
634 useEffect(() => {
635 if (query.trim()) return;
636 const projects = projectTreeProjectsNeedingInitialLoad(
637 treeRef.current,
638 expanded,
639 query,
640 topicPageStateRef.current,
641 (project) => projectNodeKey(project, 0),
642 );
643 for (const project of projects) void ensureTopicListRef.current(project);
644 }, [expanded, projectShellSignature, query]);
645
646 // Following the active topic is a view concern over the tree already held.
647 useEffect(() => {
648 const collapsed = manuallyCollapsedRef.current;
649 const keys = (activeRemote
650 ? activeRemoteProjectAncestorKeys(treeWithRemoteSessions, activeRemote, projectNodeKey)
651 : defaultExpandedProjectTreeKeys(treeWithRemoteSessions, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath))
652 .filter((key) => !collapsed.has(key));
653 // Returning prev unchanged keeps a switch that expands nothing new from
654 // re-rendering the tree at all.
655 setExpanded((prev) => (keys.every((key) => prev.has(key)) ? prev : new Set([...prev, ...keys])));
656 // Active remote groups get the same explicit cold start as a click.
657 for (const node of treeWithRemoteSessions) {
658 if (!node.remote) continue;
659 if (!keys.includes(projectNodeKey(node, 0))) continue;
660 if (!remoteSessions[remoteProjectKey(node.remote)]?.length) void ensureRemoteGroupSessions(node.remote.hostId, node.remote.workspace);
661 }
662 // eslint-disable-next-line react-hooks/exhaustive-deps
663 }, [tree, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote]);
664
665 const { readActivity, readBaselineAt, markNodeRead } = useProjectTreeReadActivity(treeWithRemoteSessions);
666
667 useEffect(() => {
668 const markActive = (nodes: ProjectNode[]) => {
669 for (const node of nodes) {
670 if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote)) markNodeRead(node);
671 markActive(asArray(node.children));
672 }
673 };
674 markActive(treeWithRemoteSessions);
675 }, [activeRemote, activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, treeWithRemoteSessions]);
676
677 useEffect(() => {
678 try {
679 localStorage.setItem(WORKBENCH_SORT_KEY, workbenchSortMode);
680 } catch {
681 /* ignore */
682 }
683 }, [workbenchSortMode]);
684
685 useEffect(() => {
686 let cancelled = false;
687 void app.Platform().then((value) => {
688 if (!cancelled) setPlatform(value);
689 }).catch(() => {});
690 return () => {
691 cancelled = true;
692 };
693 }, []);
694
695 const toggleExpand = (key: string, project?: ProjectNode) => {
696 const willCollapse = expanded.has(key);
697 if (willCollapse && project) resetTopicWindowLimits(project.key);
698 setExpanded((prev) => {
699 const next = new Set(prev);
700 if (next.has(key)) next.delete(key);
701 else next.add(key);
702 return next;
703 });
704 updateManuallyCollapsed((prev) => {
705 const next = new Set(prev);
706 if (willCollapse) next.add(key);
707 else next.delete(key);
708 return next;
709 });
710 };
711
712 const folderKeys = useMemo(() => collapsibleFolderKeys(tree), [tree]);
713 const searchActive = query.trim().length > 0;
714 const hasExpandedFolders = !searchActive && folderKeys.some((key) => expanded.has(key));
715 const canRestoreCollapsedView = collapseSnapshot !== null;
716 const canToggleCollapsedView = !searchActive && folderKeys.length > 0 && (hasExpandedFolders || canRestoreCollapsedView);
717 const collapseToggleLabel = t(canRestoreCollapsedView ? "projectTree.restoreCollapsedTooltip" : "projectTree.collapseAllTooltip");
718 const workbenchCollapseToggleLabel = t(canRestoreCollapsedView ? "projectTree.restoreCollapsedWorkbench" : "projectTree.collapseAllWorkbench");
719
720 const toggleCollapsedView = useCallback(() => {
721 if (searchActive || folderKeys.length === 0) return;
722 if (collapseSnapshot) {
723 const currentFolderKeys = new Set(folderKeys);
724 setExpanded(() => {
725 const next = new Set<string>();
726 for (const key of collapseSnapshot.expanded) {
727 if (currentFolderKeys.has(key)) next.add(key);
728 }
729 return next;
730 });
731 updateManuallyCollapsed(() => {
732 const next = new Set<string>();
733 for (const key of collapseSnapshot.manuallyCollapsed) {
734 if (currentFolderKeys.has(key)) next.add(key);
735 }
736 return next;
737 });
738 setCollapseSnapshot(null);
739 return;
740 }
741 if (!hasExpandedFolders) return;
742 resetTopicWindowLimits();
743 setCollapseSnapshot({
744 expanded: new Set(expanded),
745 manuallyCollapsed: new Set(manuallyCollapsed),
746 });
747 setExpanded((prev) => {
748 let changed = false;
749 const next = new Set(prev);
750 for (const key of folderKeys) {
751 if (next.delete(key)) changed = true;
752 }
753 return changed ? next : prev;
754 });
755 updateManuallyCollapsed((prev) => {
756 let changed = false;
757 const next = new Set(prev);
758 for (const key of folderKeys) {
759 if (!next.has(key)) {
760 next.add(key);
761 changed = true;
762 }
763 }
764 return changed ? next : prev;
765 });
766 }, [collapseSnapshot, expanded, folderKeys, hasExpandedFolders, manuallyCollapsed, resetTopicWindowLimits, searchActive, updateManuallyCollapsed]);
767
768 const openWorkbenchHeaderMenu = (
769 event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>,
770 menu: Exclude<WorkbenchHeaderMenu, null>,
771 ) => {
772 event.preventDefault();
773 event.stopPropagation();
774 setMenuNodeKey(null);
775 setMenuProject(null);
776 setConfirmArchiveTarget(null);
777 setConfirmRemoveProject(null);
778 setMenuPoint(contextMenuPointFromEvent(event));
779 setWorkbenchHeaderMenu((value) => (value === menu ? null : menu));
780 };
781 const handleCreateTopic = async (scope: string, workspaceRoot: string, key: string) => {
782 if (creatingRef.current) return;
783 creatingRef.current = true;
784 setCreatingProject(key);
785 setMenuProject(null);
786 setMenuPoint(null);
787 setExpanded((prev) => {
788 const next = new Set(prev);
789 next.add(key);
790 return next;
791 });
792 updateManuallyCollapsed((prev) => {
793 if (!prev.has(key)) return prev;
794 const next = new Set(prev);
795 next.delete(key);
796 return next;
797 });
798 try {
799 if (onCreateTopic) {
800 await onCreateTopic(scope, workspaceRoot);
801 await refresh();
802 await onTopicsChanged?.();
803 return;
804 }
805 const targetRoot = scope === "project" ? workspaceRoot : "";
806 const topic = await app.CreateTopic(scope, targetRoot, "");
807 await refresh();
808 await onTopicsChanged?.();
809 await onOpenTopic(scope, targetRoot, topic.id);
810 } catch {
811 /* ignore */
812 } finally {
813 creatingRef.current = false;
814 setCreatingProject(null);
815 }
816 };
817
818 const handleCreateIsolatedWorktree = async (workspaceRoot: string) => {
819 if (!workspaceRoot || isolatingProject) return;
820 setIsolatingProject(workspaceRoot);
821 closeMenu();
822 try {
823 await onCreateIsolatedWorktree?.(workspaceRoot);
824 } catch (err) {
825 showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
826 } finally {
827 setIsolatingProject(null);
828 }
829 };
830 const trashTopicAny = (node: ProjectNode) => remoteSessionActions.remove(node.topicId ?? "", () =>
831 node.sessionPath ? trashSession(node) : trashTopic(node.topicId ?? ""));
832 const startRenameTopic = (node: ProjectNode, label: string) => {
833 setMenuNodeKey(null);
834 setMenuProject(null);
835 setMenuPoint(null);
836 setConfirmArchiveTarget(null);
837 setEditingTopic(projectSessionRowKey(node));
838 setTopicDraft(label);
839 };
840
841 const startRenameSession = (key: string, path: string, topicId: string, label: string) => {
842 setMenuNodeKey(null);
843 setMenuProject(null);
844 setMenuPoint(null);
845 setConfirmArchiveTarget(null);
846 setEditingSession({ key, path, topicId });
847 setTopicDraft(label);
848 };
849
850 const startRenameProject = (key: string, root: string, label: string) => {
851 setMenuProject(null);
852 setMenuNodeKey(null);
853 setMenuPoint(null);
854 setConfirmRemoveProject(null);
855 setEditingProject({ key, root });
856 setProjectDraft(label);
857 };
858
859 const commitRenameTopic = async (node: ProjectNode) => {
860 const topicId = node.topicId ?? "";
861 const title = topicDraft.trim();
862 setEditingTopic(null);
863 if (!title) return;
864 try {
865 if (await remoteSessionActions.mutate(topicId, (remote) => app.RenameRemoteProjectSession(remote.hostId, remote.workspace, remoteSessionActionIdentity(remote), title))) return;
866 if (node.session || node.sessionPath) await app.RenameSessionTarget({ ref: node.session, source: node.source, sessionPath: node.sessionPath }, title);
867 else if (onRenameTopic) await onRenameTopic(topicId, title);
868 else await app.RenameTopic(topicId, title);
869 // Paint the new label immediately; the catalog event round-trip can lag.
870 setTree((current) => applyRuntimeProjection(node.session || node.sessionPath
871 ? projectTreeWithSessionTitle(current, node, title) : projectTreeWithTopicTitle(current, topicId, title)));
872 await refresh();
873 if (!onRenameTopic) await onTopicsChanged?.();
874 } catch (err) {
875 showToast(err instanceof Error ? err.message : String(err), "error");
876 }
877 };
878
879 const commitRenameSession = async () => {
880 const editing = editingSession;
881 const title = topicDraft.trim();
882 setEditingSession(null);
883 if (!editing || !title) return;
884 try {
885 await app.RenameSessionTarget({ sessionPath: editing.path, topicId: editing.topicId }, title);
886 await refresh();
887 await onTopicsChanged?.();
888 } catch (err) {
889 showToast(t(sessionTitleErrorKey(err)), "error");
890 }
891 };
892
893 const { renaming: aiRenamingTopics, rename: aiRenameSession } = useSessionTitleOperation(refresh, onTopicsChanged);
894
895 const commitRenameProject = async (root: string) => {
896 const title = projectDraft.trim();
897 setEditingProject(null);
898 if (!title) return;
899 try {
900 if (!await renameRemoteProjectTitle(root, title)) await app.RenameProject(root, title);
901 await refresh();
902 } catch (err) {
903 showToast(err instanceof Error ? err.message : String(err), "error");
904 }
905 };
906
907 const setTopicPinned = async (node: ProjectNode, pinned: boolean) => {
908 const topicId = node.topicId ?? "";
909 setMenuNodeKey(null);
910 setMenuPoint(null);
911 try {
912 if (await remoteSessionActions.mutate(topicId, (remote) => app.SetRemoteSessionPinned(remote.hostId, remote.workspace, remoteSessionActionIdentity(remote), pinned))) return;
913 if (node.session || node.sessionPath) await app.SetSessionPinned({ ref: node.session, source: node.source, sessionPath: node.sessionPath }, pinned);
914 else await app.SetTopicPinned(topicId, pinned);
915 await refresh();
916 await onTopicsChanged?.();
917 } catch (err) {
918 showToast(err instanceof Error ? err.message : String(err), "error");
919 }
920 };
921
922 const setProjectPinned = async (workspaceRoot: string, pinned: boolean) => {
923 if (!workspaceRoot) return;
924 try {
925 await app.SetProjectPinned(workspaceRoot, pinned);
926 setMenuProject(null);
927 setMenuPoint(null);
928 await refresh();
929 await onTopicsChanged?.();
930 } catch (err) {
931 showToast(err instanceof Error ? err.message : String(err), "error");
932 }
933 };
934
935 const copyProjectPath = async (path: string) => {
936 if (!path) return;
937 try {
938 await navigator.clipboard?.writeText(path);
939 } catch {
940 /* ignore */
941 }
942 };
943
944 const removeProject = async (path: string) => {
945 if (!path) return;
946 try {
947 await app.RemoveWorkspace(path);
948 setMenuProject(null);
949 setMenuPoint(null);
950 setConfirmRemoveProject(null);
951 await refresh();
952 } catch (err) {
953 showToast(err instanceof Error ? err.message : String(err), "error");
954 }
955 };
956
957 const setProjectColor = async (path: string, color: string) => {
958 try {
959 await app.SetProjectColor(path, color);
960 setMenuProject(null);
961 setMenuPoint(null);
962 await refresh();
963 await onTopicsChanged?.();
964 } catch {
965 /* ignore */
966 }
967 };
968
969 const visibleTree = useMemo(() => {
970 const q = query.trim().toLowerCase();
971 const matchesQuery = (node: ProjectNode) =>
972 [node.label, node.root, node.topicId].some((value) => (value ?? "").toLowerCase().includes(q));
973 const filterNode = (node: ProjectNode, acceptedKeys?: ReadonlySet<string>): ProjectNode | null => {
974 const isFolder = node.kind === "project" || node.kind === "global_folder";
975 const folderAcceptedKeys = isFolder && q && !node.remote
976 ? new Set(topicPageState[projectTreeListKey(node.key, "", query)]?.itemKeys ?? [])
977 : undefined;
978 const children = asArray(node.children)
979 .map((child) => filterNode(child, folderAcceptedKeys ?? acceptedKeys))
980 .filter((child): child is ProjectNode => child !== null);
981 if (isFolder) {
982 if (children.length > 0 || matchesQuery(node)) return { ...node, children };
983 if (q) return null;
984 return node;
985 }
986 if (!q) return node;
987 if (acceptedKeys && (isTopicNode(node) || isRuntimeSessionNode(node)) && !node.pinned && !acceptedKeys.has(node.key)) return null;
988 if (q && !matchesQuery(node)) return null;
989 return node;
990 };
991 const filtered = treeWithRemoteSessions
992 .map((node) => filterNode(node))
993 .filter((node): node is ProjectNode => node !== null);
994 if (compactTopics) return arrangeWorkbenchTree(filtered, workbenchSortMode);
995 return arrangeWorkbenchTree(filtered, "updated");
996 }, [compactTopics, query, topicPageState, treeWithRemoteSessions, workbenchSortMode]);
997
998 const pinnedTreeSections = useMemo<PinnedTreeSections>(() => {
999 if (creationTopics) return { pinned: [], projects: visibleTree };
1000 return splitPinnedProjectTree(visibleTree, workbenchSortMode, compactTopics);
1001 }, [compactTopics, creationTopics, visibleTree, workbenchSortMode]);
1002
1003 const projectDragEnabled = query.trim() === "";
1004
1005 const commitProjectReorder = useCallback(async (draggedRoot: string, targetRoot: string, position: ProjectDropPosition) => {
1006 const nextRoots = reorderedProjectRoots(tree, draggedRoot, targetRoot, position);
1007 const currentRoots = projectTreeProjectRoots(tree);
1008 if (nextRoots.join("\n") === currentRoots.join("\n")) return;
1009 setTree((current) => applyProjectOrder(current, nextRoots));
1010 try {
1011 await app.ReorderProjects(nextRoots);
1012 await refresh();
1013 await onTopicsChanged?.();
1014 } catch {
1015 await refresh();
1016 }
1017 }, [onTopicsChanged, refresh, tree]);
1018
1019 const organization = useProjectTreeOrganization({ tree, refresh, onTopicsChanged, organizationRevision });
1020
1021 const clearProjectDrag = useCallback(() => {
1022 setDragProjectRoot(null);
1023 setDropProject(null);
1024 }, []);
1025
1026 useEffect(() => {
1027 if (!dragProjectRoot) return;
1028 window.addEventListener("dragend", clearProjectDrag);
1029 window.addEventListener("drop", clearProjectDrag);
1030 window.addEventListener("blur", clearProjectDrag);
1031 return () => {
1032 window.removeEventListener("dragend", clearProjectDrag);
1033 window.removeEventListener("drop", clearProjectDrag);
1034 window.removeEventListener("blur", clearProjectDrag);
1035 };
1036 }, [clearProjectDrag, dragProjectRoot]);
1037
1038 const activeAncestorKeys = useMemo(
1039 () => activeRemote ? activeRemoteProjectAncestorKeys(treeWithRemoteSessions, activeRemote, projectNodeKey) : activeSessionAncestorKeys(treeWithRemoteSessions, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath),
1040 [activeRemote, activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, treeWithRemoteSessions],
1041 );
1042 const activeNavigationKey = useMemo(() => activeRemote
1043 ? `remote\u001f${JSON.stringify(activeRemote)}`
1044 : activeTopicId
1045 ? [activeScope, activeWorkspaceRoot ?? "", activeTopicId].join("\u001f")
1046 : "", [activeRemote, activeScope, activeTopicId, activeWorkspaceRoot]);
1047 const followedActiveNavigationRef = useRef("");
1048 useEffect(() => {
1049 if (!activeNavigationKey) {
1050 followedActiveNavigationRef.current = "";
1051 return;
1052 }
1053 if (followedActiveNavigationRef.current === activeNavigationKey || activeAncestorKeys.length === 0) return;
1054 followedActiveNavigationRef.current = activeNavigationKey;
1055 const ancestorSet = new Set(activeAncestorKeys);
1056 updateManuallyCollapsed((prev) => {
1057 if (!activeAncestorKeys.some((key) => prev.has(key))) return prev;
1058 const next = new Set(prev);
1059 for (const key of activeAncestorKeys) next.delete(key);
1060 return next;
1061 });
1062 setExpanded((prev) => {
1063 if (activeAncestorKeys.every((key) => prev.has(key))) return prev;
1064 return new Set([...prev, ...ancestorSet]);
1065 });
1066 }, [activeAncestorKeys, activeNavigationKey, updateManuallyCollapsed]);
1067 useEffect(() => {
1068 if (!activeNavigationKey || !projectTreeRef.current) return;
1069 const root = projectTreeRef.current;
1070 let frame = 0;
1071 let observer: MutationObserver | null = null;
1072 const reveal = () => {
1073 const row = root.querySelector<HTMLElement>(".project-tree__topic--active .project-tree__topic-main");
1074 if (!row) return false;
1075 if (typeof row.scrollIntoView === "function") row.scrollIntoView({ block: "nearest", inline: "nearest" });
1076 observer?.disconnect();
1077 observer = null;
1078 return true;
1079 };
1080 frame = requestAnimationFrame(() => {
1081 if (reveal()) return;
1082 observer = new MutationObserver(() => { reveal(); });
1083 observer.observe(root, { childList: true, subtree: true });
1084 });
1085 return () => {
1086 cancelAnimationFrame(frame);
1087 observer?.disconnect();
1088 };
1089 }, [activeNavigationKey]);
1090 useEffect(() => {
1091 if (activeAncestorKeys.length === 0) return;
1092 setExpanded((prev) => {
1093 let changed = false;
1094 const next = new Set(prev);
1095 for (const key of activeAncestorKeys) {
1096 if (manuallyCollapsed.has(key) || next.has(key)) continue;
1097 next.add(key);
1098 changed = true;
1099 }
1100 return changed ? next : prev;
1101 });
1102 }, [activeAncestorKeys, manuallyCollapsed]);
1103
1104 const projectTreeDiagnosticSnapshot = useMemo<ProjectTreeDiagnosticSnapshot>(() => {
1105 const sessionSummary = summarizeProjectTreeSessions({
1106 tree,
1107 visibleTree,
1108 expanded,
1109 queryActive: query.trim().length > 0,
1110 expandedWindowCount: Object.values(topicWindowLimits).filter((limit) => limit > PROJECT_TREE_WINDOW_INITIAL).length,
1111 folderProjection: (folder, children) => {
1112 const groups = organization.groupsFor(folder);
1113 const scopedRows = (groupID: string, members: ProjectNode[]) => {
1114 if (folder.remote) return members;
1115 const accepted = new Set(topicListState(folder, query.trim() ? "" : groupID)?.itemKeys ?? []);
1116 const rows = members.filter((member) => accepted.has(member.key));
1117 if (!query.trim()) {
1118 const active = members.find((child) => topicIsActive(child, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath));
1119 if (active && !rows.some((row) => row.key === active.key)) rows.push(active);
1120 }
1121 return rows;
1122 };
1123 const ungrouped = children.filter((child) => !groups.some((group) => projectTreeGroupContainsNode(group, child)));
1124 const ungroupedRows = scopedRows("", ungrouped);
1125 const visible = projectTreeWindowRows(
1126 ungroupedRows,
1127 query.trim() ? ungroupedRows.length : topicListLimit(folder),
1128 (child) => topicIsActive(child, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath),
1129 );
1130 const collapsed: ProjectNode[] = [];
1131 for (const group of groups) {
1132 const members = children.filter((child) => projectTreeGroupContainsNode(group, child));
1133 const groupRows = scopedRows(group.id, members);
1134 if (!query.trim() && organization.groupCollapsed(projectTreeOrganizationKey(folder), group.id)) {
1135 collapsed.push(...groupRows);
1136 continue;
1137 }
1138 visible.push(...projectTreeWindowRows(
1139 groupRows,
1140 query.trim() ? groupRows.length : topicListLimit(folder, group.id),
1141 (child) => topicIsActive(child, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath),
1142 ));
1143 }
1144 return { visible, collapsed };
1145 },
1146 projectNodeKey,
1147 isActive: (node) => topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote),
1148 isUnread: (node) => projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, readBaselineAt),
1149 });
1150 return {
1151 ...sessionSummary,
1152 directoryState: catalogStatus.state,
1153 scope: activeScope === "global" ? "global" : activeScope ? "project" : "unknown",
1154 variant,
1155 queryActive: query.trim().length > 0,
1156 catalogPartial: catalogStatus.state !== "ready"
1157 || (catalogStatus.repairActive ?? 0) > 0
1158 || (catalogStatus.unindexedTargetCount ?? 0) > 0
1159 || Boolean(catalogStatus.lastError),
1160 catalogRebuilding: catalogStatus.state === "rebuilding",
1161 catalogRevision: catalogStatus.revision,
1162 catalogIndexed: catalogStatus.indexed,
1163 catalogTotal: catalogStatus.total,
1164 unloadedSessions: Math.max(0, catalogStatus.total - sessionSummary.workspaceSessions),
1165 repairPending: catalogStatus.repairPending,
1166 treeRevision: latestRevisionRef.current,
1167 organizationRevision,
1168 };
1169 }, [activeRemote, activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, catalogStatus, expanded, organization, organizationRevision, query, readActivity, readBaselineAt, topicListLimit, topicListState, topicWindowLimits, tree, variant, visibleTree]);
1170
1171 useProjectTreeFrontendDiagnostics(projectTreeDiagnosticSnapshot);
1172
1173 const renderNode = (node: ProjectNode | null | undefined, depth: number, section: "pinned" | "projects" = "projects", isVisible = true) => {
1174 if (!node) return null;
1175 const key = projectNodeKey(node, depth);
1176 const children = asArray(node.children);
1177 const isExpanded = query.trim() ? true : expanded.has(key);
1178 const hasChildren = children.length > 0;
1179 // Snapshot rows are shells with no children until the first page is loaded,
1180 // so every project folder must remain expandable while indexing.
1181 const folderDisclosure = projectTreeFolderDisclosure(hasChildren, isExpanded, true);
1182
1183 if (isTopicNode(node) || isRuntimeSessionNode(node)) {
1184 const isSessionNode = isRuntimeSessionNode(node);
1185 const openRequest = projectTreeTopicOpenRequest(node);
1186 const scope = openRequest?.scope ?? "project";
1187 const scopeClass = scope === "global" ? " project-tree__topic--global" : " project-tree__topic--project";
1188 const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
1189 const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote);
1190 const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
1191 const activityAt = node.lastActivityAt || node.createdAt || 0;
1192 // Every variant is a single-line row with the activity time on the right;
1193 // turns and the exact date ride on the row's title instead of a second
1194 // meta line.
1195 const sideTimeVisible = true;
1196 const timeLabel = activityAt ? topicActivityLabel(activityAt, t, true) : topicUnknownTimeLabel(node, t);
1197 const exactTimeLabel = activityAt ? topicActivityDateLabel(activityAt) : "";
1198 const metaFull = projectTreeTopicMetaLine(node, t, compactTopics);
1199 const status = topicStatus(node);
1200 const statusLabel = topicStatusLabel(node, t);
1201 const archiveBlocked = projectTreeTopicArchiveBlocked(node);
1202 const waitingConfirmation = status === "waiting_confirmation";
1203 // Compact workbench: waiting shows an amber "待确认" pill instead of a
1204 // spinning orange dot, and that pill replaces the relative time so the
1205 // paused-for-user state is scannable in the background tab list.
1206 const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
1207 const showWaitingPill = waitingConfirmation || status === "finishing" || status === "cancelling" || status === "unknown";
1208 const showSideTime = sideTimeVisible && !showWaitingPill;
1209 const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, readBaselineAt);
1210 const topicId = node.topicId ?? "";
1211 const aiRenameTarget = sessionTitleTarget(node);
1212 const topicTrashing = trashingTopics.has(topicId);
1213 const sessionPath = node.sessionPath?.trim() ?? "";
1214 const sessionTrashing = trashingSessions.has(projectSessionIdentity(node));
1215 const archiveTargetKey = sessionPath ? projectTreeSessionArchiveTargetKey(sessionPath) : projectTreeTopicArchiveTargetKey(scope, node.root ?? "", topicId);
1216 const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
1217 const imSourceLabel = imSource?.label || "";
1218 const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
1219 const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
1220 const recoveryLabel = node.recoveryState === "recovery_only"
1221 ? t("projectTree.recoveryOnly")
1222 : node.recovered
1223 ? t("projectTree.recovered")
1224 : "";
1225 const forkedFromLabel = node.sessionOrigin === "fork" && node.parentSession?.sessionId
1226 ? t("projectTree.forkedFrom", { source: node.parentSession.sessionId })
1227 : "";
1228 const title = [node.preview || "", label, forkedFromLabel, recoveryLabel, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
1229 const topicMenuOpen = menuNodeKey === key;
1230 const pinned = Boolean(node.pinned);
1231 const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
1232 const openTopicMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
1233 event.preventDefault();
1234 event.stopPropagation();
1235 setMenuProject(null);
1236 setConfirmRemoveProject(null);
1237 setMenuPoint(contextMenuPointFromEvent(event));
1238 setMenuNodeKey(key);
1239 setConfirmArchiveTarget(null);
1240 };
1241 const topicMenuItems: ContextMenuItem[] = [
1242 ...organization.topicMenuItems(node, t),
1243 ...(projectTreeTopicMenuOffersPin(variant)
1244 ? [
1245 {
1246 key: pinned ? "unpin" : "pin",
1247 icon: <Pin size={13} />,
1248 label: pinLabel,
1249 onSelect: () => void setTopicPinned(node, !pinned),
1250 },
1251 ]
1252 : []),
1253 {
1254 key: "rename",
1255 icon: <Pencil size={13} />,
1256 label: t("projectTree.renameTopic"),
1257 onSelect: () => startRenameTopic(node, label),
1258 },
1259 {
1260 key: "aiRename",
1261 icon: <Sparkles size={13} />,
1262 label: aiRenamingTopics.has(aiRenameTarget) ? t("projectTree.aiRenamingTopic") : t("projectTree.aiRenameTopic"),
1263 disabled: aiRenamingTopics.has(aiRenameTarget) || !aiRenameTarget || Boolean(node.remoteSession) || Boolean(node.session?.hostId && node.session.hostId !== "local"),
1264 onSelect: () => void aiRenameSession(aiRenameTarget),
1265 },
1266 {
1267 key: "trash",
1268 icon: <Archive className={topicTrashing || sessionTrashing ? "project-tree__archive-spinner" : undefined} size={13} />,
1269 label: confirmArchiveTarget === archiveTargetKey ? t("history.confirmMoveToTrash") : t("history.moveToTrash"),
1270 disabled: archiveBlocked || topicTrashing || sessionTrashing || remoteSessionArchiveBlocked(node.remoteSession),
1271 danger: true,
1272 onSelect: () => {
1273 if (confirmArchiveTarget === archiveTargetKey) void trashTopicAny(node);
1274 else setConfirmArchiveTarget(archiveTargetKey);
1275 },
1276 },
1277 ];
1278 if ((!isSessionNode && editingTopic === key) || (isSessionNode && editingSession?.key === key)) {
1279 return (
1280 <div
1281 key={key}
1282 className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}${imSource ? " project-tree__topic--im-source" : ""}${metaFull ? " project-tree__topic--has-meta" : ""}`}
1283 style={{ paddingLeft: 14 + depth * 16 }}
1284 >
1285 <input
1286 autoFocus
1287 className="project-tree__topic-input"
1288 value={topicDraft}
1289 onChange={(event) => setTopicDraft(event.target.value)}
1290 onFocus={(event) => event.target.select()}
1291 onKeyDown={(event) => {
1292 if (event.key === "Enter") void (isSessionNode ? commitRenameSession() : commitRenameTopic(node));
1293 if (event.key === "Escape") {
1294 setEditingTopic(null);
1295 setEditingSession(null);
1296 }
1297 }}
1298 onBlur={() => void (isSessionNode ? commitRenameSession() : commitRenameTopic(node))}
1299 />
1300 </div>
1301 );
1302 }
1303 const shortcutIndex = showShortcutBadges && isVisible && topicIndexRef.current < 9 ? topicIndexRef.current + 1 : 0;
1304 if (shortcutIndex > 0) topicIndexRef.current++;
1305 // Collect visible topics in render order for shortcut navigation
1306 if (openRequest && isVisible) {
1307 visibleTopicsCollectorRef.current.push({
1308 scope: openRequest.scope,
1309 workspaceRoot: openRequest.workspaceRoot,
1310 topicId: openRequest.topicId,
1311 sessionPath: openRequest.sessionPath,
1312 });
1313 }
1314 const topicDrag = organization.topicRow(node, section === "pinned" || isSessionNode || Boolean(query || menuNodeKey || editingTopic || dragProjectRoot || creatingProject));
1315 const row = (
1316 <div
1317 className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${topicDrag.className}${sideTimeVisible && (timeLabel || showStatusInSide || showWaitingPill) ? " project-tree__topic--with-side" : metaFull ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
1318 style={accentStyle}
1319 {...topicDrag.props}
1320 onContextMenu={openTopicMenu}
1321 >
1322 <button
1323 type="button"
1324 className="project-tree__topic-main"
1325 data-topic-open-key={key}
1326 title={title}
1327 style={{ paddingLeft: 14 + depth * 16 }}
1328 onClick={() => {
1329 const remote = node.remoteSession ?? remoteSessionActions.resolve(topicId);
1330 if (!openRequest && !remote) return;
1331 const nextClick = { rowKey: key, canRename: !isSessionNode };
1332 const pending = clickTimerRef.current;
1333 if (pending !== null) {
1334 clearTimeout(pending.timer);
1335 clickTimerRef.current = null;
1336 if (projectTreeShouldSuppressOpenForRename(pending, nextClick)) return;
1337 }
1338 const timer = setTimeout(() => {
1339 if (clickTimerRef.current?.timer !== timer) return;
1340 clickTimerRef.current = null;
1341 if (remote) {
1342 if (openRemoteSessionNode(remote, openRemoteProject)) markNodeRead(node);
1343 return;
1344 }
1345 if (openRequest) void Promise.resolve()
1346 .then(() => onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath))
1347 .then(() => markNodeRead(node))
1348 .catch((error) => showToast(String(error), "error"));
1349 }, 200);
1350 clickTimerRef.current = { ...nextClick, timer };
1351 }}
1352 onKeyDown={(event) => {
1353 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
1354 openTopicMenu(event);
1355 }
1356 }}
1357 onDoubleClick={(event) => {
1358 if (isSessionNode) return;
1359 event.stopPropagation();
1360 if (clickTimerRef.current !== null && clickTimerRef.current.rowKey === key) {
1361 clearTimeout(clickTimerRef.current.timer);
1362 clickTimerRef.current = null;
1363 }
1364 startRenameTopic(node, label);
1365 }}
1366 >
1367 <span className="project-tree__topic-copy">
1368 <span className="project-tree__topic-heading">
1369 <span className="project-tree__topic-label">{label}</span>
1370 <ProjectTreeSessionBadges node={node} forkedFromLabel={forkedFromLabel} recoveryLabel={recoveryLabel} />
1371 {imSource && (
1372 <span
1373 className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
1374 title={imSourceTitle}
1375 aria-label={imSourceTitle}
1376 >
1377 <MessageSquare size={11} />
1378 <span>{imSourceLabel}</span>
1379 </span>
1380 )}
1381 {!compactTopics && statusLabel && (
1382 <span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>
1383 )}
1384 </span>
1385 </span>
1386 {sideTimeVisible && (
1387 <span className={`project-tree__topic-side${!timeLabel && !showStatusInSide && !showWaitingPill ? " project-tree__topic-side--empty" : ""}`}>
1388 {showWaitingPill && statusLabel ? (
1389 <span
1390 className="project-tree__topic-waiting-pill"
1391 title={statusLabel}
1392 >
1393 {statusLabel}
1394 </span>
1395 ) : (
1396 <>
1397 {showStatusInSide && (
1398 <span
1399 className={`project-tree__topic-state project-tree__topic-state--${status}`}
1400 title={statusLabel}
1401 aria-hidden="true"
1402 />
1403 )}
1404 {showSideTime && timeLabel && (
1405 <span className="project-tree__topic-time" aria-hidden="true">{timeLabel}</span>
1406 )}
1407 </>
1408 )}
1409 </span>
1410 )}
1411 {compactTopics && statusLabel && !showWaitingPill && (
1412 <span className="sr-only">
1413 {statusLabel}
1414 </span>
1415 )}
1416 {compactTopics && metaFull && (
1417 <span className="sr-only">
1418 {metaFull}
1419 </span>
1420 )}
1421 </button>
1422 {unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
1423 {projectTreeShouldRenderTopicActions(isSessionNode, variant, unread) && !(node.remoteSession && !node.remoteSession.name) && (
1424 <span
1425 className="project-tree__topic-actions"
1426 aria-label={t("projectTree.topicActions")}
1427 >
1428 <Tooltip label={pinLabel} side="top" className="project-tree__topic-action-slot">
1429 <button
1430 className={`project-tree__topic-action${pinned ? " project-tree__topic-action--pinned" : ""}`}
1431 type="button"
1432 aria-label={pinLabel}
1433 aria-pressed={pinned}
1434 onClick={(event) => {
1435 event.preventDefault();
1436 event.stopPropagation();
1437 void setTopicPinned(node, !pinned);
1438 }}
1439 >
1440 <Pin size={15} aria-hidden="true" />
1441 </button>
1442 </Tooltip>
1443 <Tooltip label={t("projectTree.archiveTopic")} side="top" className="project-tree__topic-action-slot">
1444 <button
1445 className={`project-tree__topic-action project-tree__topic-action--archive${topicTrashing ? " project-tree__topic-action--busy" : ""}`}
1446 type="button"
1447 aria-label={t("projectTree.archiveTopic")}
1448 aria-busy={topicTrashing} disabled={archiveBlocked || topicTrashing}
1449 onClick={(event) => {
1450 event.preventDefault();
1451 event.stopPropagation();
1452 void trashTopicAny(node);
1453 }}
1454 >
1455 <Archive className={topicTrashing ? "project-tree__archive-spinner" : undefined} size={15} aria-hidden="true" />
1456 </button>
1457 </Tooltip>
1458 </span>
1459 )}
1460 {isSessionNode ? (
1461 <ProjectTreeSessionArchiveMenu
1462 open={topicMenuOpen} point={menuPoint} sessionPath={sessionPath} blocked={archiveBlocked || topicTrashing} busy={sessionTrashing} confirmed={confirmArchiveTarget === archiveTargetKey}
1463 aiBusy={aiRenamingTopics.has(aiRenameTarget)}
1464 onRename={() => startRenameSession(key, sessionPath, topicId, label)}
1465 onAIRename={() => void aiRenameSession(aiRenameTarget)}
1466 onConfirm={() => setConfirmArchiveTarget(archiveTargetKey)} onTrash={() => { setConfirmArchiveTarget(null); void trashSession(node); }} onClose={closeMenu} />
1467 ) : <ContextMenu open={topicMenuOpen} point={menuPoint} items={topicMenuItems} minWidth={178} ariaLabel={t("projectTree.topicActions")} onClose={closeMenu} />}
1468 {shortcutIndex > 0 && (
1469 <span className="project-tree__topic-shortcut" aria-hidden="true">
1470 {topicShortcutLabel(shortcutIndex, shortcutPlatform)}
1471 </span>
1472 )}
1473 </div>
1474 );
1475 return (
1476 <div key={key}>
1477 {row}
1478 {hasChildren && (
1479 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
1480 <div className="project-tree__children-inner">
1481 {children.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
1482 </div>
1483 </div>
1484 )}
1485 </div>
1486 );
1487 }
1488
1489 const scope = node.kind === "global_folder" ? "global" : "project";
1490 const scopeClass = scope === "global" ? " project-tree__folder--global" : " project-tree__folder--project";
1491 const pinnedClass = node.pinned ? " project-tree__folder--pinned" : "";
1492 const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
1493 const projectRoot = scope === "global" ? "" : node.root ?? "";
1494 const projectDragKey = scope === "global" ? GLOBAL_PROJECT_ORDER_KEY : projectRoot;
1495 const projectPath = node.root ?? "";
1496 const colorTargetRoot = scope === "global" ? "" : projectPath;
1497 const projectLabel = node.label || (scope === "global" ? "Global" : "Untitled");
1498 const workspaceDraft = workspaceDraftBadge(draftSummaries, scope, projectRoot);
1499 const projectPinned = Boolean(node.pinned);
1500 const projectActive = node.remote ? Boolean(activeRemote && remoteProjectKey(activeRemote) === remoteProjectKey(node.remote)) : activeScope === scope && (scope === "global" || activeWorkspaceRoot === node.root);
1501 const projectMenuOpen = menuProject?.key === key;
1502 const activeTopicInProject = Boolean(activeTopicId) && activeScope === scope && (scope === "global" || activeWorkspaceRoot === projectRoot);
1503 const sourceProjectNode = tree.find((candidate) => scope === "global"
1504 ? candidate.kind === "global_folder"
1505 : candidate.kind === "project" && candidate.root === projectRoot);
1506 const activeTopicArchiveBlocked = asArray(sourceProjectNode?.children).some((candidate) =>
1507 isTopicNode(candidate) && candidate.topicId === activeTopicId && projectTreeTopicArchiveBlocked(candidate));
1508 const draggableProject = section !== "pinned" && projectDragEnabled && depth === 0 && Boolean(projectDragKey) && editingProject?.key !== key;
1509 const projectDropPosition = dropProject?.root === projectDragKey ? dropProject?.position ?? null : null;
1510 const handleProjectDragStart = (event: ReactDragEvent<HTMLElement>) => {
1511 if (!draggableProject) return;
1512 const target = event.target;
1513 if (target instanceof Element && target.closest(".project-tree__action-slot,.project-tree__folder-action-slot")) {
1514 event.preventDefault();
1515 return;
1516 }
1517 event.dataTransfer.effectAllowed = "move";
1518 event.dataTransfer.setData("text/plain", projectDragKey);
1519 setDragProjectRoot(projectDragKey);
1520 setDropProject(null);
1521 };
1522 const handleProjectDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
1523 if (!draggableProject || !dragProjectRoot || dragProjectRoot === projectDragKey) return;
1524 event.preventDefault();
1525 event.dataTransfer.dropEffect = "move";
1526 const rect = event.currentTarget.getBoundingClientRect();
1527 const position: ProjectDropPosition = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
1528 setDropProject((current) => {
1529 if (current?.root === projectDragKey && current?.position === position) return current;
1530 return { root: projectDragKey, position };
1531 });
1532 };
1533 const handleProjectDrop = (event: ReactDragEvent<HTMLDivElement>) => {
1534 if (!draggableProject) return;
1535 const draggedRoot = dragProjectRoot || event.dataTransfer.getData("text/plain");
1536 const position = dropProject?.root === projectDragKey ? dropProject?.position ?? "after" : "after";
1537 event.preventDefault();
1538 clearProjectDrag();
1539 if (draggedRoot && draggedRoot !== projectDragKey) void commitProjectReorder(draggedRoot, projectDragKey, position);
1540 };
1541 const openProjectMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
1542 event.preventDefault();
1543 event.stopPropagation();
1544 setMenuNodeKey(null);
1545 setConfirmArchiveTarget(null);
1546 setMenuPoint(contextMenuPointFromEvent(event));
1547 setMenuProject({ key, root: projectRoot, path: projectPath, scope, label: projectLabel });
1548 setConfirmRemoveProject(null);
1549 if (scope === "project" && projectRoot) {
1550 void app.IsolatedWorktreeAvailability(projectRoot).then((availability) => {
1551 setWorktreeAvailability((current) => ({
1552 ...current,
1553 [projectRoot]: { available: availability.available, reason: availability.reason },
1554 }));
1555 }).catch(() => {});
1556 }
1557 };
1558 const isolationAvailability = worktreeAvailability[projectRoot];
1559 const activeTopicArchiveTarget = activeTopicId ? projectTreeTopicArchiveTargetKey(scope, projectRoot, activeTopicId) : "";
1560 const isolatedWorkspaceItems: ContextMenuItem[] = scope === "project"
1561 ? [{
1562 key: "isolated-delivery-workspace",
1563 icon: <GitBranch size={13} />,
1564 label: (
1565 <span title={isolationAvailability?.reason || t("projectTree.createWorktreeHint")}>
1566 {isolatingProject === projectRoot ? t("projectTree.creatingWorktree") : t("projectTree.createWorktree")}
1567 </span>
1568 ),
1569 disabled: isolatingProject !== null || isolationAvailability?.available === false,
1570 onSelect: () => { void handleCreateIsolatedWorktree(projectRoot); },
1571 }]
1572 : [];
1573 const remoteProjectMenuItems = node.remote ? buildRemoteProjectMenuItems({ ref: node.remote, t, closeMenu, openRemoteProject, openRemoteWindow, setRemoteSessions, refresh, showToast }) : [];
1574 const newSessionMenuItem: ContextMenuItem = {
1575 key: "new-session",
1576 icon: <Plus size={13} />,
1577 label: t("projectTree.newTopic"),
1578 onSelect: () => { void handleCreateTopic(scope, projectPath, key); },
1579 };
1580 const projectMenuItems: ContextMenuItem[] = [
1581 {
1582 key: "new-group",
1583 icon: <FolderPlus size={13} />,
1584 label: t("projectTree.newGroup"),
1585 onSelect: () => organization.createGroup(node, t("projectTree.newGroup")),
1586 },
1587 newSessionMenuItem,
1588 ...isolatedWorkspaceItems,
1589 {
1590 key: "rename",
1591 icon: <Pencil size={13} />,
1592 label: t("projectTree.renameProject"),
1593 onSelect: () => startRenameProject(key, projectRoot, projectLabel),
1594 },
1595 { type: "separator" as const, key: "color-separator" },
1596 ...PROJECT_COLOR_OPTIONS.map((option): ContextMenuItem => ({
1597 key: `color-${option.key || "default"}`,
1598 label: colorMenuLabel(projectColorLabel(t, option.key), option.key, (node.projectColor || "") === option.key),
1599 onSelect: () => {
1600 void setProjectColor(colorTargetRoot, option.key);
1601 },
1602 })),
1603 { type: "separator" as const, key: "path-separator" },
1604 {
1605 key: "reveal",
1606 icon: <FolderOpen size={13} />,
1607 label: t(revealLabelKey(platform)),
1608 disabled: !projectPath,
1609 onSelect: () => {
1610 void app.RevealPath(projectPath).catch(() => {});
1611 closeMenu();
1612 },
1613 },
1614 {
1615 key: "copy-path",
1616 icon: <Copy size={13} />,
1617 label: t("projectTree.copyPath"),
1618 disabled: !projectPath,
1619 onSelect: () => {
1620 void copyProjectPath(projectPath);
1621 closeMenu();
1622 },
1623 },
1624 ...(scope === "project"
1625 ? [
1626 { type: "separator" as const, key: "remove-separator" },
1627 {
1628 key: "remove",
1629 icon: <XCircle size={13} />,
1630 label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProject") : t("projectTree.removeProject"),
1631 danger: true,
1632 onSelect: () => {
1633 if (confirmRemoveProject === key) void removeProject(projectPath);
1634 else setConfirmRemoveProject(key);
1635 },
1636 },
1637 ]
1638 : []),
1639 ];
1640 const workbenchProjectMenuItems: ContextMenuItem[] = [
1641 newSessionMenuItem,
1642 ...(scope === "project"
1643 ? [
1644 {
1645 key: projectPinned ? "unpin-project" : "pin-project",
1646 icon: <Pin size={13} />,
1647 label: t(projectPinned ? "projectTree.unpinProject" : "projectTree.pinProject"),
1648 onSelect: () => {
1649 void setProjectPinned(projectRoot, !projectPinned);
1650 },
1651 },
1652 ]
1653 : []),
1654 ...isolatedWorkspaceItems,
1655 {
1656 key: "reveal",
1657 icon: <FolderOpen size={13} />,
1658 label: t(revealLabelKey(platform)),
1659 disabled: !projectPath,
1660 onSelect: () => {
1661 void app.RevealPath(projectPath).catch(() => {});
1662 closeMenu();
1663 },
1664 },
1665 {
1666 key: "rename",
1667 icon: <Pencil size={13} />,
1668 label: t("projectTree.renameProjectWorkbench"),
1669 onSelect: () => startRenameProject(key, projectRoot, projectLabel),
1670 },
1671 {
1672 key: "archive-active-topic",
1673 icon: <Archive className={activeTopicId && trashingTopics.has(activeTopicId) ? "project-tree__archive-spinner" : undefined} size={13} />,
1674 label: activeTopicArchiveTarget && confirmArchiveTarget === activeTopicArchiveTarget
1675 ? t("history.confirmMoveToTrash")
1676 : t("projectTree.archiveConversation"),
1677 disabled: !activeTopicInProject || !activeTopicId || activeTopicArchiveBlocked || Boolean(activeTopicId && trashingTopics.has(activeTopicId)),
1678 danger: true,
1679 onSelect: () => {
1680 if (!activeTopicId) return;
1681 if (confirmArchiveTarget === activeTopicArchiveTarget) void trashTopic(activeTopicId);
1682 else setConfirmArchiveTarget(activeTopicArchiveTarget);
1683 },
1684 },
1685 ...(scope === "project"
1686 ? [
1687 { type: "separator" as const, key: "remove-separator" },
1688 {
1689 key: "remove",
1690 icon: <XCircle size={13} />,
1691 label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProjectShort") : t("projectTree.removeProjectShort"),
1692 danger: true,
1693 onSelect: () => {
1694 if (confirmRemoveProject === key) void removeProject(projectPath);
1695 else setConfirmRemoveProject(key);
1696 },
1697 },
1698 ]
1699 : []),
1700 ];
1701
1702 const backendPage = topicListState(node);
1703 const renderFolderChildren = () => {
1704 const hasGroups = organization.groupsFor(node).length > 0;
1705 if (!hasChildren && !hasGroups) {
1706 const remoteGroupKey = node.remote ? remoteProjectKey(node.remote) : "";
1707 const remoteBusy = Boolean(remoteGroupBusy[remoteGroupKey]);
1708 const remoteError = remoteGroupError[remoteGroupKey] || "";
1709 if (node.remote) return <RemoteProjectEmptyState
1710 busy={remoteBusy} error={remoteError} ready={remoteServers[node.remote.hostId]?.[node.remote.workspace]?.state === "ready"}
1711 isExpanded={isExpanded} depth={depth} t={t} onEnsure={() => ensureRemoteGroupSessions(node.remote!.hostId, node.remote!.workspace)}
1712 />;
1713 // While the first topic page is still loading (cold start, catalog
1714 // reconcile in flight), show a skeleton instead of a blank folder.
1715 if (backendPage?.loading) {
1716 return (
1717 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
1718 <div className="project-tree__children-inner">
1719 <div className="project-tree__skeleton" style={{ paddingLeft: 14 + (depth + 1) * 16 }} aria-hidden="true">
1720 <span className="project-tree__skeleton-bar" />
1721 <span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
1722 <span className="project-tree__skeleton-bar" />
1723 <span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
1724 </div>
1725 </div>
1726 </div>
1727 );
1728 }
1729 if (!backendPage?.initialized) return null;
1730 }
1731 return (
1732 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
1733 <div className="project-tree__children-inner">
1734 <ProjectTreeGroupRows
1735 folder={node} children={children} depth={depth + 1} section={section} visible={isVisible && isExpanded}
1736 organization={organization} renderNode={renderNode} t={t} queryActive={query.trim().length > 0}
1737 remote={Boolean(node.remote)} activeTopicId={activeTopicId} isActive={(child) => topicIsActive(child, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)}
1738 listState={(groupID) => topicListState(node, groupID)} listLimit={(groupID) => topicListLimit(node, groupID)}
1739 onEnsureList={(groupID) => ensureTopicList(node, groupID)} onExpandList={(groupID, loadedCount) => expandTopicList(node, groupID, loadedCount)}
1740 onRetryList={(groupID) => retryTopicList(node, groupID)}
1741 onForgetList={(groupID) => forgetTopicList(node, groupID)}
1742 />
1743 {query.trim() && backendPage?.nextCursor && (
1744 <button
1745 type="button"
1746 className="project-tree__topic-window-toggle"
1747 style={{ paddingLeft: 14 + (depth + 1) * 16 }}
1748 disabled={backendPage.loading}
1749 aria-label={`${t("projectTree.loadMoreResults")} · ${projectLabel}`}
1750 onClick={() => void loadProjectTopics(node, true)}
1751 >
1752 {backendPage.loading ? t("projectTree.loadingMore") : t("projectTree.loadMoreResults")}
1753 </button>
1754 )}
1755 </div>
1756 </div>
1757 );
1758 };
1759
1760 if (editingProject?.key === key) {
1761 return (
1762 <div key={key} className="project-tree__project-wrapper">
1763 <div
1764 className={`project-tree__folder project-tree__folder--editing${projectActive ? " project-tree__folder--active" : ""}`}
1765 style={{ paddingLeft: 8 + depth * 16 }}
1766 >
1767 <input
1768 autoFocus
1769 className="project-tree__folder-input"
1770 value={projectDraft}
1771 onChange={(event) => setProjectDraft(event.target.value)}
1772 onKeyDown={(event) => {
1773 if (event.key === "Enter") void commitRenameProject(projectRoot);
1774 if (event.key === "Escape") setEditingProject(null);
1775 }}
1776 onBlur={() => void commitRenameProject(projectRoot)}
1777 />
1778 </div>
1779 {renderFolderChildren()}
1780 </div>
1781 );
1782 }
1783
1784 return (
1785 <div key={key} className="project-tree__project-wrapper">
1786 <div
1787 className={`project-tree__folder${scopeClass}${pinnedClass}${draggableProject ? " project-tree__folder--draggable" : ""}${projectActive ? " project-tree__folder--active" : ""}${projectMenuOpen ? " project-tree__folder--menu-open" : ""}${dragProjectRoot === projectDragKey ? " project-tree__folder--dragging" : ""}${projectDropPosition ? ` project-tree__folder--drop-${projectDropPosition}` : ""}`}
1788 style={accentStyle}
1789 draggable={draggableProject}
1790 aria-grabbed={draggableProject ? dragProjectRoot === projectRoot : undefined}
1791 onDragStart={handleProjectDragStart}
1792 onDragOver={handleProjectDragOver}
1793 onDragLeave={(event) => {
1794 if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropProject(null);
1795 }}
1796 onDrop={handleProjectDrop}
1797 onDragEnd={clearProjectDrag}
1798 onContextMenu={openProjectMenu}
1799 >
1800 <button
1801 type="button"
1802 className="project-tree__folder-main"
1803 style={{ paddingLeft: 8 + depth * 16 }}
1804 onClick={() => {
1805 if (node.remote && !folderDisclosure.canExpand) return void openRemoteProject(node.remote, { focus: true });
1806 if (folderDisclosure.canExpand) {
1807 const willExpand = !expanded.has(key);
1808 toggleExpand(key, node);
1809 if (node.remote && willExpand) { void ensureRemoteGroupSessions(node.remote.hostId, node.remote.workspace); }
1810 }
1811 }}
1812 onKeyDown={(event) => {
1813 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
1814 openProjectMenu(event);
1815 }
1816 }}
1817 aria-expanded={folderDisclosure.ariaExpanded}
1818 >
1819 <span className={folderDisclosure.iconStackClassName}>
1820 {node.remote ? <Cloud size={14} className="project-tree__folder-icon" /> : folderDisclosure.isOpen ? <FolderOpen size={14} className="project-tree__folder-icon" /> : <Folder size={14} className="project-tree__folder-icon" />}
1821 </span>
1822 <span className="project-tree__folder-color" aria-hidden="true" />
1823 <span className={`project-tree__folder-label${!hasChildren ? " project-tree__folder-label--empty" : ""}`}>
1824 {projectLabel}
1825 {node.isolatedWorktree && <WorktreeBadge size={11} />}
1826 {workspaceDraft ? <span
1827 className={`project-tree__draft-badge${workspaceDraft.state && workspaceDraft.state !== "saved" ? ` project-tree__draft-badge--${workspaceDraft.state}` : ""}`}
1828 role="button"
1829 tabIndex={0}
1830 title={workspaceDraft.state && workspaceDraft.state !== "saved" ? workspaceDraft.state : undefined}
1831 onClick={(event) => { event.stopPropagation(); void onOpenDraft?.(scope, projectRoot); }}
1832 onKeyDown={(event) => {
1833 if (event.key !== "Enter" && event.key !== " ") return;
1834 event.preventDefault();
1835 event.stopPropagation();
1836 void onOpenDraft?.(scope, projectRoot);
1837 }}
1838 >{t("draft.badge")}</span> : null}
1839 {node.remote ? <span className={`project-tree__remote-badge project-tree__remote-badge--${remoteServeBadgeState(remoteServers[node.remote.hostId]?.[node.remote.workspace], remoteGroupBusy[remoteProjectKey(node.remote)])}`} aria-hidden="true" /> : null}
1840 </span>
1841 <ProjectTreeFolderActivity folder={node} />
1842 </button>
1843 {compactTopics && (
1844 <Tooltip label={t("projectTree.projectActions")} className="project-tree__folder-action-slot">
1845 <button
1846 type="button"
1847 className="project-tree__folder-action project-tree__folder-action--menu"
1848 aria-label={t("projectTree.projectActions")}
1849 aria-haspopup="menu"
1850 aria-expanded={projectMenuOpen}
1851 onClick={(e) => {
1852 openProjectMenu(e);
1853 }}
1854 >
1855 <MoreHorizontal size={16} aria-hidden="true" />
1856 </button>
1857 </Tooltip>
1858 )}
1859 {!node.remote && <Tooltip label={t("projectTree.newTopicTooltip")} className={compactTopics ? "project-tree__folder-action-slot" : "project-tree__action-slot"}>
1860 <button
1861 type="button"
1862 className={compactTopics
1863 ? `project-tree__folder-action project-tree__folder-action--create${creatingProject === key ? " project-tree__folder-action--active" : ""}`
1864 : `project-tree__new-topic${creatingProject === key ? " project-tree__new-topic--active" : ""}`}
1865 aria-label={t("projectTree.newTopicTooltip")}
1866 disabled={creatingProject !== null}
1867 onClick={(e) => {
1868 e.stopPropagation();
1869 void handleCreateTopic(scope, projectPath, key);
1870 }}
1871 >
1872 {compactTopics ? <Plus size={15} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
1873 </button>
1874 </Tooltip>}
1875 <ContextMenu
1876 open={projectMenuOpen}
1877 point={menuPoint}
1878 items={node.remote ? remoteProjectMenuItems : compactTopics ? workbenchProjectMenuItems : projectMenuItems}
1879 minWidth={compactTopics ? 206 : 212}
1880 ariaLabel={t("projectTree.projectActions")}
1881 onClose={closeMenu}
1882 />
1883 </div>
1884 {renderFolderChildren()}
1885 </div>
1886 );
1887 };
1888
1889 const workbenchHeaderMoreItems: ContextMenuItem[] = [
1890 {
1891 key: "sort-heading",
1892 icon: <Clock size={13} />,
1893 label: t("projectTree.sortCriteria"),
1894 disabled: true,
1895 variant: "section",
1896 onSelect: () => {},
1897 },
1898 {
1899 key: "sort-created",
1900 icon: <Clock size={13} />,
1901 label: menuLabelWithCheck(t("projectTree.sortByCreatedAt"), workbenchSortMode === "created"),
1902 onSelect: () => {
1903 selectWorkbenchSortMode("created");
1904 },
1905 },
1906 {
1907 key: "sort-updated",
1908 icon: <Pencil size={13} />,
1909 label: menuLabelWithCheck(t("projectTree.sortByUpdatedAt"), workbenchSortMode === "updated"),
1910 onSelect: () => {
1911 selectWorkbenchSortMode("updated");
1912 },
1913 },
1914 ];
1915
1916 const addItemCallbacks = {
1917 onBlank: () => { closeMenu(); openBlankProjectFlow(); },
1918 onLocal: () => { closeMenu(); void handleAddProject(); },
1919 onRemote: () => { closeMenu(); openRemoteConnectFlow(); },
1920 };
1921 const classicHeaderAddItems = projectTreeHeaderAddItems({
1922 localLabel: t("projectTree.addProjectTooltip"), remoteLabel: t("projectTree.remoteConnection"), disabled: addingProject, ...addItemCallbacks,
1923 });
1924 const workbenchHeaderAddItems = projectTreeHeaderAddItems({
1925 blankLabel: t("projectTree.createBlankProject"), localLabel: t("projectTree.useExistingFolder"), remoteLabel: t("projectTree.remoteConnection"), disabled: addingProject, ...addItemCallbacks,
1926 });
1927
1928 const renderProjectHeader = (mode: "classic" | "workbench") => (
1929 <div className="project-tree__header">
1930 <span className="project-tree__header-title">
1931 <BriefcaseBusiness className="project-tree__header-icon" size={13} />
1932 {t("projectTree.workspaceTitle")}
1933 </span>
1934 <span className="project-tree__header-actions">
1935 {mode === "workbench" ? (
1936 <>
1937 <Tooltip label={workbenchCollapseToggleLabel} className="project-tree__header-action-slot">
1938 <button
1939 type="button"
1940 className="project-tree__header-icon-btn"
1941 aria-label={workbenchCollapseToggleLabel}
1942 disabled={!canToggleCollapsedView}
1943 onClick={toggleCollapsedView}
1944 >
1945 {canRestoreCollapsedView ? <Maximize2 size={15} aria-hidden="true" /> : <Minimize2 size={15} aria-hidden="true" />}
1946 </button>
1947 </Tooltip>
1948 <span className="project-tree__header-menu-wrap">
1949 <Tooltip label={t("projectTree.moreActions")} className="project-tree__header-action-slot">
1950 <button
1951 type="button"
1952 className={`project-tree__header-icon-btn${workbenchHeaderMenu === "more" ? " project-tree__header-icon-btn--active" : ""}`}
1953 aria-label={t("projectTree.moreActions")}
1954 aria-haspopup="menu"
1955 aria-expanded={workbenchHeaderMenu === "more"}
1956 onClick={(event) => {
1957 openWorkbenchHeaderMenu(event, "more");
1958 }}
1959 >
1960 <MoreHorizontal size={16} aria-hidden="true" />
1961 </button>
1962 </Tooltip>
1963 <ContextMenu
1964 open={workbenchHeaderMenu === "more"}
1965 point={menuPoint}
1966 items={workbenchHeaderMoreItems}
1967 minWidth={222}
1968 ariaLabel={t("projectTree.moreActions")}
1969 onClose={closeMenu}
1970 />
1971 </span>
1972 <span className="project-tree__header-menu-wrap">
1973 <Tooltip label={t("projectTree.addProjectTooltip")} className="project-tree__header-action-slot">
1974 <button
1975 type="button"
1976 className={`project-tree__header-icon-btn${workbenchHeaderMenu === "add" ? " project-tree__header-icon-btn--active" : ""}`}
1977 aria-label={t("projectTree.addProjectTooltip")}
1978 aria-haspopup="menu"
1979 aria-expanded={workbenchHeaderMenu === "add"}
1980 disabled={addingProject}
1981 onClick={(event) => {
1982 openWorkbenchHeaderMenu(event, "add");
1983 }}
1984 >
1985 <FolderPlus size={16} aria-hidden="true" />
1986 </button>
1987 </Tooltip>
1988 <ContextMenu
1989 open={workbenchHeaderMenu === "add"}
1990 point={menuPoint}
1991 items={workbenchHeaderAddItems}
1992 minWidth={206}
1993 ariaLabel={t("projectTree.addProjectTooltip")}
1994 onClose={closeMenu}
1995 />
1996 </span>
1997 </>
1998 ) : (
1999 <>
2000 <Tooltip label={collapseToggleLabel} className="project-tree__action-slot project-tree__header-action-slot project-tree__action-slot--collapse">
2001 <button
2002 type="button"
2003 className={`project-tree__collapse-all${canRestoreCollapsedView ? " project-tree__collapse-all--restore" : ""}`}
2004 aria-label={collapseToggleLabel}
2005 aria-pressed={canRestoreCollapsedView}
2006 disabled={!canToggleCollapsedView}
2007 onClick={toggleCollapsedView}
2008 >
2009 {canRestoreCollapsedView ? <ListRestart size={14} /> : <ListCollapse size={14} />}
2010 </button>
2011 </Tooltip>
2012 <ProjectTreeHeaderAddControl
2013 open={workbenchHeaderMenu === "add"} point={menuPoint} items={classicHeaderAddItems}
2014 label={t("projectTree.addProjectTooltip")} disabled={addingProject}
2015 onOpen={(event) => openWorkbenchHeaderMenu(event, "add")} onClose={closeMenu}
2016 />
2017 </>
2018 )}
2019 </span>
2020 </div>
2021 );
2022
2023 const renderEmptyState = () => {
2024 if (query.trim()) return <div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>;
2025 return (
2026 <div className="project-tree__empty-state">
2027 <div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.emptyNoProjects")}</div>
2028 <button
2029 type="button"
2030 className="project-tree__empty-primary"
2031 onClick={() => void handleAddProject()}
2032 disabled={addingProject}
2033 >
2034 <FolderPlus size={14} />
2035 <span>{t("projectTree.addProjectTooltip")}</span>
2036 </button>
2037 <ProjectTreeRemoteAction label={t("projectTree.remoteConnection")} disabled={addingProject} onClick={openRemoteConnectFlow} />
2038 </div>
2039 );
2040 };
2041
2042 const hasTreeRows = pinnedTreeSections.pinned.length > 0 || pinnedTreeSections.projects.length > 0;
2043
2044 // Report visible topics to parent after render so shortcuts match sidebar order.
2045 // eslint-disable-next-line react-hooks/exhaustive-deps
2046 useEffect(() => {
2047 onVisibleTopicsChange?.(visibleTopicsCollectorRef.current);
2048 });
2049
2050 // Reset topic index counter and visible topics collector before each render.
2051 topicIndexRef.current = 0;
2052 visibleTopicsCollectorRef.current = [];
2053 const catalogNotice = sessionCatalogNotice(catalogStatus);
2054 const catalogNoticeText = catalogNotice === "indexing"
2055 ? (catalogStatus.total <= 0 ? t("projectTree.indexing")
2056 : t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total }))
2057 : catalogNotice === "repair-active"
2058 ? t("projectTree.repairActive", { count: catalogStatus.repairActive ?? catalogStatus.repairPending })
2059 : catalogNotice === "repair-deferred"
2060 ? t("projectTree.repairDeferred")
2061 : catalogNotice === "repair-blocked"
2062 ? t("projectTree.repairBlocked", { count: catalogStatus.repairBlocked ?? catalogStatus.repairPending })
2063 : `${t("projectTree.indexing")} — ${t("task.state.failed")}`;
2064
2065 return (
2066 <div ref={projectTreeRef} className="project-tree">
2067 {searchVisible && (
2068 <label className="project-tree__search">
2069 <Search size={14} />
2070 <input
2071 ref={searchInputRef}
2072 value={query}
2073 onChange={(event) => changeQuery(event.target.value)}
2074 placeholder={t("projectTree.searchPlaceholder")}
2075 />
2076 </label>
2077 )}
2078 {catalogNotice && (
2079 <div className="project-tree__catalog-progress" role="status">
2080 <span>{catalogNoticeText}</span>
2081 {catalogNotice === "rebuild" && (
2082 <button type="button" className="project-tree__catalog-rebuild" onClick={() => void rebuildSessionCatalog()}>
2083 {t("projectTree.rebuildCatalog")}
2084 </button>
2085 )}
2086 </div>
2087 )}
2088 {compactTopics ? (
2089 <>
2090 {renderProjectHeader("workbench")}
2091 <div className="project-tree__list project-tree__list--workbench">
2092 {!hasTreeRows ? (
2093 renderEmptyState()
2094 ) : (
2095 <>
2096 {pinnedTreeSections.pinned.length > 0 && (
2097 <div className="project-tree__section project-tree__section--pinned">
2098 <div className="project-tree__section-title project-tree__section-title--pinned">
2099 <Pin size={14} className="project-tree__section-title-icon" aria-hidden="true" />
2100 <span>{t("projectTree.pinnedTitle")}</span>
2101 </div>
2102 {pinnedTreeSections.pinned.map((node) => renderNode(node, 0, "pinned"))}
2103 </div>
2104 )}
2105 <div className="project-tree__section project-tree__section--projects">
2106 {pinnedTreeSections.projects.map((node) => renderNode(node, 0, "projects"))}
2107 </div>
2108 </>
2109 )}
2110 </div>
2111 </>
2112 ) : (
2113 <>
2114 {renderProjectHeader("classic")}
2115 <div className="project-tree__list">
2116 {!hasTreeRows ? (
2117 renderEmptyState()
2118 ) : (
2119 <>
2120 {pinnedTreeSections.pinned.length > 0 && (
2121 <div className="project-tree__section project-tree__section--pinned">
2122 <div className="project-tree__section-title">{t("projectTree.pinnedTitle")}</div>
2123 {pinnedTreeSections.pinned.map((node) => renderNode(node, 1, "pinned"))}
2124 </div>
2125 )}
2126 <div className="project-tree__section project-tree__section--projects">
2127 {pinnedTreeSections.projects.map((node) => renderNode(node, 0, "projects"))}
2128 </div>
2129 </>
2130 )}
2131 </div>
2132 </>
2133 )}
2134 {blankProjectFlow}
2135 {remoteConnectFlow}
2136 </div>
2137 );
2138 }
2139
2139 lines Plain Text