返回 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, 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, 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 topicRow(node: ProjectNode, disabled: boolean): { className: string; props: TopicRowDragProps };
148 topicMenuItems(node: ProjectNode, t: Translator): ContextMenuItem[];
149 createGroup(folder: ProjectNode, title: string): void;
150 groupsFor(folder: ProjectNode): SessionGroup[];
151 groupCollapsed(key: string, id: string): boolean;
152 toggleGroup(key: string, id: string): void;
153 renameGroup(key: string, id: string, title: string): void;
154 deleteGroup(key: string, id: string): void;
155 canDropTopicInto(key: string): boolean;
156 dropTopicInto(key: string, groupID: string): void;
157 }
158
159 export function useProjectTreeOrganization({
160 tree,
161 refresh,
162 onTopicsChanged,
163 organizationRevision = 0,
164 bindings = app,
165 }: {
166 tree: ProjectNode[];
167 refresh: ProjectTreeRefresh;
168 onTopicsChanged?: () => Promise<void> | void;
169 organizationRevision?: number;
170 bindings?: ProjectTreeOrganizationBindings;
171 }): ProjectTreeOrganizationController {
172 const { showToast } = useToast();
173 const [ordersByKey, setOrdersByKey] = useState<Record<string, string[]>>({});
174 const [dragTopicID, setDragTopicID] = useState<string | null>(null);
175 const [dropTopic, setDropTopic] = useState<{ topicID: string; position: ProjectDropPosition } | null>(null);
176 const dragContextRef = useRef<{ scope: "global" | "project"; root: string; hostId?: string; key?: string } | null>(null);
177 const [groupsByKey, setGroupsByKey] = useState<Record<string, SessionGroup[]>>({});
178 const groupsRef = useRef(groupsByKey);
179 const mountedRef = useRef(false);
180 const loadedGroupsRef = useRef(new Set<string>());
181 const loadingGroupsRef = useRef(new Set<string>());
182 const groupMutationVersionsRef = useRef<Record<string, number>>({});
183 const groupLoadSequencesRef = useRef<Record<string, number>>({});
184 const groupSaveChainsRef = useRef(new Map<string, Promise<void>>());
185 const organizationRevisionRef = useRef(organizationRevision);
186 const [collapsedGroups, setCollapsedGroups] = useState(new Set<string>());
187
188 useEffect(() => {
189 mountedRef.current = true;
190 return () => { mountedRef.current = false; };
191 }, []);
192
193 const setKeyGroups = useCallback((key: string, groups: SessionGroup[]) => {
194 groupsRef.current = { ...groupsRef.current, [key]: groups };
195 setGroupsByKey(groupsRef.current);
196 }, []);
197
198 const loadGroups = useCallback((key: string, force = false) => {
199 if (!force && (loadedGroupsRef.current.has(key) || loadingGroupsRef.current.has(key))) return;
200 const sequence = (groupLoadSequencesRef.current[key] ?? 0) + 1;
201 groupLoadSequencesRef.current[key] = sequence;
202 const mutationVersion = groupMutationVersionsRef.current[key] ?? 0;
203 loadingGroupsRef.current.add(key);
204 const { scope, root, hostId } = splitOrganizationKey(key);
205 const read = bindings.GetSessionOrganization ? bindings.GetSessionOrganization({ scope, workspaceRoot: root, hostId })
206 : typeof bindings.GetProjectGroups === "function"
207 ? bindings.GetProjectGroups(scope, root)
208 : bindings.ListProjectGroups(scope, root).then((groups) => ({ groups, revision: 0, applied: true }));
209 void read.then((snapshot) => {
210 if (!mountedRef.current || groupLoadSequencesRef.current[key] !== sequence) return;
211 if ((groupMutationVersionsRef.current[key] ?? 0) !== mutationVersion) return;
212 // Never replace an optimistic state while its semantic mutations are
213 // queued. The CAS path reads the newest server snapshot before applying.
214 if (groupSaveChainsRef.current.has(key)) return;
215 loadedGroupsRef.current.add(key);
216 setKeyGroups(key, asArray(snapshot.groups));
217 if ("order" in snapshot && "manualOrderEnabled" in snapshot) setOrdersByKey(current => ({ ...current, [key]: snapshot.manualOrderEnabled ? asArray(snapshot.order as string[]) : [] }));
218 }).catch(() => {}).finally(() => {
219 if (groupLoadSequencesRef.current[key] === sequence) loadingGroupsRef.current.delete(key);
220 });
221 }, [bindings, setKeyGroups]);
222
223 useEffect(() => {
224 const force = organizationRevisionRef.current !== organizationRevision;
225 organizationRevisionRef.current = organizationRevision;
226 for (const folder of tree) {
227 if (folder.kind !== "project" && folder.kind !== "global_folder") continue;
228 const key = projectTreeOrganizationKey(folder);
229 loadGroups(key, force);
230 }
231 }, [loadGroups, organizationRevision, tree]);
232
233 const mutateGroups = useCallback((key: string, update: (groups: SessionGroup[]) => SessionGroup[], mutation: SessionOrganizationMutation) => {
234 const next = update(groupsRef.current[key] ?? []);
235 groupMutationVersionsRef.current[key] = (groupMutationVersionsRef.current[key] ?? 0) + 1;
236 loadedGroupsRef.current.add(key);
237 setKeyGroups(key, next);
238 if (!bindings.UpdateSessionOrganization) {
239 showToast("Session organization is unavailable. Upgrade the desktop service.", "error");
240 loadGroups(key, true);
241 return;
242 }
243 const { scope, root, hostId } = splitOrganizationKey(key);
244 const previous = groupSaveChainsRef.current.get(key) ?? Promise.resolve();
245 const pending = previous.catch(() => {}).then(async () => {
246 const saved = await mutateSessionOrganization(bindings, { scope, workspaceRoot: root, hostId }, mutation);
247 if (mountedRef.current) {
248 setKeyGroups(key, asArray(saved.groups));
249 setOrdersByKey(current => ({ ...current, [key]: saved.manualOrderEnabled ? asArray(saved.order) : [] }));
250 }
251 await refresh({ reloadAllTopics: true });
252 });
253 groupSaveChainsRef.current.set(key, pending);
254 void pending.catch(error => {
255 if (groupSaveChainsRef.current.get(key) === pending) groupSaveChainsRef.current.delete(key);
256 showToast(error instanceof Error ? error.message : String(error), "error");
257 loadGroups(key, true);
258 }).finally(() => {
259 if (groupSaveChainsRef.current.get(key) === pending) groupSaveChainsRef.current.delete(key);
260 });
261 }, [bindings, loadGroups, refresh, setKeyGroups, showToast]);
262
263 const clearTopicDrag = useCallback(() => {
264 dragContextRef.current = null;
265 setDragTopicID(null);
266 setDropTopic(null);
267 }, []);
268
269 useEffect(() => {
270 if (!dragTopicID) return;
271 window.addEventListener("dragend", clearTopicDrag);
272 window.addEventListener("drop", clearTopicDrag);
273 window.addEventListener("blur", clearTopicDrag);
274 return () => {
275 window.removeEventListener("dragend", clearTopicDrag);
276 window.removeEventListener("drop", clearTopicDrag);
277 window.removeEventListener("blur", clearTopicDrag);
278 };
279 }, [clearTopicDrag, dragTopicID]);
280
281 const topicRow = useCallback((node: ProjectNode, disabled: boolean) => {
282 const topicID = node.topicId ?? "";
283 const sessionKey = projectSessionIdentity(node);
284 const key = projectTreeOrganizationKey(node);
285 const draggable = !disabled && !node.runtimeOnly && topicID !== "" && Boolean(node.session || node.sessionPath);
286 const sameScope = dragContextRef.current?.key === key;
287 const className = sameScope && dropTopic?.topicID === sessionKey && dragTopicID !== sessionKey ? ` project-tree__topic--drop-${dropTopic.position}` : "";
288 const props: TopicRowDragProps = { draggable };
289 if (!draggable) return { className, props };
290 props.onDragStart = (event) => {
291 const context = splitOrganizationKey(key);
292 dragContextRef.current = { ...context, key };
293 event.dataTransfer.setData(TOPIC_DRAG_TYPE, sessionKey);
294 event.dataTransfer.setData("text/plain", sessionKey);
295 event.dataTransfer.effectAllowed = "move";
296 setDragTopicID(sessionKey);
297 };
298 props.onDragOver = (event) => {
299 if (!sameScope || !dragTopicID || dragTopicID === sessionKey) return;
300 event.preventDefault();
301 event.dataTransfer.dropEffect = "move";
302 const rect = event.currentTarget.getBoundingClientRect();
303 const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
304 setDropTopic((current) => current?.topicID === sessionKey && current.position === position ? current : { topicID: sessionKey, position });
305 };
306 props.onDragLeave = () => setDropTopic((current) => current?.topicID === sessionKey ? null : current);
307 props.onDrop = (event: DragEvent<HTMLDivElement>) => {
308 event.preventDefault();
309 const draggedID = event.dataTransfer.getData(TOPIC_DRAG_TYPE) || dragTopicID;
310 const context = dragContextRef.current;
311 if (draggedID && context && sameScope) {
312 const rect = event.currentTarget.getBoundingClientRect();
313 const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
314 const folder = tree.find(folder => projectTreeOrganizationKey(folder) === key);
315 const dragged = folder?.children?.find(row => projectSessionIdentity(row) === draggedID);
316 if (bindings.UpdateSessionOrganization && dragged) {
317 void mutateSessionOrganization(bindings, { scope: context.scope, workspaceRoot: context.root, hostId: splitOrganizationKey(key).hostId },
318 { kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position })
319 .then(saved => { setOrdersByKey(current => ({ ...current, [key]: saved.order })); return refresh({ reloadAllTopics: true }); })
320 .then(() => onTopicsChanged?.()).catch(error => { showToast(String(error), "error"); loadGroups(key, true); return refresh({ reloadAllTopics: true }); });
321 } else {
322 showToast("Session organization is unavailable. Upgrade the desktop service.", "error");
323 }
324 }
325 clearTopicDrag();
326 };
327 props.onDragEnd = clearTopicDrag;
328 return { className, props };
329 }, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, onTopicsChanged, refresh, showToast, tree]);
330
331 const removeTopicFromGroups = useCallback((node: ProjectNode) => {
332 const key = projectTreeOrganizationKey(node);
333 mutateGroups(key, (groups) => groups.map((group) => removeNodeFromGroup(group, node)), { kind: "set-group", target: projectNodeSelector(node), groupId: "" });
334 }, [mutateGroups]);
335
336 const forgetGroupWindow = useCallback((key: string, id: string) => {
337 const folder = tree.find((node) => projectTreeOrganizationKey(node) === key);
338 if (folder) forgetProjectTreeWindowLimit(projectTreeListKey(folder.key, id));
339 }, [tree]);
340
341 return {
342 orderFor(folder) { return ordersByKey[projectTreeOrganizationKey(folder)] ?? []; },
343 topicRow,
344 topicMenuItems(node, t) {
345 if (!(groupsRef.current[projectTreeOrganizationKey(node)] ?? []).some((group) => projectTreeGroupContainsNode(group, node))) return [];
346 return [{ key: "remove-from-group", icon: <FolderMinus size={13} />, label: t("projectTree.removeFromGroup"), onSelect: () => removeTopicFromGroups(node) }];
347 },
348 createGroup(folder, title) {
349 const key = projectTreeOrganizationKey(folder);
350 const suffix = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
351 mutateGroups(key, (groups) => [...groups, { id: `group-${suffix}`, title, topicIds: [] }], { kind: "create-group", groupId: `group-${suffix}`, title });
352 },
353 groupsFor(folder) { return groupsByKey[projectTreeOrganizationKey(folder)] ?? []; },
354 groupCollapsed(key, id) { return collapsedGroups.has(`${key}|${id}`); },
355 toggleGroup(key, id) {
356 setCollapsedGroups((current) => {
357 const next = new Set(current), collapseKey = `${key}|${id}`;
358 if (next.has(collapseKey)) next.delete(collapseKey); else next.add(collapseKey);
359 return next;
360 });
361 },
362 renameGroup(key, id, title) {
363 const trimmed = title.trim();
364 if (!trimmed) forgetGroupWindow(key, id);
365 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 });
366 },
367 deleteGroup(key, id) {
368 forgetGroupWindow(key, id);
369 mutateGroups(key, (groups) => groups.filter((group) => group.id !== id), { kind: "delete-group", groupId: id });
370 },
371 canDropTopicInto(key) {
372 const context = dragContextRef.current;
373 return Boolean(dragTopicID && context?.key === key);
374 },
375 dropTopicInto(key, groupID) {
376 const sessionKey = dragTopicID;
377 if (!sessionKey) return;
378 const folder = tree.find((candidate) => projectTreeOrganizationKey(candidate) === key);
379 const node = asArray(folder?.children).find((candidate) => projectSessionIdentity(candidate) === sessionKey);
380 if (!node) return;
381 mutateGroups(key, (groups) => moveNodeToGroup(groups, node, groupID), { kind: "set-group", target: projectNodeSelector(node), groupId: groupID });
382 clearTopicDrag();
383 },
384 };
385 }
386
387 export function ProjectTreeGroupRows({
388 folder,
389 children,
390 depth,
391 section,
392 visible,
393 organization,
394 renderNode,
395 t,
396 queryActive,
397 remote,
398 activeTopicId,
399 isActive,
400 listState,
401 listLimit,
402 onEnsureList,
403 onExpandList,
404 onRetryList,
405 onForgetList,
406 }: {
407 folder: ProjectNode;
408 children: ProjectNode[];
409 depth: number;
410 section: "pinned" | "projects";
411 visible: boolean;
412 organization: ProjectTreeOrganizationController;
413 renderNode: (node: ProjectNode, depth: number, section: "pinned" | "projects", visible: boolean) => ReactNode;
414 t: Translator;
415 queryActive: boolean;
416 remote: boolean;
417 activeTopicId?: string;
418 isActive: (node: ProjectNode) => boolean;
419 listState: (groupID: string) => ProjectTreeListPageState | undefined;
420 listLimit: (groupID: string) => number;
421 onEnsureList: (groupID: string) => void;
422 onExpandList: (groupID: string, loadedCount: number) => void;
423 onRetryList: (groupID: string) => void;
424 onForgetList: (groupID: string) => void;
425 }) {
426 const [menuGroup, setMenuGroup] = useState<string | null>(null);
427 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
428 const [editingGroup, setEditingGroup] = useState<string | null>(null);
429 const [groupDraft, setGroupDraft] = useState("");
430 const key = projectTreeOrganizationKey(folder);
431 const groups = organization.groupsFor(folder);
432 const groupIDs = groups.map((group) => group.id).join("\u001f");
433 const expandedGroupIDs = groups
434 .filter((group) => !organization.groupCollapsed(key, group.id))
435 .map((group) => group.id)
436 .join("\u001f");
437 const previousActiveTopicRef = useRef<string | undefined>(undefined);
438 useEffect(() => {
439 const previous = previousActiveTopicRef.current;
440 previousActiveTopicRef.current = activeTopicId;
441 if (!activeTopicId || previous === activeTopicId) return;
442 const activeNode = children.find(isActive);
443 const activeGroup = activeNode ? groups.find((group) => projectTreeGroupContainsNode(group, activeNode)) : undefined;
444 if (activeGroup && organization.groupCollapsed(key, activeGroup.id)) organization.toggleGroup(key, activeGroup.id);
445 }, [activeTopicId, children, groupIDs, groups, isActive, key, organization]);
446 useEffect(() => {
447 if (!visible || queryActive || remote) return;
448 onEnsureList("");
449 for (const groupID of expandedGroupIDs.split("\u001f")) {
450 if (groupID) onEnsureList(groupID);
451 }
452 }, [expandedGroupIDs, onEnsureList, queryActive, remote, visible]);
453
454 const renderWindowControls = (groupID: string, label: string, loadedCount: number, hasHiddenLoadedRows: boolean) => {
455 if (queryActive) return null;
456 const state = listState(groupID);
457 const canExpand = hasHiddenLoadedRows || Boolean(state?.nextCursor);
458 if (!state?.loading && !state?.error && !canExpand) return null;
459 return <div className="project-tree__topic-window-actions" style={{ paddingLeft: 14 + depth * 16 }}>
460 {state?.error ? <button type="button" className="project-tree__topic-window-toggle" aria-label={t("projectTree.retryGroup", { name: label })} onClick={() => onRetryList(groupID)}>
461 {t("projectTree.loadFailedRetry")}
462 </button> : null}
463 {state?.loading ? <span className="project-tree__topic-window-status">{t("projectTree.loadingMore")}</span> : null}
464 {!state?.loading && !state?.error && canExpand ? <button type="button" className="project-tree__topic-window-toggle" aria-label={t("projectTree.expandGroup", { name: label })} onClick={() => onExpandList(groupID, loadedCount)}>
465 {t("projectTree.expandDisplay")}
466 </button> : null}
467 </div>;
468 };
469
470 const scopedRows = (groupID: string, members: ProjectNode[]) => {
471 if (remote) {
472 const order = organization.orderFor?.(folder) ?? [];
473 const ranks = new Map(order.map((key, index) => [key, index]));
474 return [...members].sort((a,b) => (ranks.get(projectSessionIdentity(a)) ?? Number.MAX_SAFE_INTEGER) - (ranks.get(projectSessionIdentity(b)) ?? Number.MAX_SAFE_INTEGER));
475 }
476 const state = listState(queryActive ? "" : groupID);
477 const accepted = new Set(state?.itemKeys ?? []);
478 const rows = members.filter((member) => accepted.has(member.key));
479 if (!queryActive) {
480 const active = members.find(isActive);
481 if (active && !rows.some((row) => row.key === active.key)) rows.push(active);
482 }
483 return rows;
484 };
485
486 const ungrouped = children.filter((child) => !groups.some((group) => projectTreeGroupContainsNode(group, child)));
487 const ungroupedRows = scopedRows("", ungrouped);
488 const ungroupedProjection = queryActive
489 ? { rows: ungroupedRows, hasHiddenLoadedRows: false }
490 : projectTreeWindowProjection(ungroupedRows, listLimit(""), isActive);
491 const commitRename = (id: string) => {
492 if (!groupDraft.trim()) onForgetList(id);
493 organization.renameGroup(key, id, groupDraft);
494 setEditingGroup(null);
495 };
496 return <>
497 {ungroupedProjection.rows.map((child) => renderNode(child, depth, section, visible))}
498 {renderWindowControls("", folder.label, ungroupedRows.length, ungroupedProjection.hasHiddenLoadedRows)}
499 {groups.map((group) => {
500 const collapsed = queryActive ? false : organization.groupCollapsed(key, group.id);
501 const members = children.filter((child) => projectTreeGroupContainsNode(group, child));
502 const groupRows = scopedRows(group.id, members);
503 const groupProjection = queryActive
504 ? { rows: groupRows, hasHiddenLoadedRows: false }
505 : projectTreeWindowProjection(groupRows, listLimit(group.id), isActive);
506 if (queryActive && groupRows.length === 0) return null;
507 const canDrop = organization.canDropTopicInto(key);
508 return <div key={group.id} className={`project-tree__group${collapsed ? " project-tree__group--collapsed" : ""}`}>
509 <div
510 role="button"
511 tabIndex={0}
512 className={`project-tree__group-main${canDrop ? " project-tree__group-main--drop-target" : ""}`}
513 style={{ paddingLeft: 14 + depth * 16 }}
514 title={group.title}
515 onClick={() => organization.toggleGroup(key, group.id)}
516 onKeyDown={(event) => {
517 if (editingGroup === group.id) return;
518 if (event.key === "Enter" || event.key === " ") organization.toggleGroup(key, group.id);
519 }}
520 onContextMenu={(event) => {
521 event.preventDefault();
522 setMenuGroup(group.id);
523 setMenuPoint(contextMenuPointFromEvent(event));
524 }}
525 onDragOver={(event) => {
526 if (!canDrop) return;
527 event.preventDefault();
528 event.dataTransfer.dropEffect = "move";
529 }}
530 onDrop={(event) => {
531 event.preventDefault();
532 if (canDrop) organization.dropTopicInto(key, group.id);
533 }}
534 >
535 <span className="project-tree__group-chevron" aria-hidden="true">{collapsed ? "▸" : "▾"}</span>
536 {editingGroup === group.id ? <input
537 autoFocus
538 className="project-tree__group-input"
539 value={groupDraft}
540 onChange={(event) => setGroupDraft(event.target.value)}
541 onFocus={(event) => event.target.select()}
542 onKeyDown={(event) => {
543 if (event.key === "Enter") commitRename(group.id);
544 if (event.key === "Escape") setEditingGroup(null);
545 }}
546 onBlur={() => commitRename(group.id)}
547 onClick={(event) => event.stopPropagation()}
548 /> : <span className="project-tree__group-title">{group.title}</span>}
549 </div>
550 {menuGroup === group.id && <ContextMenu
551 open
552 point={menuPoint}
553 items={[
554 { key: "rename", icon: <Pencil size={13} />, label: t("projectTree.renameGroup"), onSelect: () => { setEditingGroup(group.id); setGroupDraft(group.title); setMenuGroup(null); } },
555 { key: "delete", icon: <Archive size={13} />, label: t("projectTree.deleteGroup"), danger: true, onSelect: () => { onForgetList(group.id); organization.deleteGroup(key, group.id); setMenuGroup(null); } },
556 ]}
557 minWidth={178}
558 ariaLabel={t("projectTree.renameGroup")}
559 onClose={() => setMenuGroup(null)}
560 />}
561 {!collapsed && <div className="project-tree__group-children">
562 {groupProjection.rows.map((child) => renderNode(child, depth, section, visible))}
563 {renderWindowControls(group.id, group.title, groupRows.length, groupProjection.hasHiddenLoadedRows)}
564 </div>}
565 </div>;
566 })}
567 </>;
568 }
569
570 export function projectTreeFolderHasActiveRuntime(folder: ProjectNode): boolean {
571 return asArray(folder.children).some(projectTreeTopicArchiveBlocked);
572 }
573
574 const FolderActivity = lazy(() => import("./RuntimeActivityIndicator"));
575 export function ProjectTreeFolderActivity({ folder }: { folder: ProjectNode }) {
576 return <Suspense fallback={null}><FolderActivity target={{
577 scope: folder.kind === "global_folder" ? "global" : "project",
578 root: folder.root ?? "",
579 remote: folder.remote,
580 topics: asArray(folder.children),
581 fallbackActive: projectTreeFolderHasActiveRuntime(folder),
582 }} /></Suspense>;
583 }
584
584 lines Plain Text