返回 DeepSeek-Reasonix
ProjectTreeOrganization.tsx
根目录 / desktop / frontend / src / components / ProjectTreeOrganization.tsx
1 import { useCallback, useEffect, useRef, useState, lazy, Suspense, type DragEvent, type HTMLAttributes, type ReactNode } from "react";
2 import { Archive, Clock, FolderMinus, Pencil } from "lucide-react";
3 import { app } from "../lib/bridge";
4 import { asArray } from "../lib/array";
5 import type { Translator } from "../lib/i18n";
6 import { isTopicNode, projectTreeTopicArchiveBlocked } from "../lib/projectTreeTopic";
7 import type { ProjectTreeRefresh } from "../lib/projectTreeArchive";
8 import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types";
9 import { forgetProjectTreeWindowLimit, projectTreeListKey, projectTreeListShowsLoading, projectTreeWindowProjection, type ProjectTreeListPageState } from "../lib/projectTreeWindow";
10 import { projectSessionIdentity } from "../lib/projectSessionIdentity";
11 import { mutateSessionOrganization, projectNodeSelector } from "../lib/sessionOrganization";
12 import type { SessionOrganizationMutation } from "../generated/desktopContract.generated";
13 import { useToast } from "../lib/toast";
14 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
15
16 export type ProjectDropPosition = "before" | "after";
17
18 export const GLOBAL_PROJECT_ORDER_KEY = "__global__";
19 const TOPIC_DRAG_TYPE = "application/x-reasonix-topic-id";
20
21 function projectOrderKey(node: ProjectNode): string {
22 if (node.kind === "global_folder") return GLOBAL_PROJECT_ORDER_KEY;
23 if (node.kind === "project" && node.root) return node.root;
24 return "";
25 }
26
27 export function projectTreeProjectRoots(nodes: ProjectNode[]): string[] {
28 return nodes.map(projectOrderKey).filter((key) => key !== "");
29 }
30
31 export function reorderedProjectRoots(
32 nodes: ProjectNode[],
33 draggedRoot: string,
34 targetRoot: string,
35 position: ProjectDropPosition,
36 ): string[] {
37 const roots = projectTreeProjectRoots(nodes);
38 if (draggedRoot === targetRoot || !roots.includes(draggedRoot) || !roots.includes(targetRoot)) return roots;
39 const next = roots.filter((root) => root !== draggedRoot);
40 const targetIndex = next.indexOf(targetRoot);
41 if (targetIndex < 0) return roots;
42 next.splice(position === "before" ? targetIndex : targetIndex + 1, 0, draggedRoot);
43 return next;
44 }
45
46 export function applyProjectOrder(nodes: ProjectNode[], roots: string[]): ProjectNode[] {
47 const entries = nodes.map((node): [string, ProjectNode] => [projectOrderKey(node), node]).filter(([key]) => key !== "");
48 const byRoot = new Map(entries);
49 const ordered = roots.map((root) => byRoot.get(root)).filter((node): node is ProjectNode => Boolean(node));
50 const orderedKeys = new Set(roots);
51 return [...nodes.filter((node) => !orderedKeys.has(projectOrderKey(node))), ...ordered];
52 }
53
54 export function manualTopicOrder(a: ProjectNode, b: ProjectNode): number {
55 const aOrder = typeof a.sortOrder === "number" && a.sortOrder >= 0 ? a.sortOrder : Number.MAX_SAFE_INTEGER;
56 const bOrder = typeof b.sortOrder === "number" && b.sortOrder >= 0 ? b.sortOrder : Number.MAX_SAFE_INTEGER;
57 return aOrder === bOrder ? 0 : aOrder - bOrder;
58 }
59
60 export function projectTreeOrganizationKey(node: ProjectNode): string {
61 const remote = node.remote ?? node.remoteSession;
62 if (remote) return `remote|${JSON.stringify([remote.hostId, remote.workspace])}`;
63 return node.kind === "global_folder" || node.kind === "global_topic" ? "global|" : `project|${node.root ?? ""}`;
64 }
65
66 function splitOrganizationKey(key: string): { scope: "global" | "project"; root: string; hostId?: string } {
67 if (key.startsWith("remote|")) { const [hostId, root] = JSON.parse(key.slice(7)) as [string,string]; return { scope: "project", root, hostId }; }
68 return key === "global|" ? { scope: "global", root: "" } : { scope: "project", root: key.slice("project|".length) };
69 }
70
71 export function reorderedTopicIDs(
72 nodes: ProjectNode[],
73 scope: "global" | "project",
74 root: string,
75 draggedID: string,
76 targetID: string,
77 position: ProjectDropPosition,
78 ): string[] | null {
79 const parent = nodes.find((node) => projectTreeOrganizationKey(node) === (scope === "global" ? "global|" : `project|${root}`));
80 if (!parent) return null;
81 const ids = asArray(parent.children)
82 .filter((node) => isTopicNode(node) && !node.runtimeOnly && node.topicId)
83 .map((node) => node.topicId as string);
84 if (draggedID === targetID || !ids.includes(draggedID) || !ids.includes(targetID)) return null;
85 const rest = ids.filter((id) => id !== draggedID);
86 const targetIndex = rest.indexOf(targetID);
87 const insertAt = position === "before" ? targetIndex : targetIndex + 1;
88 return [...rest.slice(0, insertAt), draggedID, ...rest.slice(insertAt)];
89 }
90
91 export function reorderedSessionKeys(
92 nodes: ProjectNode[],
93 scope: "global" | "project",
94 root: string,
95 draggedKey: string,
96 targetKey: string,
97 position: ProjectDropPosition,
98 ): string[] | null {
99 const parent = nodes.find((node) => projectTreeOrganizationKey(node) === (scope === "global" ? "global|" : `project|${root}`));
100 if (!parent) return null;
101 const keys = asArray(parent.children)
102 .filter((node) => isTopicNode(node) && !node.runtimeOnly && (node.session || node.sessionPath))
103 .map(projectSessionIdentity);
104 if (draggedKey === targetKey || !keys.includes(draggedKey) || !keys.includes(targetKey)) return null;
105 const rest = keys.filter((key) => key !== draggedKey);
106 const targetIndex = rest.indexOf(targetKey);
107 const insertAt = position === "before" ? targetIndex : targetIndex + 1;
108 return [...rest.slice(0, insertAt), draggedKey, ...rest.slice(insertAt)];
109 }
110
111 export function projectTreeGroupContainsNode(group: SessionGroup, node: ProjectNode): boolean {
112 const key = projectSessionIdentity(node);
113 if (group.excludedSessionKeys?.includes(key)) return false;
114 if (group.sessionKeys?.includes(key)) return true;
115 return Boolean(node.topicId && group.topicIds?.includes(node.topicId));
116 }
117
118 function removeNodeFromGroup(group: SessionGroup, node: ProjectNode): SessionGroup {
119 const key = projectSessionIdentity(node);
120 const inheritsTopic = Boolean(node.topicId && group.topicIds?.includes(node.topicId));
121 const excluded = new Set(group.excludedSessionKeys ?? []);
122 if (inheritsTopic) excluded.add(key); else excluded.delete(key);
123 return {
124 ...group,
125 sessionKeys: (group.sessionKeys ?? []).filter((candidate) => candidate !== key),
126 excludedSessionKeys: [...excluded],
127 };
128 }
129
130 function moveNodeToGroup(groups: SessionGroup[], node: ProjectNode, groupID: string): SessionGroup[] {
131 const key = projectSessionIdentity(node);
132 return groups.map((group) => {
133 const without = removeNodeFromGroup(group, node);
134 if (group.id !== groupID) return without;
135 return {
136 ...without,
137 sessionKeys: [...(without.sessionKeys ?? []).filter((candidate) => candidate !== key), key],
138 excludedSessionKeys: (without.excludedSessionKeys ?? []).filter((candidate) => candidate !== key),
139 };
140 });
141 }
142
143 type TopicRowDragProps = Pick<HTMLAttributes<HTMLDivElement>, "draggable" | "onDragStart" | "onDragOver" | "onDragLeave" | "onDrop" | "onDragEnd">;
144
145 export interface ProjectTreeOrganizationController {
146 orderFor?(folder: ProjectNode): readonly string[];
147 resetOrderMenuItems(folder: ProjectNode, t: Translator, closeMenu: () => void): ContextMenuItem[];
148 topicRow(node: ProjectNode, disabled: boolean): { className: string; props: TopicRowDragProps };
149 topicMenuItems(node: ProjectNode, t: Translator): ContextMenuItem[];
150 createGroup(folder: ProjectNode, title: string): void;
151 groupsFor(folder: ProjectNode): SessionGroup[];
152 groupCollapsed(key: string, id: string): boolean;
153 toggleGroup(key: string, id: string): void;
154 renameGroup(key: string, id: string, title: string): void;
155 deleteGroup(key: string, id: string): void;
156 canDropTopicInto(key: string): boolean;
157 dropTopicInto(key: string, groupID: string): void;
158 }
159
160 export function useProjectTreeOrganization({
161 tree,
162 refresh,
163 onTopicsChanged,
164 organizationRevision = 0,
165 sortMode,
166 bindings = app,
167 }: {
168 tree: ProjectNode[];
169 refresh: ProjectTreeRefresh;
170 onTopicsChanged?: () => Promise<void> | void;
171 organizationRevision?: number;
172 // The activity order on screen; a first manual move starts from it.
173 sortMode?: string;
174 bindings?: ProjectTreeOrganizationBindings;
175 }): ProjectTreeOrganizationController {
176 const { showToast } = useToast();
177 const sortModeRef = useRef(sortMode);
178 sortModeRef.current = sortMode;
179 const [ordersByKey, setOrdersByKey] = useState<Record<string, string[]>>({});
180 const [dragTopicID, setDragTopicID] = useState<string | null>(null);
181 const [dropTopic, setDropTopic] = useState<{ topicID: string; position: ProjectDropPosition } | null>(null);
182 const dragContextRef = useRef<{ scope: "global" | "project"; root: string; hostId?: string; key?: string } | null>(null);
183 const [groupsByKey, setGroupsByKey] = useState<Record<string, SessionGroup[]>>({});
184 const groupsRef = useRef(groupsByKey);
185 const mountedRef = useRef(false);
186 const loadedGroupsRef = useRef(new Set<string>());
187 const loadingGroupsRef = useRef(new Set<string>());
188 const groupMutationVersionsRef = useRef<Record<string, number>>({});
189 const groupLoadSequencesRef = useRef<Record<string, number>>({});
190 const groupSaveChainsRef = useRef(new Map<string, Promise<void>>());
191 const organizationRevisionRef = useRef(organizationRevision);
192 const [collapsedGroups, setCollapsedGroups] = useState(new Set<string>());
193
194 useEffect(() => {
195 mountedRef.current = true;
196 return () => { mountedRef.current = false; };
197 }, []);
198
199 const setKeyGroups = useCallback((key: string, groups: SessionGroup[]) => {
200 groupsRef.current = { ...groupsRef.current, [key]: groups };
201 setGroupsByKey(groupsRef.current);
202 }, []);
203
204 const loadGroups = useCallback((key: string, force = false) => {
205 if (!force && (loadedGroupsRef.current.has(key) || loadingGroupsRef.current.has(key))) return;
206 const sequence = (groupLoadSequencesRef.current[key] ?? 0) + 1;
207 groupLoadSequencesRef.current[key] = sequence;
208 const mutationVersion = groupMutationVersionsRef.current[key] ?? 0;
209 loadingGroupsRef.current.add(key);
210 const { scope, root, hostId } = splitOrganizationKey(key);
211 const read = bindings.GetSessionOrganization ? bindings.GetSessionOrganization({ scope, workspaceRoot: root, hostId })
212 : typeof bindings.GetProjectGroups === "function"
213 ? bindings.GetProjectGroups(scope, root)
214 : bindings.ListProjectGroups(scope, root).then((groups) => ({ groups, revision: 0, applied: true }));
215 void read.then((snapshot) => {
216 if (!mountedRef.current || groupLoadSequencesRef.current[key] !== sequence) return;
217 if ((groupMutationVersionsRef.current[key] ?? 0) !== mutationVersion) return;
218 // Never replace an optimistic state while its semantic mutations are
219 // queued. The CAS path reads the newest server snapshot before applying.
220 if (groupSaveChainsRef.current.has(key)) return;
221 loadedGroupsRef.current.add(key);
222 setKeyGroups(key, asArray(snapshot.groups));
223 if ("order" in snapshot && "manualOrderEnabled" in snapshot) setOrdersByKey(current => ({ ...current, [key]: snapshot.manualOrderEnabled ? asArray(snapshot.order as string[]) : [] }));
224 }).catch(() => {}).finally(() => {
225 if (groupLoadSequencesRef.current[key] === sequence) loadingGroupsRef.current.delete(key);
226 });
227 }, [bindings, setKeyGroups]);
228
229 useEffect(() => {
230 const force = organizationRevisionRef.current !== organizationRevision;
231 organizationRevisionRef.current = organizationRevision;
232 for (const folder of tree) {
233 if (folder.kind !== "project" && folder.kind !== "global_folder") continue;
234 const key = projectTreeOrganizationKey(folder);
235 loadGroups(key, force);
236 }
237 }, [loadGroups, organizationRevision, tree]);
238
239 const mutateGroups = useCallback((key: string, update: (groups: SessionGroup[]) => SessionGroup[], mutation: SessionOrganizationMutation) => {
240 const next = update(groupsRef.current[key] ?? []);
241 groupMutationVersionsRef.current[key] = (groupMutationVersionsRef.current[key] ?? 0) + 1;
242 loadedGroupsRef.current.add(key);
243 setKeyGroups(key, next);
244 if (!bindings.UpdateSessionOrganization) {
245 showToast("Session organization is unavailable. Upgrade the desktop service.", "error");
246 loadGroups(key, true);
247 return;
248 }
249 const { scope, root, hostId } = splitOrganizationKey(key);
250 const previous = groupSaveChainsRef.current.get(key) ?? Promise.resolve();
251 const pending = previous.catch(() => {}).then(async () => {
252 const saved = await mutateSessionOrganization(bindings, { scope, workspaceRoot: root, hostId }, mutation);
253 if (mountedRef.current) {
254 setKeyGroups(key, asArray(saved.groups));
255 setOrdersByKey(current => ({ ...current, [key]: saved.manualOrderEnabled ? asArray(saved.order) : [] }));
256 }
257 await refresh({ reloadAllTopics: true });
258 });
259 groupSaveChainsRef.current.set(key, pending);
260 void pending.catch(error => {
261 if (groupSaveChainsRef.current.get(key) === pending) groupSaveChainsRef.current.delete(key);
262 showToast(error instanceof Error ? error.message : String(error), "error");
263 loadGroups(key, true);
264 }).finally(() => {
265 if (groupSaveChainsRef.current.get(key) === pending) groupSaveChainsRef.current.delete(key);
266 });
267 }, [bindings, loadGroups, refresh, setKeyGroups, showToast]);
268
269 const clearTopicDrag = useCallback(() => {
270 dragContextRef.current = null;
271 setDragTopicID(null);
272 setDropTopic(null);
273 }, []);
274
275 useEffect(() => {
276 if (!dragTopicID) return;
277 window.addEventListener("dragend", clearTopicDrag);
278 window.addEventListener("drop", clearTopicDrag);
279 window.addEventListener("blur", clearTopicDrag);
280 return () => {
281 window.removeEventListener("dragend", clearTopicDrag);
282 window.removeEventListener("drop", clearTopicDrag);
283 window.removeEventListener("blur", clearTopicDrag);
284 };
285 }, [clearTopicDrag, dragTopicID]);
286
287 const topicRow = useCallback((node: ProjectNode, disabled: boolean) => {
288 const topicID = node.topicId ?? "";
289 const sessionKey = projectSessionIdentity(node);
290 const key = projectTreeOrganizationKey(node);
291 const draggable = !disabled && !node.runtimeOnly && topicID !== "" && Boolean(node.session || node.sessionPath);
292 const sameScope = dragContextRef.current?.key === key;
293 const className = sameScope && dropTopic?.topicID === sessionKey && dragTopicID !== sessionKey ? ` project-tree__topic--drop-${dropTopic.position}` : "";
294 const props: TopicRowDragProps = { draggable };
295 if (!draggable) return { className, props };
296 props.onDragStart = (event) => {
297 const context = splitOrganizationKey(key);
298 dragContextRef.current = { ...context, key };
299 event.dataTransfer.setData(TOPIC_DRAG_TYPE, sessionKey);
300 event.dataTransfer.setData("text/plain", sessionKey);
301 event.dataTransfer.effectAllowed = "move";
302 setDragTopicID(sessionKey);
303 };
304 props.onDragOver = (event) => {
305 if (!sameScope || !dragTopicID || dragTopicID === sessionKey) return;
306 event.preventDefault();
307 event.dataTransfer.dropEffect = "move";
308 const rect = event.currentTarget.getBoundingClientRect();
309 const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
310 setDropTopic((current) => current?.topicID === sessionKey && current.position === position ? current : { topicID: sessionKey, position });
311 };
312 props.onDragLeave = () => setDropTopic((current) => current?.topicID === sessionKey ? null : current);
313 props.onDrop = (event: DragEvent<HTMLDivElement>) => {
314 event.preventDefault();
315 const draggedID = event.dataTransfer.getData(TOPIC_DRAG_TYPE) || dragTopicID;
316 const context = dragContextRef.current;
317 if (draggedID && context && sameScope) {
318 const rect = event.currentTarget.getBoundingClientRect();
319 const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
320 const folder = tree.find(folder => projectTreeOrganizationKey(folder) === key);
321 const dragged = folder?.children?.find(row => projectSessionIdentity(row) === draggedID);
322 const targetGroup = (groupsRef.current[key] ?? []).find((group) => projectTreeGroupContainsNode(group, node));
323 if (dragged && targetGroup && !projectTreeGroupContainsNode(targetGroup, dragged)) {
324 mutateGroups(key, (groups) => moveNodeToGroup(groups, dragged, targetGroup.id), { kind: "set-group", target: projectNodeSelector(dragged), groupId: targetGroup.id });
325 } else if (bindings.UpdateSessionOrganization && dragged) {
326 void mutateSessionOrganization(bindings, { scope: context.scope, workspaceRoot: context.root, hostId: splitOrganizationKey(key).hostId },
327 { kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position, sortMode: sortModeRef.current })
328 .then(saved => { setOrdersByKey(current => ({ ...current, [key]: saved.order })); return refresh({ reloadAllTopics: true }); })
329 .then(() => onTopicsChanged?.()).catch(error => { showToast(String(error), "error"); loadGroups(key, true); return refresh({ reloadAllTopics: true }); });
330 } else {
331 showToast("Session organization is unavailable. Upgrade the desktop service.", "error");
332 }
333 }
334 clearTopicDrag();
335 };
336 props.onDragEnd = clearTopicDrag;
337 return { className, props };
338 }, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, mutateGroups, onTopicsChanged, refresh, showToast, tree]);
339
340 const removeTopicFromGroups = useCallback((node: ProjectNode) => {
341 const key = projectTreeOrganizationKey(node);
342 mutateGroups(key, (groups) => groups.map((group) => removeNodeFromGroup(group, node)), { kind: "set-group", target: projectNodeSelector(node), groupId: "" });
343 }, [mutateGroups]);
344
345 const forgetGroupWindow = useCallback((key: string, id: string) => {
346 const folder = tree.find((node) => projectTreeOrganizationKey(node) === key);
347 if (folder) forgetProjectTreeWindowLimit(projectTreeListKey(folder.key, id));
348 }, [tree]);
349
350 return {
351 orderFor(folder) { return ordersByKey[projectTreeOrganizationKey(folder)] ?? []; },
352 resetOrderMenuItems(folder, t, closeMenu) {
353 const key = projectTreeOrganizationKey(folder);
354 return (ordersByKey[key]?.length ?? 0) > 0 ? [{
355 key: "reset-manual-order", icon: <Clock size={13} />, label: t("projectTree.resetManualOrder"),
356 onSelect: () => { closeMenu(); mutateGroups(key, (groups) => groups, { kind: "reset-order" }); },
357 }] : [];
358 },
359 topicRow,
360 topicMenuItems(node, t) {
361 if (!(groupsRef.current[projectTreeOrganizationKey(node)] ?? []).some((group) => projectTreeGroupContainsNode(group, node))) return [];
362 return [{ key: "remove-from-group", icon: <FolderMinus size={13} />, label: t("projectTree.removeFromGroup"), onSelect: () => removeTopicFromGroups(node) }];
363 },
364 createGroup(folder, title) {
365 const key = projectTreeOrganizationKey(folder);
366 const suffix = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
367 mutateGroups(key, (groups) => [...groups, { id: `group-${suffix}`, title, topicIds: [] }], { kind: "create-group", groupId: `group-${suffix}`, title });
368 },
369 groupsFor(folder) { return groupsByKey[projectTreeOrganizationKey(folder)] ?? []; },
370 groupCollapsed(key, id) { return collapsedGroups.has(`${key}|${id}`); },
371 toggleGroup(key, id) {
372 setCollapsedGroups((current) => {
373 const next = new Set(current), collapseKey = `${key}|${id}`;
374 if (next.has(collapseKey)) next.delete(collapseKey); else next.add(collapseKey);
375 return next;
376 });
377 },
378 renameGroup(key, id, title) {
379 const trimmed = title.trim();
380 if (!trimmed) forgetGroupWindow(key, id);
381 mutateGroups(key, (groups) => trimmed ? groups.map((group) => group.id === id ? { ...group, title: trimmed } : group) : groups.filter((group) => group.id !== id), { kind: trimmed ? "rename-group" : "delete-group", groupId: id, title: trimmed });
382 },
383 deleteGroup(key, id) {
384 forgetGroupWindow(key, id);
385 mutateGroups(key, (groups) => groups.filter((group) => group.id !== id), { kind: "delete-group", groupId: id });
386 },
387 canDropTopicInto(key) {
388 const context = dragContextRef.current;
389 return Boolean(dragTopicID && context?.key === key);
390 },
391 dropTopicInto(key, groupID) {
392 const sessionKey = dragTopicID;
393 if (!sessionKey) return;
394 const folder = tree.find((candidate) => projectTreeOrganizationKey(candidate) === key);
395 const node = asArray(folder?.children).find((candidate) => projectSessionIdentity(candidate) === sessionKey);
396 if (!node) return;
397 mutateGroups(key, (groups) => moveNodeToGroup(groups, node, groupID), { kind: "set-group", target: projectNodeSelector(node), groupId: groupID });
398 clearTopicDrag();
399 },
400 };
401 }
402
403 export function ProjectTreeGroupRows({
404 folder,
405 children,
406 depth,
407 section,
408 visible,
409 organization,
410 renderNode,
411 t,
412 queryActive,
413 remote,
414 activeTopicId,
415 isActive,
416 listState,
417 listLimit,
418 onEnsureList,
419 onExpandList,
420 onRetryList,
421 onForgetList,
422 }: {
423 folder: ProjectNode;
424 children: ProjectNode[];
425 depth: number;
426 section: "pinned" | "projects";
427 visible: boolean;
428 organization: ProjectTreeOrganizationController;
429 renderNode: (node: ProjectNode, depth: number, section: "pinned" | "projects", visible: boolean) => ReactNode;
430 t: Translator;
431 queryActive: boolean;
432 remote: boolean;
433 activeTopicId?: string;
434 isActive: (node: ProjectNode) => boolean;
435 listState: (groupID: string) => ProjectTreeListPageState | undefined;
436 listLimit: (groupID: string) => number;
437 onEnsureList: (groupID: string) => void;
438 onExpandList: (groupID: string, loadedCount: number) => void;
439 onRetryList: (groupID: string) => void;
440 onForgetList: (groupID: string) => void;
441 }) {
442 const [menuGroup, setMenuGroup] = useState<string | null>(null);
443 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
444 const [editingGroup, setEditingGroup] = useState<string | null>(null);
445 const [groupDraft, setGroupDraft] = useState("");
446 const key = projectTreeOrganizationKey(folder);
447 const groups = organization.groupsFor(folder);
448 const groupIDs = groups.map((group) => group.id).join("\u001f");
449 const expandedGroupIDs = groups
450 .filter((group) => !organization.groupCollapsed(key, group.id))
451 .map((group) => group.id)
452 .join("\u001f");
453 const previousActiveTopicRef = useRef<string | undefined>(undefined);
454 useEffect(() => {
455 const previous = previousActiveTopicRef.current;
456 previousActiveTopicRef.current = activeTopicId;
457 if (!activeTopicId || previous === activeTopicId) return;
458 const activeNode = children.find(isActive);
459 const activeGroup = activeNode ? groups.find((group) => projectTreeGroupContainsNode(group, activeNode)) : undefined;
460 if (activeGroup && organization.groupCollapsed(key, activeGroup.id)) organization.toggleGroup(key, activeGroup.id);
461 }, [activeTopicId, children, groupIDs, groups, isActive, key, organization]);
462 useEffect(() => {
463 if (!visible || queryActive || remote) return;
464 onEnsureList("");
465 for (const groupID of expandedGroupIDs.split("\u001f")) {
466 if (groupID) onEnsureList(groupID);
467 }
468 }, [expandedGroupIDs, onEnsureList, queryActive, remote, visible]);
469
470 const renderWindowControls = (groupID: string, label: string, loadedCount: number, hasHiddenLoadedRows: boolean) => {
471 if (queryActive) return null;
472 const state = listState(groupID);
473 const showLoading = projectTreeListShowsLoading(state);
474 const canExpand = hasHiddenLoadedRows || Boolean(state?.nextCursor);
475 if (!showLoading && !state?.error && !canExpand) return null;
476 return <div className="project-tree__topic-window-actions" style={{ paddingLeft: 14 + depth * 16 }}>
477 {state?.error ? <button type="button" className="project-tree__topic-window-toggle" aria-label={t("projectTree.retryGroup", { name: label })} onClick={() => onRetryList(groupID)}>
478 {t("projectTree.loadFailedRetry")}
479 </button> : null}
480 {showLoading ? <span className="project-tree__topic-window-status">{t("projectTree.loadingMore")}</span> : null}
481 {!showLoading && !state?.error && canExpand ? <button type="button" className="project-tree__topic-window-toggle" disabled={state?.loading} aria-label={t("projectTree.expandGroup", { name: label })} onClick={() => onExpandList(groupID, loadedCount)}>
482 {t("projectTree.expandDisplay")}
483 </button> : null}
484 </div>;
485 };
486
487 const scopedRows = (groupID: string, members: ProjectNode[]) => {
488 if (remote) {
489 const order = organization.orderFor?.(folder) ?? [];
490 const ranks = new Map(order.map((key, index) => [key, index]));
491 return [...members].sort((a,b) => (ranks.get(projectSessionIdentity(a)) ?? Number.MAX_SAFE_INTEGER) - (ranks.get(projectSessionIdentity(b)) ?? Number.MAX_SAFE_INTEGER));
492 }
493 const state = listState(queryActive ? "" : groupID);
494 const accepted = new Set(state?.itemKeys ?? []);
495 const rows = members.filter((member) => accepted.has(member.key));
496 if (!queryActive) {
497 const active = members.find(isActive);
498 if (active && !rows.some((row) => row.key === active.key)) rows.push(active);
499 }
500 return rows;
501 };
502
503 const ungrouped = children.filter((child) => !groups.some((group) => projectTreeGroupContainsNode(group, child)));
504 const ungroupedRows = scopedRows("", ungrouped);
505 const ungroupedProjection = queryActive
506 ? { rows: ungroupedRows, hasHiddenLoadedRows: false }
507 : projectTreeWindowProjection(ungroupedRows, listLimit(""), isActive);
508 const commitRename = (id: string) => {
509 if (!groupDraft.trim()) onForgetList(id);
510 organization.renameGroup(key, id, groupDraft);
511 setEditingGroup(null);
512 };
513 return <>
514 {ungroupedProjection.rows.map((child) => renderNode(child, depth, section, visible))}
515 {renderWindowControls("", folder.label, ungroupedRows.length, ungroupedProjection.hasHiddenLoadedRows)}
516 {groups.map((group) => {
517 const collapsed = queryActive ? false : organization.groupCollapsed(key, group.id);
518 const members = children.filter((child) => projectTreeGroupContainsNode(group, child));
519 const groupRows = scopedRows(group.id, members);
520 const groupProjection = queryActive
521 ? { rows: groupRows, hasHiddenLoadedRows: false }
522 : projectTreeWindowProjection(groupRows, listLimit(group.id), isActive);
523 if (queryActive && groupRows.length === 0) return null;
524 const canDrop = organization.canDropTopicInto(key);
525 return <div key={group.id} className={`project-tree__group${collapsed ? " project-tree__group--collapsed" : ""}`}>
526 <div
527 role="button"
528 tabIndex={0}
529 className={`project-tree__group-main${canDrop ? " project-tree__group-main--drop-target" : ""}`}
530 style={{ paddingLeft: 14 + depth * 16 }}
531 title={group.title}
532 onClick={() => organization.toggleGroup(key, group.id)}
533 onKeyDown={(event) => {
534 if (editingGroup === group.id) return;
535 if (event.key === "Enter" || event.key === " ") organization.toggleGroup(key, group.id);
536 }}
537 onContextMenu={(event) => {
538 event.preventDefault();
539 setMenuGroup(group.id);
540 setMenuPoint(contextMenuPointFromEvent(event));
541 }}
542 onDragOver={(event) => {
543 if (!canDrop) return;
544 event.preventDefault();
545 event.dataTransfer.dropEffect = "move";
546 }}
547 onDrop={(event) => {
548 event.preventDefault();
549 if (canDrop) organization.dropTopicInto(key, group.id);
550 }}
551 >
552 <span className="project-tree__group-chevron" aria-hidden="true">{collapsed ? "▸" : "▾"}</span>
553 {editingGroup === group.id ? <input
554 autoFocus
555 className="project-tree__group-input"
556 value={groupDraft}
557 onChange={(event) => setGroupDraft(event.target.value)}
558 onFocus={(event) => event.target.select()}
559 onKeyDown={(event) => {
560 if (event.key === "Enter") commitRename(group.id);
561 if (event.key === "Escape") setEditingGroup(null);
562 }}
563 onBlur={() => commitRename(group.id)}
564 onClick={(event) => event.stopPropagation()}
565 /> : <span className="project-tree__group-title">{group.title}</span>}
566 </div>
567 {menuGroup === group.id && <ContextMenu
568 open
569 point={menuPoint}
570 items={[
571 { key: "rename", icon: <Pencil size={13} />, label: t("projectTree.renameGroup"), onSelect: () => { setEditingGroup(group.id); setGroupDraft(group.title); setMenuGroup(null); } },
572 { key: "delete", icon: <Archive size={13} />, label: t("projectTree.deleteGroup"), danger: true, onSelect: () => { onForgetList(group.id); organization.deleteGroup(key, group.id); setMenuGroup(null); } },
573 ]}
574 minWidth={178}
575 ariaLabel={t("projectTree.renameGroup")}
576 onClose={() => setMenuGroup(null)}
577 />}
578 {!collapsed && <div className="project-tree__group-children">
579 {groupProjection.rows.map((child) => renderNode(child, depth, section, visible))}
580 {renderWindowControls(group.id, group.title, groupRows.length, groupProjection.hasHiddenLoadedRows)}
581 </div>}
582 </div>;
583 })}
584 </>;
585 }
586
587 export function projectTreeFolderHasActiveRuntime(folder: ProjectNode): boolean {
588 return asArray(folder.children).some(projectTreeTopicArchiveBlocked);
589 }
590
591 const FolderActivity = lazy(() => import("./RuntimeActivityIndicator"));
592 export function ProjectTreeFolderActivity({ folder }: { folder: ProjectNode }) {
593 return <Suspense fallback={null}><FolderActivity target={{
594 scope: folder.kind === "global_folder" ? "global" : "project",
595 root: folder.root ?? "",
596 remote: folder.remote,
597 topics: asArray(folder.children),
598 fallbackActive: projectTreeFolderHasActiveRuntime(folder),
599 }} /></Suspense>;
600 }
601
601 lines Plain Text