返回 DeepSeek-Reasonix
ProjectTreeRemoteGroups.tsx
根目录 / desktop / frontend / src / components / ProjectTreeRemoteGroups.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type Dispatch, type SetStateAction } from "react";
2 import { Plus, Server, Square, XCircle } from "lucide-react";
3
4 import { app, onRemoteTabOpened, onRemoteTabUpdated } from "../lib/bridge";
5 import { runtimeStateStore, selectRuntime, type RuntimeProjection } from "../lib/runtimeStateStore";
6 import type { Translator } from "../lib/i18n";
7 import type { ProjectNode, RemoteServerView, RemoteSessionView, RemoteTabRefView } from "../lib/types";
8 import type { ToastContextValue } from "../lib/toast";
9 import { loadRemoteSessionCache, removeRemoteSessionCache, saveRemoteSessionCache } from "../lib/remoteSessionCache";
10 import { useRemoteNavigationCommand } from "../lib/remoteNavigationCommands";
11 import { publishNavigationIntent } from "../lib/useNavigationIntentFence";
12 import { useRemoteStore, waitForRemoteConnection } from "../store/remote";
13 import type { ContextMenuItem } from "./ContextMenu";
14
15 export function remoteProjectKey(ref: RemoteTabRefView): string {
16 return `${ref.hostId}\u0000${ref.workspace}`;
17 }
18
19 // Canonical remote sessions may not have a legacy transcript path. Use the
20 // immutable session ID first so React keys, topic IDs, and action lookups do
21 // not collapse a canonical row into a path-only or blank row.
22 export function remoteSessionIdentity(row: Pick<RemoteSessionView, "sessionId" | "path" | "name">): string {
23 return row.sessionId?.trim() || row.path?.trim() || row.name?.trim() || "new";
24 }
25
26 // Mutations still use the historical bridge argument named `name`, but that
27 // argument is an identity token, not the visible title. Canonical rows must
28 // use sessionId; legacy rows retain their stable basename.
29 export function remoteSessionActionIdentity(remote: Pick<RemoteSessionView, "sessionId" | "name" | "path">): string {
30 // Keep the legacy name fallback for old Serve rows, but never fall back to
31 // path: the synthetic current row may have a path while still being a
32 // blank, non-catalogued session.
33 const legacyIdentity = remote.name || remote.sessionId || "";
34 const explicit = remote.sessionId?.trim() || legacyIdentity.trim();
35 if (explicit) return explicit;
36 const route = remote.path?.trim() || "";
37 return route.startsWith("session-id:") ? route.slice("session-id:".length).trim() : "";
38 }
39
40 // A remote session cannot be trashed while it is the serve's current row, or
41 // before it has an identity the delete call can name.
42 export function remoteSessionArchiveBlocked(
43 remote: (Pick<RemoteSessionView, "sessionId" | "name" | "path"> & { current?: boolean }) | undefined,
44 ): boolean {
45 return Boolean(remote && (remote.current || !remoteSessionActionIdentity(remote)));
46 }
47
48 export function remoteSessionLabel(row: Pick<RemoteSessionView, "sessionId" | "name" | "title">, t: Translator): string {
49 const title = row.title?.trim();
50 if (title) return title;
51 // Canonical rows use `name` as an identity compatibility field, which is
52 // the opaque session ID. Never expose that ID as the visible topic label.
53 if (row.sessionId?.trim()) return t("projectTree.newTopic");
54 return row.name?.trim() || t("projectTree.newTopic");
55 }
56
57 export function useRemoteRuntimeTree(tree: ProjectNode[], sessions: Record<string, RemoteSessionView[]>, t: Translator) {
58 const runtime = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getSnapshot);
59 const failed = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getFailed);
60 return useMemo(() => mergeRemoteSessionsIntoTree(tree, sessions, t, runtime, failed), [tree, sessions, t, runtime, failed]);
61 }
62
63 export function activeRemoteProjectAncestorKeys(
64 nodes: ProjectNode[],
65 activeRemote: RemoteTabRefView,
66 nodeKey: (node: ProjectNode, index: number) => string,
67 ): string[] {
68 const activeKey = remoteProjectKey(activeRemote);
69 return nodes.flatMap((node, index) => node.remote && remoteProjectKey(node.remote) === activeKey ? [nodeKey(node, index)] : []);
70 }
71
72 export function remoteServeBadgeState(view?: RemoteServerView, busy = false): string {
73 if (busy) return "serve-busy";
74 if (view?.state === "ready") return "serve-ready";
75 if (view?.state === "error") return "serve-error";
76 if (!view || view.state === "stopped") return "serve-idle";
77 return "serve-busy";
78 }
79
80 export function mergeRemoteSessionsIntoTree(
81 tree: ProjectNode[],
82 sessions: Record<string, RemoteSessionView[]>,
83 t: Translator,
84 runtime?: RuntimeProjection,
85 failed = false,
86 ): ProjectNode[] {
87 return tree.map((node) => {
88 if (!node.remote) return node;
89 const rows = sessions[remoteProjectKey(node.remote)] ?? [];
90 const remoteChildren = rows.map((row): ProjectNode => {
91 const identity = remoteSessionIdentity(row);
92 const session = runtime?.sessions.find(session => session.hostId === node.remote!.hostId && session.workspaceRoot === node.remote!.workspace && (
93 row.sessionId ? session.sessionId === row.sessionId : session.sessionPath === row.path
94 ));
95 const state = selectRuntime(session, failed);
96 const status = state.unknown ? "unknown" : state.known && state.kind !== "idle" && state.kind !== "legacy" ? state.kind : undefined;
97 return ({
98 key: `remote-session-${node.remote!.hostId}-${node.remote!.workspace}-${identity}`,
99 kind: "topic",
100 label: remoteSessionLabel(row, t),
101 root: node.remote!.workspace,
102 topicId: `${node.remote!.hostId}\u0000${node.remote!.workspace}\u0000${identity}`,
103 sessionPath: row.path,
104 turns: row.turns,
105 running: state.known ? state.unknown ? false : Boolean(state.running || session!.state.pendingPrompt || session!.state.backgroundJobs) : row.running,
106 status: status as ProjectNode["status"],
107 lastActivityAt: row.lastActivityAt,
108 pinned: row.pinned,
109 remoteSession: { hostId: node.remote!.hostId, workspace: node.remote!.workspace, name: row.name, path: row.path, sessionId: row.sessionId, title: row.title, current: row.current },
110 children: [],
111 });
112 });
113 return { ...node, children: [...remoteChildren, ...(node.children ?? [])] };
114 });
115 }
116
117 export function useRemoteSessionActions(
118 sessions: Record<string, RemoteSessionView[]>,
119 refresh: () => void,
120 reportError: (error: unknown) => void,
121 ) {
122 const index = useMemo(() => {
123 const next = new Map<string, { hostId: string; workspace: string; name: string; path?: string; sessionId?: string; writable: boolean }>();
124 for (const [groupKey, rows] of Object.entries(sessions)) {
125 const [hostId, workspace] = groupKey.split("\u0000");
126 for (const row of rows) {
127 // A canonical row is identified exactly by its sessionId; only legacy
128 // rows fall back to name identity, where duplicate names stay
129 // ambiguous and mutations must refuse rather than hit the wrong row.
130 const writable = Boolean(row.sessionId?.trim()) || rows.filter((candidate) => candidate.name === row.name).length === 1;
131 next.set(`${hostId}\u0000${workspace}\u0000${remoteSessionIdentity(row)}`, {
132 hostId, workspace, name: row.name, path: row.path, sessionId: row.sessionId, writable,
133 });
134 }
135 }
136 return next;
137 }, [sessions]);
138 const resolve = useCallback((topicId: string) => index.get(topicId), [index]);
139 const mutate = useCallback(async (
140 topicId: string,
141 action: (remote: { hostId: string; workspace: string; name: string; path?: string; sessionId?: string }) => Promise<unknown>,
142 ) => {
143 const remote = index.get(topicId);
144 if (!remote) return false;
145 if (!remote.writable) throw new Error("This remote service cannot identify that session precisely. Upgrade the remote Reasonix service before changing it.");
146 // The synthesized current blank session intentionally has an empty name.
147 // Its rename/pin/delete bindings still own that row and must decide whether
148 // the requested mutation is supported; never report success without
149 // invoking the backend action.
150 await action(remote);
151 refresh();
152 return true;
153 }, [index, refresh]);
154 const remove = useCallback(async (topicId: string, local: () => Promise<unknown>) => {
155 try {
156 if (await mutate(topicId, (remote) => {
157 const identity = remoteSessionActionIdentity(remote);
158 // A blank, not-yet-catalogued session has no identity the Serve can
159 // delete; the deterministic "name required" rejection would only
160 // surface as a broken trash action.
161 if (!identity) return Promise.resolve();
162 return app.DeleteRemoteProjectSession(remote.hostId, remote.workspace, identity);
163 })) return;
164 await local();
165 } catch (error) {
166 reportError(error);
167 }
168 }, [mutate, reportError]);
169 return { resolve, mutate, remove };
170 }
171
172 export function openRemoteSessionNode(
173 remote: { hostId: string; workspace: string; name: string; path?: string; sessionId?: string; title?: string } | undefined,
174 open: (ref: RemoteTabRefView, opts?: { sessionName?: string; sessionPath?: string; sessionId?: string; sessionTitle?: string; focus?: boolean }) => Promise<void>,
175 ): boolean {
176 if (!remote) return false;
177 void open(remote, remote.name || remote.path || remote.sessionId
178 ? { sessionName: remote.name, sessionPath: remote.path, sessionId: remote.sessionId, sessionTitle: remote.title }
179 : { focus: true });
180 return true;
181 }
182
183 export async function renameRemoteProjectTitle(root: string, title: string): Promise<boolean> {
184 if (!root.startsWith("remote-project:")) return false;
185 const identity = root.slice("remote-project:".length);
186 const separator = identity.indexOf(":");
187 if (separator < 1) throw new Error("invalid remote project identity");
188 await app.SetRemoteProjectTitle(identity.slice(0, separator), identity.slice(separator + 1), title);
189 return true;
190 }
191
192 export function useRemoteProjectGroups(
193 projects: Array<{ key?: string; remote?: RemoteTabRefView }>,
194 showToast: ToastContextValue["showToast"],
195 expanded: Set<string>,
196 query: string,
197 ) {
198 const navigateRemote = useRemoteNavigationCommand();
199 const statuses = useRemoteStore((state) => state.statuses);
200 const servers = useRemoteStore((state) => state.servers);
201 const [sessions, setSessions] = useState<Record<string, RemoteSessionView[]>>({});
202 const [groupBusy, setGroupBusy] = useState<Record<string, boolean>>({});
203 const [groupError, setGroupError] = useState<Record<string, string>>({});
204 const sessionLoads = useRef(new Map<string, number>());
205 const sessionLoadGenerations = useRef(new Map<string, number>());
206 const eligibleSessionKeys = useRef(new Set<string>());
207 const groupBusyRef = useRef(new Set<string>());
208 const nextLoad = useRef(0);
209 const opening = useRef(new Set<string>());
210 const [revision, setRevision] = useState(0);
211 const groupKeys = useMemo(
212 () => projects.flatMap((project) => project.remote ? [remoteProjectKey(project.remote)] : []),
213 [projects],
214 );
215
216 const acceptRemoteSessionRows = useCallback((key: string, rows: RemoteSessionView[]) => {
217 saveRemoteSessionCache(key, rows);
218 setSessions((current) => ({ ...current, [key]: rows }));
219 // A passive refresh can recover after an explicit ensure failed. Once an
220 // authoritative listing succeeds, the old connection error no longer
221 // describes this group (including when the successful result is empty).
222 setGroupError((current) => current[key] ? { ...current, [key]: "" } : current);
223 }, []);
224
225 const recordRemoteSessionLoadError = useCallback((key: string, error: unknown) => {
226 // Passive refreshes must not turn a transient Serve failure into an
227 // authoritative empty listing. Keep the last successful rows/cache while
228 // surfacing a retry when the group has no rows to render.
229 setGroupError((current) => ({
230 ...current,
231 [key]: error instanceof Error ? error.message : String(error),
232 }));
233 }, []);
234
235 const openRemoteProject = useCallback(async (
236 ref: RemoteTabRefView,
237 opts?: { newSession?: boolean; sessionName?: string; sessionPath?: string; sessionId?: string; sessionTitle?: string; focus?: boolean },
238 ) => {
239 const key = remoteProjectKey(ref);
240 if (opening.current.has(key)) return;
241 opening.current.add(key);
242 try {
243 const outcome = await navigateRemote(ref,
244 opts?.focus ? {} : opts?.sessionName || opts?.sessionPath || opts?.sessionId
245 ? { sessionName: opts.sessionName, sessionPath: opts.sessionPath, sessionId: opts.sessionId, sessionTitle: opts.sessionTitle }
246 : { newSession: true });
247 if (outcome.status === "cancelled") return;
248 if (outcome.status === "failed") throw outcome.error;
249 if (!opts?.focus) setRevision((current) => current + 1);
250 } catch (error) {
251 showToast(error instanceof Error ? error.message : String(error), "error");
252 } finally {
253 opening.current.delete(key);
254 }
255 }, [navigateRemote, showToast]);
256
257 const ensureRemoteGroupSessions = useCallback(async (hostId: string, workspace: string) => {
258 const key = `${hostId}\u0000${workspace}`;
259 if (groupBusyRef.current.has(key)) return;
260 groupBusyRef.current.add(key);
261 setGroupBusy((current) => ({ ...current, [key]: true }));
262 setGroupError((current) => ({ ...current, [key]: "" }));
263 // Explicit ensures and passive listings share one last-start-wins order.
264 // In particular, a passive request that began before this cold start must
265 // not be allowed to overwrite the authoritative rows returned here.
266 const load = ++nextLoad.current;
267 sessionLoads.current.set(key, load);
268 sessionLoadGenerations.current.set(key, load);
269 try {
270 const rows = await app.EnsureRemoteProjectSessions(hostId, workspace);
271 if (sessionLoadGenerations.current.get(key) !== load) return;
272 acceptRemoteSessionRows(key, rows);
273 void app.RemoteServerStatus(hostId, workspace).then((view) => useRemoteStore.getState().setServer(view)).catch(() => {});
274 } catch (error) {
275 if (sessionLoadGenerations.current.get(key) !== load) return;
276 recordRemoteSessionLoadError(key, error);
277 } finally {
278 if (sessionLoads.current.get(key) === load) sessionLoads.current.delete(key);
279 groupBusyRef.current.delete(key);
280 setGroupBusy((current) => ({ ...current, [key]: false }));
281 }
282 }, [acceptRemoteSessionRows, recordRemoteSessionLoadError]);
283
284 const openRemoteWindow = useCallback(async (ref: RemoteTabRefView) => {
285 try {
286 const state = statuses[ref.hostId]?.state;
287 if (state !== "connected" && state !== "degraded") {
288 await app.ConnectRemoteHost(ref.hostId);
289 await waitForRemoteConnection(ref.hostId);
290 }
291 await publishNavigationIntent("remote-workspace");
292 await app.OpenRemoteWorkspace(ref.hostId, ref.workspace);
293 } catch (error) {
294 showToast(error instanceof Error ? error.message : String(error), "error");
295 }
296 }, [showToast, statuses]);
297
298 useEffect(() => onRemoteTabOpened(() => setRevision((current) => current + 1)), []);
299
300 useEffect(() => onRemoteTabUpdated((meta) => {
301 if (!meta.remote) return;
302 if (runtimeStateStore.getSnapshot()?.sessions.some(session => session.tabId === meta.id && session.state.schemaVersion === 1)) return;
303 const key = remoteProjectKey(meta.remote);
304 if (!groupKeys.includes(key) || !eligibleSessionKeys.current.has(key)) return;
305 const load = ++nextLoad.current;
306 sessionLoads.current.set(key, load);
307 sessionLoadGenerations.current.set(key, load);
308 void app.RemoteProjectSessions(meta.remote.hostId, meta.remote.workspace)
309 .then((rows) => {
310 if (sessionLoadGenerations.current.get(key) === load && eligibleSessionKeys.current.has(key)) {
311 acceptRemoteSessionRows(key, rows);
312 }
313 })
314 .catch((error) => {
315 if (sessionLoadGenerations.current.get(key) === load && eligibleSessionKeys.current.has(key)) {
316 recordRemoteSessionLoadError(key, error);
317 }
318 })
319 .finally(() => {
320 if (sessionLoads.current.get(key) === load) sessionLoads.current.delete(key);
321 });
322 }), [acceptRemoteSessionRows, groupKeys, recordRemoteSessionLoadError]);
323
324 useEffect(() => {
325 const seeded: Record<string, RemoteSessionView[]> = {};
326 for (const key of groupKeys) {
327 const rows = loadRemoteSessionCache(key);
328 if (rows.length > 0) seeded[key] = rows;
329 }
330 if (Object.keys(seeded).length > 0) {
331 setSessions((current) => ({ ...seeded, ...current }));
332 }
333 }, [groupKeys]);
334
335 useEffect(() => {
336 void app.RemoteConnectionStatuses()
337 .then((rows) => useRemoteStore.getState().hydrateStatuses(rows ?? []))
338 .catch(() => {});
339 }, []);
340
341 useEffect(() => {
342 for (const key of groupKeys) {
343 const [hostId, workspace] = key.split("\u0000");
344 void app.RemoteServerStatus(hostId, workspace)
345 .then((view) => useRemoteStore.getState().setServer(view))
346 .catch(() => {});
347 }
348 }, [groupKeys]);
349
350 useEffect(() => {
351 const searchable = query.trim() !== "";
352 const eligible = new Set(groupKeys.filter((key) => {
353 const state = statuses[key.split("\u0000")[0]]?.state;
354 if (state !== "connected" && state !== "degraded") return false;
355 const project = projects.find((item) => item.remote && remoteProjectKey(item.remote) === key);
356 return searchable || Boolean(project?.key && expanded.has(project.key));
357 }));
358 eligibleSessionKeys.current = eligible;
359 for (const key of sessionLoads.current.keys()) {
360 if (!eligible.has(key)) sessionLoads.current.delete(key);
361 }
362 const retained = new Set(groupKeys);
363 for (const key of sessionLoadGenerations.current.keys()) {
364 if (!retained.has(key)) sessionLoadGenerations.current.delete(key);
365 }
366 setSessions((current) => {
367 if (Object.keys(current).every((key) => retained.has(key))) return current;
368 const next = Object.fromEntries(Object.entries(current).filter(([key]) => retained.has(key)));
369 return next;
370 });
371 for (const key of eligible) {
372 if (sessionLoads.current.has(key)) continue;
373 const [hostId, workspace] = key.split("\u0000");
374 const load = ++nextLoad.current;
375 sessionLoads.current.set(key, load);
376 sessionLoadGenerations.current.set(key, load);
377 void app.RemoteProjectSessions(hostId, workspace)
378 .then((rows) => {
379 if (sessionLoadGenerations.current.get(key) === load && eligibleSessionKeys.current.has(key)) {
380 acceptRemoteSessionRows(key, rows);
381 }
382 })
383 .catch((error) => {
384 if (sessionLoadGenerations.current.get(key) === load && eligibleSessionKeys.current.has(key)) {
385 recordRemoteSessionLoadError(key, error);
386 }
387 })
388 .finally(() => {
389 if (sessionLoads.current.get(key) === load) sessionLoads.current.delete(key);
390 });
391 }
392 }, [acceptRemoteSessionRows, expanded, groupKeys, projects, query, recordRemoteSessionLoadError, revision, statuses]);
393
394 return {
395 openRemoteProject,
396 openRemoteWindow,
397 remoteSessions: sessions,
398 remoteGroupBusy: groupBusy,
399 remoteGroupError: groupError,
400 ensureRemoteGroupSessions,
401 setRemoteSessions: setSessions,
402 remoteServers: servers,
403 refreshRemoteSessions: () => setRevision((current) => current + 1),
404 };
405 }
406
407 export function RemoteProjectEmptyState({
408 busy, error, ready, isExpanded, depth, t, onEnsure,
409 }: {
410 busy: boolean;
411 error: string;
412 ready: boolean;
413 isExpanded: boolean;
414 depth: number;
415 t: Translator;
416 onEnsure: () => void;
417 }) {
418 const inner = busy ? (
419 <div className="project-tree__skeleton" style={{ paddingLeft: 14 + (depth + 1) * 16 }} aria-hidden="true">
420 <span className="project-tree__skeleton-bar" />
421 <span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
422 <span className="project-tree__skeleton-bar" />
423 <span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
424 </div>
425 ) : error || !ready ? (
426 <button
427 type="button"
428 className={`project-tree__remote-status${error ? " project-tree__remote-status--error" : ""}`}
429 style={{ paddingLeft: 14 + (depth + 1) * 16 }}
430 onClick={onEnsure}
431 >
432 {error ? t("projectTree.remoteConnectFailed") : t("projectTree.remoteConnect")}
433 </button>
434 ) : null;
435 if (!inner) return null;
436 return (
437 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
438 <div className="project-tree__children-inner">{inner}</div>
439 </div>
440 );
441 }
442
443 interface RemoteMenuOptions {
444 ref: RemoteTabRefView;
445 t: Translator;
446 closeMenu: () => void;
447 openRemoteProject: (ref: RemoteTabRefView, opts?: { newSession?: boolean; sessionName?: string; sessionPath?: string; sessionId?: string; sessionTitle?: string; focus?: boolean }) => Promise<void>;
448 openRemoteWindow: (ref: RemoteTabRefView) => Promise<void>;
449 setRemoteSessions: Dispatch<SetStateAction<Record<string, RemoteSessionView[]>>>;
450 refresh: () => Promise<void>;
451 showToast: ToastContextValue["showToast"];
452 }
453
454 export function buildRemoteProjectMenuItems(options: RemoteMenuOptions): ContextMenuItem[] {
455 const { ref, t, closeMenu, openRemoteProject, openRemoteWindow, setRemoteSessions, refresh, showToast } = options;
456 const report = (error: unknown) => showToast(error instanceof Error ? error.message : String(error), "error");
457 return [
458 {
459 key: "remote-new-session", icon: <Plus size={13} />, label: t("projectTree.newTopic"),
460 onSelect: () => { closeMenu(); void openRemoteProject(ref, { newSession: true }); },
461 },
462 {
463 key: "remote-open-window", icon: <Server size={13} />, label: t("projectTree.remoteOpenWindow"),
464 onSelect: () => { closeMenu(); void openRemoteWindow(ref); },
465 },
466 {
467 key: "remote-stop-server", icon: <Square size={13} />, label: t("projectTree.remoteStopServer"),
468 onSelect: () => { closeMenu(); void app.StopRemoteServer(ref.hostId, ref.workspace).catch(report); },
469 },
470 {
471 key: "remote-unpin", icon: <XCircle size={13} />, label: t("projectTree.remoteUnpin"),
472 onSelect: () => {
473 closeMenu();
474 void app.RemoveRemoteProject(ref.hostId, ref.workspace).then(() => {
475 removeRemoteSessionCache(remoteProjectKey(ref));
476 setRemoteSessions((current) => {
477 const next = { ...current };
478 delete next[remoteProjectKey(ref)];
479 return next;
480 });
481 void refresh();
482 }).catch(report);
483 },
484 },
485 ];
486 }
487
487 lines Plain Text