| 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 |