portless-studio 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli/mcp/studio-tools.js +36 -2
- package/dist/cli/studio-items.js +2 -0
- package/dist/core/api/contract.js +6 -1
- package/dist/core/config/studio-config.js +83 -0
- package/dist/core/terminals/terminal-history.js +53 -0
- package/dist/core/terminals/terminal.js +0 -4
- package/dist/server/application/port-discovery.js +1 -0
- package/dist/server/application/studio-runtime.js +1 -0
- package/dist/server/application/terminals.js +221 -49
- package/dist/server/infrastructure/config/json-terminal-history-store.js +30 -0
- package/dist/server/infrastructure/system/linux-ports.js +31 -1
- package/dist/server/infrastructure/system/macos-ports.js +31 -2
- package/dist/server/infrastructure/system/system-port-scanner.js +7 -4
- package/dist/server/infrastructure/system/windows-ports.js +9 -1
- package/dist/server/interface/http/http-server.js +4 -0
- package/dist/server/interface/http/static-files.js +6 -2
- package/dist/server/main.js +8 -0
- package/dist/web/assets/index-CZVCj1cX.css +1 -0
- package/dist/web/assets/index-DODZ4DTe.js +23 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-9WPxv6pu.js +0 -17
- package/dist/web/assets/index-Bckc4cIr.css +0 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ portless replaces port numbers with names: `portless api pnpm dev` serves your a
|
|
|
16
16
|
with ten dev servers across several projects you have to remember which names exist and which
|
|
17
17
|
ports have none. Studio shows both, live, and names a port in one click.
|
|
18
18
|
|
|
19
|
-

