| 1 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 2 | import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; |
| 3 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; |
| 4 | import * as Arr from "effect/Array"; |
| 5 | import * as Order from "effect/Order"; |
| 6 | |
| 7 | export { |
| 8 | getTerminalLabel, |
| 9 | nextTerminalId, |
| 10 | resolveTerminalSessionLabel, |
| 11 | } from "@t3tools/shared/terminalLabels"; |
| 12 | |
| 13 | export interface TerminalMenuSession { |
| 14 | readonly terminalId: string; |
| 15 | readonly cwd: string | null; |
| 16 | readonly status: "starting" | "running" | "exited" | "error" | "closed"; |
| 17 | readonly hasRunningSubprocess: boolean; |
| 18 | /** Server-authoritative title with the same fallback rules as web. */ |
| 19 | readonly displayLabel: string; |
| 20 | readonly updatedAt: string | null; |
| 21 | } |
| 22 | |
| 23 | const terminalMenuSessionOrder = Order.make<TerminalMenuSession>((left, right) => { |
| 24 | const comparison = left.terminalId.localeCompare(right.terminalId, undefined, { numeric: true }); |
| 25 | if (comparison === 0) { |
| 26 | return 0; |
| 27 | } |
| 28 | return comparison < 0 ? -1 : 1; |
| 29 | }); |
| 30 | |
| 31 | export function basename(input: string | null): string | null { |
| 32 | if (!input) { |
| 33 | return null; |
| 34 | } |
| 35 | |
| 36 | const normalized = input.replace(/\/+$/, ""); |
| 37 | if (normalized.length === 0) { |
| 38 | return "/"; |
| 39 | } |
| 40 | |
| 41 | const segments = normalized.split("/"); |
| 42 | return segments[segments.length - 1] ?? normalized; |
| 43 | } |
| 44 | |
| 45 | export function getTerminalStatusLabel(input: { |
| 46 | readonly status: TerminalMenuSession["status"]; |
| 47 | readonly hasRunningSubprocess?: boolean; |
| 48 | }): string { |
| 49 | if (input.status === "running") { |
| 50 | return input.hasRunningSubprocess ? "Task running" : "Ready"; |
| 51 | } |
| 52 | if (input.status === "starting") { |
| 53 | return "Starting"; |
| 54 | } |
| 55 | if (input.status === "exited") { |
| 56 | return "Exited"; |
| 57 | } |
| 58 | if (input.status === "error") { |
| 59 | return "Error"; |
| 60 | } |
| 61 | |
| 62 | return "Not started"; |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Picks an id for "open another shell". Counts the terminal screen already mounted |
| 67 | * (`activeRouteTerminalId`) as occupied so an empty session list on the primary route |
| 68 | * still advances to `term-2` instead of `replace`-navigating to the same `default` tab. |
| 69 | */ |
| 70 | export function nextOpenTerminalId(input: { |
| 71 | readonly listedTerminalIds: ReadonlyArray<string>; |
| 72 | readonly activeRouteTerminalId?: string | null; |
| 73 | readonly uniqueSuffix?: string; |
| 74 | }): string { |
| 75 | const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0); |
| 76 | const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null; |
| 77 | |
| 78 | if (!routeId || listed.includes(routeId)) { |
| 79 | return nextTerminalId(listed, input.uniqueSuffix); |
| 80 | } |
| 81 | |
| 82 | return nextTerminalId([...listed, routeId], input.uniqueSuffix); |
| 83 | } |
| 84 | |
| 85 | export function buildTerminalMenuSessions(input: { |
| 86 | readonly knownSessions: ReadonlyArray<KnownTerminalSession>; |
| 87 | readonly workspaceRoot: string | null; |
| 88 | readonly currentSession?: TerminalMenuSession | null; |
| 89 | }): ReadonlyArray<TerminalMenuSession> { |
| 90 | const sessionsById = new Map<string, TerminalMenuSession>(); |
| 91 | |
| 92 | for (const session of input.knownSessions) { |
| 93 | if ( |
| 94 | session.state.status !== "running" && |
| 95 | session.state.status !== "starting" && |
| 96 | session.target.terminalId !== input.currentSession?.terminalId |
| 97 | ) { |
| 98 | continue; |
| 99 | } |
| 100 | |
| 101 | sessionsById.set(session.target.terminalId, { |
| 102 | terminalId: session.target.terminalId, |
| 103 | cwd: session.state.summary?.cwd ?? input.workspaceRoot, |
| 104 | status: session.state.status, |
| 105 | hasRunningSubprocess: session.state.hasRunningSubprocess, |
| 106 | displayLabel: resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), |
| 107 | updatedAt: session.state.updatedAt, |
| 108 | }); |
| 109 | } |
| 110 | |
| 111 | if (input.currentSession && !sessionsById.has(input.currentSession.terminalId)) { |
| 112 | sessionsById.set(input.currentSession.terminalId, input.currentSession); |
| 113 | } |
| 114 | |
| 115 | return Arr.sort(sessionsById.values(), terminalMenuSessionOrder); |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Picks the session to show after a terminal exits: the nearest live session |
| 120 | * below the exited id (terminal n-1), falling back to the nearest one above. |
| 121 | * Returns null when no other live session remains and the terminal UI should |
| 122 | * be dismissed instead. |
| 123 | */ |
| 124 | export function previousLiveTerminalId(input: { |
| 125 | readonly sessions: ReadonlyArray<TerminalMenuSession>; |
| 126 | readonly exitedTerminalId: string; |
| 127 | }): string | null { |
| 128 | const live = Arr.sort( |
| 129 | input.sessions.filter( |
| 130 | (session) => |
| 131 | session.terminalId !== input.exitedTerminalId && |
| 132 | (session.status === "running" || session.status === "starting"), |
| 133 | ), |
| 134 | terminalMenuSessionOrder, |
| 135 | ); |
| 136 | if (live.length === 0) { |
| 137 | return null; |
| 138 | } |
| 139 | |
| 140 | const below = live.filter( |
| 141 | (session) => |
| 142 | session.terminalId.localeCompare(input.exitedTerminalId, undefined, { numeric: true }) < 0, |
| 143 | ); |
| 144 | return (below[below.length - 1] ?? live[0])?.terminalId ?? null; |
| 145 | } |
| 146 | |
| 147 | export function resolveProjectScriptTerminalId(input: { |
| 148 | readonly existingTerminalIds: ReadonlyArray<string>; |
| 149 | readonly hasRunningTerminal: boolean; |
| 150 | readonly uniqueSuffix?: string; |
| 151 | }): string { |
| 152 | if (!input.hasRunningTerminal && input.uniqueSuffix === undefined) { |
| 153 | return DEFAULT_TERMINAL_ID; |
| 154 | } |
| 155 | |
| 156 | return nextTerminalId(input.existingTerminalIds, input.uniqueSuffix); |
| 157 | } |
| 158 | |
| 159 | export function projectScriptMenuIcon(icon: ProjectScript["icon"]) { |
| 160 | if (icon === "test") return "flask"; |
| 161 | if (icon === "lint") return "checklist"; |
| 162 | if (icon === "configure") return "wrench.and.screwdriver"; |
| 163 | if (icon === "build") return "hammer"; |
| 164 | if (icon === "debug") return "ladybug"; |
| 165 | return "play"; |
| 166 | } |