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