|
|
20
20
|
|
|
21
21
|
The screenshots use the built-in sample data (`?demo`), not a real machine.
|
|
22
22
|
|
|
@@ -423,7 +423,9 @@ function terminalTools(client) {
|
|
|
423
423
|
name: "list_terminals",
|
|
424
424
|
title: "List Studio terminals",
|
|
425
425
|
description: "Lists the terminals Studio runs: id, title, folder, command, project, whether it still " +
|
|
426
|
-
"runs, exit code
|
|
426
|
+
"runs, exit code, the port it named and who started it. Terminals that were running when " +
|
|
427
|
+
"Studio last stopped are listed with stopped_by_restart: true; restart_terminal runs them " +
|
|
428
|
+
"again.",
|
|
427
429
|
inputSchema: NO_INPUT,
|
|
428
430
|
call: async () => {
|
|
429
431
|
const result = await client.readPrivate(API_PATHS.terminals);
|
|
@@ -431,8 +433,12 @@ function terminalTools(client) {
|
|
|
431
433
|
return failure(result.error);
|
|
432
434
|
const { terminals } = result.value;
|
|
433
435
|
const running = terminals.filter((terminal) => terminal.status === "running").length;
|
|
436
|
+
const stopped = terminals.filter((terminal) => terminal.stoppedByRestart === true).length;
|
|
437
|
+
const restorable = stopped > 0
|
|
438
|
+
? `, ${stopped} stopped by a Studio restart: run ${stopped === 1 ? "it" : "them"} again with restart_terminal`
|
|
439
|
+
: "";
|
|
434
440
|
return {
|
|
435
|
-
summary: `${plural(terminals.length, "terminal")} (${running} running).`,
|
|
441
|
+
summary: `${plural(terminals.length, "terminal")} (${running} running${restorable}).`,
|
|
436
442
|
data: terminals.map(terminalItem),
|
|
437
443
|
};
|
|
438
444
|
},
|
|
@@ -556,6 +562,32 @@ function terminalTools(client) {
|
|
|
556
562
|
return { summary: `Stopping ${id.value}. list_terminals shows when it has exited.` };
|
|
557
563
|
},
|
|
558
564
|
},
|
|
565
|
+
{
|
|
566
|
+
name: "restart_terminal",
|
|
567
|
+
title: "Run a terminal's command again",
|
|
568
|
+
description: "Runs a terminal's command again in the same terminal: stops it first when it still runs. " +
|
|
569
|
+
"Use it for a terminal stopped by a Studio restart (stopped_by_restart in list_terminals) " +
|
|
570
|
+
"or one whose command ended.",
|
|
571
|
+
inputSchema: {
|
|
572
|
+
type: "object",
|
|
573
|
+
properties: { id: TERMINAL_ID_SCHEMA },
|
|
574
|
+
required: ["id"],
|
|
575
|
+
additionalProperties: false,
|
|
576
|
+
},
|
|
577
|
+
call: async (args) => {
|
|
578
|
+
const id = readString(args, "id");
|
|
579
|
+
if (!id.ok)
|
|
580
|
+
return invalid(id.error);
|
|
581
|
+
const result = await client.write("POST", API_PATHS.terminalRestart(id.value));
|
|
582
|
+
if (!result.ok)
|
|
583
|
+
return failure(result.error);
|
|
584
|
+
const terminal = result.value;
|
|
585
|
+
return {
|
|
586
|
+
summary: `Running ${terminal.id} again in ${terminal.cwd}: ${terminal.command || "shell"}. Read its output with read_terminal {"id": "${terminal.id}"}.`,
|
|
587
|
+
data: terminalItem(terminal),
|
|
588
|
+
};
|
|
589
|
+
},
|
|
590
|
+
},
|
|
559
591
|
{
|
|
560
592
|
name: "focus_studio",
|
|
561
593
|
title: "Show something in Studio",
|
|
@@ -611,6 +643,8 @@ function terminalItem(terminal) {
|
|
|
611
643
|
pid: terminal.pid,
|
|
612
644
|
named: terminal.naming?.name ?? null,
|
|
613
645
|
port: terminal.naming?.port ?? null,
|
|
646
|
+
started_by: terminal.startedBy ?? null,
|
|
647
|
+
stopped_by_restart: terminal.stoppedByRestart === true,
|
|
614
648
|
};
|
|
615
649
|
}
|
|
616
650
|
async function readSnapshot(client, includeStudio = false) {
|
package/dist/cli/studio-items.js
CHANGED
|
@@ -8,6 +8,7 @@ export function routeItem(route) {
|
|
|
8
8
|
project: route.project?.name ?? null,
|
|
9
9
|
worktree: route.project?.worktree ?? null,
|
|
10
10
|
pid: route.listenerPid ?? route.pid,
|
|
11
|
+
startedAt: route.startedAt ?? null,
|
|
11
12
|
};
|
|
12
13
|
}
|
|
13
14
|
export function detectedItem(port) {
|
|
@@ -19,6 +20,7 @@ export function detectedItem(port) {
|
|
|
19
20
|
suggestedName: port.suggestedName,
|
|
20
21
|
project: port.project?.name ?? null,
|
|
21
22
|
worktree: port.project?.worktree ?? null,
|
|
23
|
+
startedAt: port.startedAt ?? null,
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
export function proxyItem(proxy) {
|
|
@@ -51,6 +51,8 @@ export const API_PATHS = {
|
|
|
51
51
|
* Studio shows.
|
|
52
52
|
*/
|
|
53
53
|
terminalsInFolder: "/api/terminals/in-folder",
|
|
54
|
+
/** GET returns `RecentCommandsResponse`: the last commands run in each folder. */
|
|
55
|
+
terminalsRecentCommands: "/api/terminals/recent-commands",
|
|
54
56
|
/**
|
|
55
57
|
* GET returns `TerminalInfo`; PATCH `RenameTerminalRequest` renames it; DELETE stops the command
|
|
56
58
|
* if it runs and closes the terminal.
|
|
@@ -67,7 +69,10 @@ export const API_PATHS = {
|
|
|
67
69
|
terminalInput: (id) => `/api/terminals/${encodeURIComponent(id)}/input`,
|
|
68
70
|
/** POST: Ctrl+C, then SIGTERM, then SIGKILL until the command ends. */
|
|
69
71
|
terminalStop: (id) => `/api/terminals/${encodeURIComponent(id)}/stop`,
|
|
70
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* POST: runs the same command again in the same terminal; also starts a terminal that was
|
|
74
|
+
* stopped by a Studio restart.
|
|
75
|
+
*/
|
|
71
76
|
terminalRestart: (id) => `/api/terminals/${encodeURIComponent(id)}/restart`,
|
|
72
77
|
/** POST `FocusRequest`: brings a Studio page to the front showing a route, port or terminal. */
|
|
73
78
|
focus: "/api/focus",
|
|
@@ -2,6 +2,14 @@ import { DEFAULT_NAME_TEMPLATE, parseNameTemplate } from "../ports/name-template
|
|
|
2
2
|
import { projectIconKind } from "../projects/project-icon.js";
|
|
3
3
|
import { err, ok } from "../result.js";
|
|
4
4
|
import { DEFAULT_VIEW, isItemKey, parseViews } from "../views/view.js";
|
|
5
|
+
export const DEFAULT_TERMINAL_APPEARANCE = {
|
|
6
|
+
fontSize: 12,
|
|
7
|
+
lineHeight: 1.2,
|
|
8
|
+
cursorStyle: "block",
|
|
9
|
+
cursorBlink: true,
|
|
10
|
+
scrollback: 10_000,
|
|
11
|
+
theme: "studio",
|
|
12
|
+
};
|
|
5
13
|
export const DEFAULT_CONFIG = {
|
|
6
14
|
port: 4949,
|
|
7
15
|
autoName: false,
|
|
@@ -39,6 +47,9 @@ export const DEFAULT_CONFIG = {
|
|
|
39
47
|
shortUrls: false,
|
|
40
48
|
showStudioRoute: false,
|
|
41
49
|
showEphemeralPorts: false,
|
|
50
|
+
terminal: DEFAULT_TERMINAL_APPEARANCE,
|
|
51
|
+
restoreTerminals: "ask",
|
|
52
|
+
defaultEditor: null,
|
|
42
53
|
};
|
|
43
54
|
const MIN_SCAN_INTERVAL_MS = 1000;
|
|
44
55
|
/**
|
|
@@ -147,8 +158,80 @@ export function parseConfig(input) {
|
|
|
147
158
|
return err(`"nameTemplate" ${parsed.error}`);
|
|
148
159
|
config.nameTemplate = template;
|
|
149
160
|
}
|
|
161
|
+
if (raw.terminal !== undefined) {
|
|
162
|
+
const terminal = parseTerminalAppearance(raw.terminal);
|
|
163
|
+
if (!terminal.ok)
|
|
164
|
+
return terminal;
|
|
165
|
+
config.terminal = terminal.value;
|
|
166
|
+
}
|
|
167
|
+
if (raw.restoreTerminals !== undefined) {
|
|
168
|
+
const value = raw.restoreTerminals;
|
|
169
|
+
if (value !== "ask" && value !== "always" && value !== "never") {
|
|
170
|
+
return err(`"restoreTerminals" must be "ask", "always" or "never".`);
|
|
171
|
+
}
|
|
172
|
+
config.restoreTerminals = value;
|
|
173
|
+
}
|
|
174
|
+
if (raw.defaultEditor !== undefined) {
|
|
175
|
+
const value = raw.defaultEditor;
|
|
176
|
+
if (value !== null && (typeof value !== "string" || !APP_ID.test(value))) {
|
|
177
|
+
return err(`"defaultEditor" must be the id of an app in Open with, like "vscode", or null.`);
|
|
178
|
+
}
|
|
179
|
+
config.defaultEditor = value;
|
|
180
|
+
}
|
|
150
181
|
return ok(config);
|
|
151
182
|
}
|
|
183
|
+
/** The shape of open-with app ids: `vscode`, `vscode-insiders`, `custom-1`. */
|
|
184
|
+
const APP_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
185
|
+
function parseTerminalAppearance(input) {
|
|
186
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
187
|
+
return err(`"terminal" must be an object.`);
|
|
188
|
+
}
|
|
189
|
+
const raw = input;
|
|
190
|
+
const terminal = { ...DEFAULT_TERMINAL_APPEARANCE };
|
|
191
|
+
if (raw.fontSize !== undefined) {
|
|
192
|
+
if (!isWholeBetween(raw.fontSize, 10, 24)) {
|
|
193
|
+
return err(`"terminal.fontSize" must be a whole number from 10 to 24.`);
|
|
194
|
+
}
|
|
195
|
+
terminal.fontSize = raw.fontSize;
|
|
196
|
+
}
|
|
197
|
+
if (raw.lineHeight !== undefined) {
|
|
198
|
+
const value = raw.lineHeight;
|
|
199
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value > 2) {
|
|
200
|
+
return err(`"terminal.lineHeight" must be a number from 1 to 2.`);
|
|
201
|
+
}
|
|
202
|
+
terminal.lineHeight = value;
|
|
203
|
+
}
|
|
204
|
+
if (raw.cursorStyle !== undefined) {
|
|
205
|
+
const value = raw.cursorStyle;
|
|
206
|
+
if (value !== "block" && value !== "bar" && value !== "underline") {
|
|
207
|
+
return err(`"terminal.cursorStyle" must be "block", "bar" or "underline".`);
|
|
208
|
+
}
|
|
209
|
+
terminal.cursorStyle = value;
|
|
210
|
+
}
|
|
211
|
+
if (raw.cursorBlink !== undefined) {
|
|
212
|
+
if (typeof raw.cursorBlink !== "boolean") {
|
|
213
|
+
return err(`"terminal.cursorBlink" must be true or false.`);
|
|
214
|
+
}
|
|
215
|
+
terminal.cursorBlink = raw.cursorBlink;
|
|
216
|
+
}
|
|
217
|
+
if (raw.scrollback !== undefined) {
|
|
218
|
+
if (!isWholeBetween(raw.scrollback, 1000, 100_000)) {
|
|
219
|
+
return err(`"terminal.scrollback" must be a whole number from 1000 to 100000.`);
|
|
220
|
+
}
|
|
221
|
+
terminal.scrollback = raw.scrollback;
|
|
222
|
+
}
|
|
223
|
+
if (raw.theme !== undefined) {
|
|
224
|
+
const value = raw.theme;
|
|
225
|
+
if (value !== "studio" && value !== "dark" && value !== "light") {
|
|
226
|
+
return err(`"terminal.theme" must be "studio", "dark" or "light".`);
|
|
227
|
+
}
|
|
228
|
+
terminal.theme = value;
|
|
229
|
+
}
|
|
230
|
+
return ok(terminal);
|
|
231
|
+
}
|
|
232
|
+
function isWholeBetween(value, min, max) {
|
|
233
|
+
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
|
234
|
+
}
|
|
152
235
|
/** Empty names and values are dropped, so clearing a field in the panel removes its icon. */
|
|
153
236
|
function parseProjectIcons(value) {
|
|
154
237
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Studio keeps about terminals between runs, in `terminals.json` next to the config: the
|
|
3
|
+
* terminals that were running when it stopped, so they can run again, and the last commands run
|
|
4
|
+
* in each folder, offered when a new terminal opens there. See docs/terminals.md.
|
|
5
|
+
*/
|
|
6
|
+
export const EMPTY_TERMINAL_HISTORY = { running: [], recentCommands: {} };
|
|
7
|
+
/** Commands remembered per folder. */
|
|
8
|
+
export const MAX_RECENT_COMMANDS = 20;
|
|
9
|
+
/** Reads `terminals.json`; anything it cannot read is left out rather than failing. */
|
|
10
|
+
export function parseTerminalHistory(input) {
|
|
11
|
+
if (typeof input !== "object" || input === null)
|
|
12
|
+
return { running: [], recentCommands: {} };
|
|
13
|
+
const raw = input;
|
|
14
|
+
const running = Array.isArray(raw.running) ? raw.running.filter(isSavedTerminal) : [];
|
|
15
|
+
const recentCommands = {};
|
|
16
|
+
if (typeof raw.recentCommands === "object" && raw.recentCommands !== null) {
|
|
17
|
+
for (const [folder, list] of Object.entries(raw.recentCommands)) {
|
|
18
|
+
if (folder === "__proto__" || !Array.isArray(list))
|
|
19
|
+
continue;
|
|
20
|
+
const commands = list.filter((item) => typeof item === "string" && item.trim() !== "");
|
|
21
|
+
if (commands.length > 0)
|
|
22
|
+
recentCommands[folder] = commands.slice(0, MAX_RECENT_COMMANDS);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { running, recentCommands };
|
|
26
|
+
}
|
|
27
|
+
/** Puts `command` first in the folder's list, once, and keeps the newest `MAX_RECENT_COMMANDS`. */
|
|
28
|
+
export function rememberCommand(recent, cwd, command) {
|
|
29
|
+
const wanted = command.trim();
|
|
30
|
+
const copy = {};
|
|
31
|
+
for (const [folder, list] of Object.entries(recent))
|
|
32
|
+
copy[folder] = [...list];
|
|
33
|
+
if (wanted === "")
|
|
34
|
+
return copy;
|
|
35
|
+
const list = (copy[cwd] ?? []).filter((item) => item !== wanted);
|
|
36
|
+
copy[cwd] = [wanted, ...list].slice(0, MAX_RECENT_COMMANDS);
|
|
37
|
+
return copy;
|
|
38
|
+
}
|
|
39
|
+
function isSavedTerminal(value) {
|
|
40
|
+
if (typeof value !== "object" || value === null)
|
|
41
|
+
return false;
|
|
42
|
+
const item = value;
|
|
43
|
+
const optionalText = (field) => field === null || typeof field === "string";
|
|
44
|
+
return (typeof item.command === "string" &&
|
|
45
|
+
typeof item.cwd === "string" &&
|
|
46
|
+
item.cwd !== "" &&
|
|
47
|
+
typeof item.title === "string" &&
|
|
48
|
+
typeof item.startedAt === "number" &&
|
|
49
|
+
(item.startedBy === "program" || item.startedBy === "person") &&
|
|
50
|
+
optionalText(item.project) &&
|
|
51
|
+
optionalText(item.worktree) &&
|
|
52
|
+
optionalText(item.branch));
|
|
53
|
+
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A terminal Studio runs: one command in one project folder, with its output kept so the page,
|
|
3
|
-
* the CLI and agents can read it. See docs/terminals.md.
|
|
4
|
-
*/
|
|
5
1
|
// Control sequences: CSI (colors, cursor), OSC (window title) and two-character escapes.
|
|
6
2
|
const ESCAPES = new RegExp([
|
|
7
3
|
String.raw `\u001b\[[0-?]*[ -/]*[@-~]`,
|
|
@@ -143,5 +143,6 @@ function toDetected(port, projects, nameTemplate) {
|
|
|
143
143
|
cwd: port.cwd,
|
|
144
144
|
suggestedName: info === null ? null : suggestName(info, nameTemplate, port.port),
|
|
145
145
|
...(project === null ? {} : { project }),
|
|
146
|
+
...(port.startedAt === undefined ? {} : { startedAt: port.startedAt }),
|
|
146
147
|
};
|
|
147
148
|
}
|
|
@@ -403,6 +403,7 @@ export class StudioRuntime {
|
|
|
403
403
|
return {
|
|
404
404
|
...route,
|
|
405
405
|
...(listener?.pid == null ? {} : { listenerPid: listener.pid }),
|
|
406
|
+
...(listener?.startedAt === undefined ? {} : { startedAt: listener.startedAt }),
|
|
406
407
|
...(project === null ? {} : { project }),
|
|
407
408
|
...(health === undefined ? {} : { health }),
|
|
408
409
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { err, ok } from "../../core/result.js";
|
|
2
2
|
import { OutputBuffer } from "../../core/terminals/output-buffer.js";
|
|
3
|
+
import { rememberCommand, } from "../../core/terminals/terminal-history.js";
|
|
3
4
|
import { appError } from "./app-error.js";
|
|
4
5
|
const DEFAULT_MAX_OUTPUT = 2 * 1024 * 1024;
|
|
5
6
|
const DEFAULT_GRACE_MS = 3_000;
|
|
@@ -9,6 +10,13 @@ const DEFAULT_COLS = 120;
|
|
|
9
10
|
const DEFAULT_ROWS = 30;
|
|
10
11
|
const MAX_COMMAND_CHARS = 8 * 1024;
|
|
11
12
|
const MAX_TITLE_CHARS = 60;
|
|
13
|
+
const DEFAULT_PERSIST_DELAY_MS = 300;
|
|
14
|
+
/**
|
|
15
|
+
* A command that ended this shortly before Studio stopped counts as running then: a service
|
|
16
|
+
* manager stops Studio's commands together with Studio, and their exits can arrive first.
|
|
17
|
+
*/
|
|
18
|
+
const RECENT_EXIT_MS = 2_000;
|
|
19
|
+
const RESTART_NOTE = "\r\n[Studio] Stopped by a Studio restart. Run it again to start it.\r\n";
|
|
12
20
|
/**
|
|
13
21
|
* The terminals Studio runs. Each keeps its last `maxOutputChars` of output, numbered so a client
|
|
14
22
|
* can resume where it stopped. From a page, commands run only in folders Studio shows; a local
|
|
@@ -25,6 +33,11 @@ export class TerminalManager {
|
|
|
25
33
|
#namingTimeoutMs;
|
|
26
34
|
#processTree;
|
|
27
35
|
#homeDir;
|
|
36
|
+
#history;
|
|
37
|
+
#persistDelayMs;
|
|
38
|
+
#recent = null;
|
|
39
|
+
#persistTimer;
|
|
40
|
+
#closing = false;
|
|
28
41
|
#entries = new Map();
|
|
29
42
|
#watchers = new Set();
|
|
30
43
|
/** Folders a local program ran a terminal in. */
|
|
@@ -45,6 +58,8 @@ export class TerminalManager {
|
|
|
45
58
|
this.#namingTimeoutMs = options.namingTimeoutMs ?? DEFAULT_NAMING_TIMEOUT_MS;
|
|
46
59
|
this.#processTree = options.processTree;
|
|
47
60
|
this.#homeDir = options.homeDir;
|
|
61
|
+
this.#history = options.history;
|
|
62
|
+
this.#persistDelayMs = options.persistDelayMs ?? DEFAULT_PERSIST_DELAY_MS;
|
|
48
63
|
}
|
|
49
64
|
get mode() {
|
|
50
65
|
return this.#spawner.mode;
|
|
@@ -73,6 +88,7 @@ export class TerminalManager {
|
|
|
73
88
|
const wanted = title.trim().slice(0, MAX_TITLE_CHARS);
|
|
74
89
|
entry.info = { ...entry.info, title: wanted || entry.defaultTitle };
|
|
75
90
|
this.#publishStatus(entry);
|
|
91
|
+
this.#schedulePersist();
|
|
76
92
|
return ok({ ...entry.info });
|
|
77
93
|
}
|
|
78
94
|
async create(request, origin) {
|
|
@@ -87,69 +103,97 @@ export class TerminalManager {
|
|
|
87
103
|
return cwd;
|
|
88
104
|
if (origin.trusted)
|
|
89
105
|
this.#trustedFolders.add(cwd.value);
|
|
90
|
-
const id = `t${this.#nextId++}`;
|
|
91
|
-
const cols = readSize(request.cols, DEFAULT_COLS);
|
|
92
|
-
const rows = readSize(request.rows, DEFAULT_ROWS);
|
|
93
106
|
const naming = readNaming(request.name);
|
|
94
|
-
const defaultTitle = (command.value || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
|
|
95
|
-
const title = request.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
|
|
96
107
|
const project = await this.#projectOf(cwd.value);
|
|
97
|
-
const entry = {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
exitCode: null,
|
|
110
|
-
startedAt: this.#now(),
|
|
111
|
-
endedAt: null,
|
|
112
|
-
pid: null,
|
|
113
|
-
mode: this.#spawner.mode,
|
|
114
|
-
cols,
|
|
115
|
-
rows,
|
|
116
|
-
nextSeq: 0,
|
|
117
|
-
naming,
|
|
118
|
-
},
|
|
119
|
-
request: {
|
|
120
|
-
command: command.value,
|
|
121
|
-
cwd: cwd.value,
|
|
122
|
-
cols,
|
|
123
|
-
rows,
|
|
124
|
-
env: {
|
|
125
|
-
TERM: "xterm-256color",
|
|
126
|
-
COLUMNS: String(cols),
|
|
127
|
-
LINES: String(rows),
|
|
128
|
-
PORTLESS_STUDIO_TERMINAL: id,
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
buffer: new OutputBuffer(this.#maxOutput),
|
|
132
|
-
process: null,
|
|
133
|
-
stopTimers: [],
|
|
134
|
-
watchers: new Set(),
|
|
135
|
-
baseline: null,
|
|
136
|
-
namingTimer: undefined,
|
|
137
|
-
};
|
|
108
|
+
const entry = this.#newEntry({
|
|
109
|
+
id: `t${this.#nextId++}`,
|
|
110
|
+
command: command.value,
|
|
111
|
+
cwd: cwd.value,
|
|
112
|
+
title: request.title,
|
|
113
|
+
cols: readSize(request.cols, DEFAULT_COLS),
|
|
114
|
+
rows: readSize(request.rows, DEFAULT_ROWS),
|
|
115
|
+
naming,
|
|
116
|
+
project,
|
|
117
|
+
startedBy: origin.trusted ? "program" : "person",
|
|
118
|
+
startedAt: this.#now(),
|
|
119
|
+
});
|
|
138
120
|
if (naming)
|
|
139
121
|
entry.baseline = await this.#portsInUse();
|
|
140
122
|
const started = await this.#spawn(entry);
|
|
141
123
|
if (!started.ok)
|
|
142
124
|
return started;
|
|
143
|
-
this.#entries.set(id, entry);
|
|
125
|
+
this.#entries.set(entry.info.id, entry);
|
|
126
|
+
if (command.value) {
|
|
127
|
+
this.#recent = rememberCommand(this.#recentCommands(), cwd.value, command.value);
|
|
128
|
+
}
|
|
144
129
|
if (naming)
|
|
145
130
|
this.#startNaming(entry);
|
|
146
131
|
else
|
|
147
132
|
this.#updateWorkspaceWatch();
|
|
148
133
|
this.#changed();
|
|
134
|
+
this.#schedulePersist();
|
|
149
135
|
if (this.#watchers.size > 0)
|
|
150
136
|
void this.#refreshPorts();
|
|
151
137
|
return ok({ ...entry.info });
|
|
152
138
|
}
|
|
139
|
+
/** The last commands run in each folder, newest first. */
|
|
140
|
+
recentCommands() {
|
|
141
|
+
return Object.fromEntries(Object.entries(this.#recentCommands()).map(([folder, list]) => [folder, [...list]]));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Brings back the terminals that were running when Studio last stopped: listed as stopped by a
|
|
145
|
+
* restart (`ask`), started again unless they are a plain shell (`always`), or forgotten
|
|
146
|
+
* (`never`). Folders that no longer exist are skipped. Call it once, on start.
|
|
147
|
+
*/
|
|
148
|
+
async restore(mode) {
|
|
149
|
+
if (this.#closing)
|
|
150
|
+
return;
|
|
151
|
+
const saved = this.#history?.read().running ?? [];
|
|
152
|
+
if (mode === "never" || saved.length === 0) {
|
|
153
|
+
if (saved.length > 0)
|
|
154
|
+
this.#persistNow();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const restored = [];
|
|
158
|
+
for (const item of saved) {
|
|
159
|
+
if (this.#entries.size >= this.#maxTerminals)
|
|
160
|
+
break;
|
|
161
|
+
const cwd = this.#folders.directory(item.cwd);
|
|
162
|
+
if (cwd === null)
|
|
163
|
+
continue;
|
|
164
|
+
if (item.startedBy === "program")
|
|
165
|
+
this.#trustedFolders.add(cwd);
|
|
166
|
+
const entry = this.#newEntry({
|
|
167
|
+
id: `t${this.#nextId++}`,
|
|
168
|
+
command: item.command || null,
|
|
169
|
+
cwd,
|
|
170
|
+
title: item.title,
|
|
171
|
+
cols: DEFAULT_COLS,
|
|
172
|
+
rows: DEFAULT_ROWS,
|
|
173
|
+
naming: null,
|
|
174
|
+
project: { name: item.project ?? "", worktree: item.worktree, branch: item.branch },
|
|
175
|
+
startedBy: item.startedBy,
|
|
176
|
+
startedAt: item.startedAt,
|
|
177
|
+
});
|
|
178
|
+
if (item.project === null) {
|
|
179
|
+
entry.info = { ...entry.info, project: null, worktree: null, branch: null };
|
|
180
|
+
}
|
|
181
|
+
entry.info = { ...entry.info, status: "exited", stoppedByRestart: true };
|
|
182
|
+
this.#append(entry, RESTART_NOTE);
|
|
183
|
+
this.#entries.set(entry.info.id, entry);
|
|
184
|
+
restored.push(entry);
|
|
185
|
+
}
|
|
186
|
+
this.#changed();
|
|
187
|
+
this.#persistNow();
|
|
188
|
+
if (mode !== "always")
|
|
189
|
+
return;
|
|
190
|
+
for (const entry of restored) {
|
|
191
|
+
if (this.#closing)
|
|
192
|
+
return;
|
|
193
|
+
if (entry.info.command !== "")
|
|
194
|
+
await this.restart(entry.info.id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
153
197
|
input(id, text) {
|
|
154
198
|
const entry = this.#entries.get(id);
|
|
155
199
|
if (!entry)
|
|
@@ -166,6 +210,7 @@ export class TerminalManager {
|
|
|
166
210
|
return notFound(id);
|
|
167
211
|
if (!entry.process || entry.info.status !== "running")
|
|
168
212
|
return ok({ ...entry.info });
|
|
213
|
+
entry.stoppedOnPurpose = true;
|
|
169
214
|
this.#escalate(entry, entry.process);
|
|
170
215
|
return ok({ ...entry.info });
|
|
171
216
|
}
|
|
@@ -177,16 +222,36 @@ export class TerminalManager {
|
|
|
177
222
|
const current = entry.process;
|
|
178
223
|
if (current && entry.info.status === "running") {
|
|
179
224
|
const ended = new Promise((resolve) => current.onExit(() => resolve()));
|
|
225
|
+
entry.stoppedOnPurpose = true;
|
|
180
226
|
this.#escalate(entry, current);
|
|
181
227
|
await ended;
|
|
182
228
|
}
|
|
183
229
|
this.#append(entry, `\r\n[Studio] restarted: ${entry.info.command || "shell"}\r\n`);
|
|
230
|
+
if (entry.info.project === null) {
|
|
231
|
+
const project = await this.#projectOf(entry.info.cwd);
|
|
232
|
+
if (project) {
|
|
233
|
+
entry.info = {
|
|
234
|
+
...entry.info,
|
|
235
|
+
project: project.name,
|
|
236
|
+
worktree: project.worktree ?? null,
|
|
237
|
+
branch: project.branch ?? null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
184
241
|
const started = await this.#spawn(entry);
|
|
185
242
|
if (!started.ok)
|
|
186
243
|
return started;
|
|
187
|
-
entry.
|
|
244
|
+
entry.stoppedOnPurpose = false;
|
|
245
|
+
entry.info = {
|
|
246
|
+
...entry.info,
|
|
247
|
+
status: "running",
|
|
248
|
+
exitCode: null,
|
|
249
|
+
endedAt: null,
|
|
250
|
+
stoppedByRestart: false,
|
|
251
|
+
};
|
|
188
252
|
entry.info.startedAt = this.#now();
|
|
189
253
|
this.#publishStatus(entry);
|
|
254
|
+
this.#schedulePersist();
|
|
190
255
|
this.#updateWorkspaceWatch();
|
|
191
256
|
if (this.#watchers.size > 0)
|
|
192
257
|
void this.#refreshPorts();
|
|
@@ -205,6 +270,7 @@ export class TerminalManager {
|
|
|
205
270
|
entry.watchers.clear();
|
|
206
271
|
this.#updateWorkspaceWatch();
|
|
207
272
|
this.#changed();
|
|
273
|
+
this.#schedulePersist();
|
|
208
274
|
return ok(undefined);
|
|
209
275
|
}
|
|
210
276
|
read(id, from, maxChars) {
|
|
@@ -225,8 +291,16 @@ export class TerminalManager {
|
|
|
225
291
|
entry.watchers.add(watcher);
|
|
226
292
|
return ok(() => entry.watchers.delete(watcher));
|
|
227
293
|
}
|
|
228
|
-
/**
|
|
294
|
+
/**
|
|
295
|
+
* Studio is stopping: the terminals still running are written down for the next start, then
|
|
296
|
+
* every command gets SIGTERM and SIGKILL right away.
|
|
297
|
+
*/
|
|
229
298
|
closeAll() {
|
|
299
|
+
if (!this.#closing) {
|
|
300
|
+
this.#persistNow(true);
|
|
301
|
+
this.#closing = true;
|
|
302
|
+
clearTimeout(this.#persistTimer);
|
|
303
|
+
}
|
|
230
304
|
for (const entry of this.#entries.values()) {
|
|
231
305
|
this.#clearTimers(entry);
|
|
232
306
|
if (entry.process && entry.info.status === "running") {
|
|
@@ -267,10 +341,108 @@ export class TerminalManager {
|
|
|
267
341
|
}
|
|
268
342
|
entry.info.ports = [];
|
|
269
343
|
this.#publishStatus(entry);
|
|
344
|
+
this.#schedulePersist();
|
|
270
345
|
this.#updateWorkspaceWatch();
|
|
271
346
|
});
|
|
272
347
|
return ok(undefined);
|
|
273
348
|
}
|
|
349
|
+
#newEntry(fields) {
|
|
350
|
+
const defaultTitle = (fields.command || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
|
|
351
|
+
const title = fields.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
|
|
352
|
+
const { id, cols, rows } = fields;
|
|
353
|
+
return {
|
|
354
|
+
defaultTitle,
|
|
355
|
+
info: {
|
|
356
|
+
id,
|
|
357
|
+
title,
|
|
358
|
+
cwd: fields.cwd,
|
|
359
|
+
command: fields.command ?? "",
|
|
360
|
+
project: fields.project?.name ?? null,
|
|
361
|
+
worktree: fields.project?.worktree ?? null,
|
|
362
|
+
branch: fields.project?.branch ?? null,
|
|
363
|
+
ports: [],
|
|
364
|
+
status: "running",
|
|
365
|
+
exitCode: null,
|
|
366
|
+
startedAt: fields.startedAt,
|
|
367
|
+
endedAt: null,
|
|
368
|
+
pid: null,
|
|
369
|
+
mode: this.#spawner.mode,
|
|
370
|
+
cols,
|
|
371
|
+
rows,
|
|
372
|
+
nextSeq: 0,
|
|
373
|
+
naming: fields.naming,
|
|
374
|
+
startedBy: fields.startedBy,
|
|
375
|
+
stoppedByRestart: false,
|
|
376
|
+
},
|
|
377
|
+
request: {
|
|
378
|
+
command: fields.command,
|
|
379
|
+
cwd: fields.cwd,
|
|
380
|
+
cols,
|
|
381
|
+
rows,
|
|
382
|
+
env: {
|
|
383
|
+
TERM: "xterm-256color",
|
|
384
|
+
COLUMNS: String(cols),
|
|
385
|
+
LINES: String(rows),
|
|
386
|
+
PORTLESS_STUDIO_TERMINAL: id,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
buffer: new OutputBuffer(this.#maxOutput),
|
|
390
|
+
process: null,
|
|
391
|
+
stopTimers: [],
|
|
392
|
+
watchers: new Set(),
|
|
393
|
+
baseline: null,
|
|
394
|
+
namingTimer: undefined,
|
|
395
|
+
stoppedOnPurpose: false,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
#recentCommands() {
|
|
399
|
+
this.#recent ??= this.#history?.read().recentCommands ?? {};
|
|
400
|
+
return this.#recent;
|
|
401
|
+
}
|
|
402
|
+
#schedulePersist() {
|
|
403
|
+
if (!this.#history || this.#closing || this.#persistTimer)
|
|
404
|
+
return;
|
|
405
|
+
this.#persistTimer = setTimeout(() => {
|
|
406
|
+
this.#persistTimer = undefined;
|
|
407
|
+
this.#persistNow();
|
|
408
|
+
}, this.#persistDelayMs);
|
|
409
|
+
this.#persistTimer.unref?.();
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Writes the terminals that run and the recent commands. When Studio stops, commands that just
|
|
413
|
+
* ended without anyone asking count as running too.
|
|
414
|
+
*/
|
|
415
|
+
#persistNow(stopping = false) {
|
|
416
|
+
const history = this.#history;
|
|
417
|
+
if (!history || this.#closing)
|
|
418
|
+
return;
|
|
419
|
+
clearTimeout(this.#persistTimer);
|
|
420
|
+
this.#persistTimer = undefined;
|
|
421
|
+
const now = this.#now();
|
|
422
|
+
const running = [...this.#entries.values()]
|
|
423
|
+
.filter((entry) => entry.info.status === "running" ||
|
|
424
|
+
entry.info.stoppedByRestart === true ||
|
|
425
|
+
(stopping &&
|
|
426
|
+
!entry.stoppedOnPurpose &&
|
|
427
|
+
entry.info.endedAt !== null &&
|
|
428
|
+
now - entry.info.endedAt <= RECENT_EXIT_MS))
|
|
429
|
+
.map(({ info }) => ({
|
|
430
|
+
command: info.command,
|
|
431
|
+
cwd: info.cwd,
|
|
432
|
+
title: info.title,
|
|
433
|
+
startedAt: info.startedAt,
|
|
434
|
+
startedBy: info.startedBy ?? "person",
|
|
435
|
+
project: info.project,
|
|
436
|
+
worktree: info.worktree,
|
|
437
|
+
branch: info.branch,
|
|
438
|
+
}));
|
|
439
|
+
try {
|
|
440
|
+
history.write({ running, recentCommands: this.#recentCommands() });
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// A full disk or a read-only config folder: terminals keep working, they just are not kept.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
274
446
|
#escalate(entry, child) {
|
|
275
447
|
this.#clearTimers(entry);
|
|
276
448
|
child.interrupt();
|