shelldeck 0.0.1__py3-none-any.whl

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.
Files changed (45) hide show
  1. shelldeck/__init__.py +0 -0
  2. shelldeck/__main__.py +4 -0
  3. shelldeck/addons/__init__.py +1 -0
  4. shelldeck/addons/fast_context.py +1346 -0
  5. shelldeck/auth.py +207 -0
  6. shelldeck/cli.py +587 -0
  7. shelldeck/db.py +794 -0
  8. shelldeck/gitgraph.py +150 -0
  9. shelldeck/integration/bash.sh +27 -0
  10. shelldeck/integration/shelldeck.fish +5 -0
  11. shelldeck/integration/shelldeck.ps1 +35 -0
  12. shelldeck/integration/zsh/.zshenv +6 -0
  13. shelldeck/integration/zsh/.zshrc +10 -0
  14. shelldeck/pty.py +352 -0
  15. shelldeck/runner.py +75 -0
  16. shelldeck/scheduler.py +283 -0
  17. shelldeck/server.py +1216 -0
  18. shelldeck/shells.py +174 -0
  19. shelldeck/static/app.css +2288 -0
  20. shelldeck/static/app.js +1863 -0
  21. shelldeck/static/gitgraph.js +142 -0
  22. shelldeck/static/history.js +172 -0
  23. shelldeck/static/icon-192.png +0 -0
  24. shelldeck/static/icon-32.png +0 -0
  25. shelldeck/static/icon-512.png +0 -0
  26. shelldeck/static/icon.svg +1 -0
  27. shelldeck/static/index.html +102 -0
  28. shelldeck/static/manifest.webmanifest +1 -0
  29. shelldeck/static/monitor.js +143 -0
  30. shelldeck/static/ui.js +316 -0
  31. shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
  32. shelldeck/static/vendor/addon-fit.js +2 -0
  33. shelldeck/static/vendor/addon-search.js +2 -0
  34. shelldeck/static/vendor/addon-serialize.js +2 -0
  35. shelldeck/static/vendor/addon-web-links.js +2 -0
  36. shelldeck/static/vendor/addon-webgl.js +2 -0
  37. shelldeck/static/vendor/xterm.css +218 -0
  38. shelldeck/static/vendor/xterm.js +2 -0
  39. shelldeck/static/views.js +658 -0
  40. shelldeck/stats.py +128 -0
  41. shelldeck-0.0.1.dist-info/METADATA +254 -0
  42. shelldeck-0.0.1.dist-info/RECORD +45 -0
  43. shelldeck-0.0.1.dist-info/WHEEL +4 -0
  44. shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
  45. shelldeck-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1863 @@
1
+ import { $, $$, api, authError, bus, closeMenu, confirmDialog, dialog, esc, hydrateIcons, icon, menu, promptDialog, store, toast, toastError, topDialog, withEyes } from "./ui.js";
2
+ import * as views from "./views.js";
3
+ import { startMonitor } from "./monitor.js";
4
+ import { searchAllDialog } from "./history.js";
5
+ import { showGitGraph } from "./gitgraph.js";
6
+
7
+ // ------------------------------------------------------------------ state
8
+
9
+ export const S = {
10
+ projects: [],
11
+ settings: { default_shell: "pwsh", wsl_distro: "", font_size: "13", theme: "dark" },
12
+ shells: { kinds: [], available: [], wsl_distros: [] },
13
+ terms: new Map(), // sid -> Term
14
+ layout: store.get("layout", null), // {sid} | {dir, kids, sizes}
15
+ free: store.get("free", {}), // free mode: sid -> {x, y, w, h, z}
16
+ focused: store.get("focused", null),
17
+ view: "terminals",
18
+ collapsed: new Set(store.get("collapsed", [])),
19
+ order: store.get("order", []),
20
+ unread: new Set(),
21
+ ports: {}, // sid -> listening TCP ports, from the stats poller
22
+ online: true,
23
+ };
24
+
25
+ const SHELL_LABEL = { pwsh: "pwsh", powershell: "PowerShell", cmd: "cmd", wsl: "WSL" };
26
+ const mobile = matchMedia("(max-width: 900px)");
27
+ const isFree = () => S.settings.layout_mode === "free" && !mobile.matches;
28
+
29
+ // Project palette slots (db column projects.color). Tuned to read on dark and light.
30
+ const PROJECT_COLORS = ["#60a5fa", "#f472b6", "#34d399", "#fbbf24", "#a78bfa", "#f87171", "#22d3ee", "#fb923c"];
31
+
32
+ export function projectColor(p) {
33
+ return PROJECT_COLORS[(p?.color ?? 0) % PROJECT_COLORS.length];
34
+ }
35
+
36
+ function mix(a, b, t) {
37
+ const n = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
38
+ const [x, y] = [n(a), n(b)];
39
+ return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("")}`;
40
+ }
41
+
42
+ export function shellLabel(kind) {
43
+ kind = kind || S.settings.default_shell;
44
+ return S.shells.labels?.[kind] || SHELL_LABEL[kind] || kind;
45
+ }
46
+
47
+ export function allSessions() {
48
+ return S.projects.flatMap((p) => p.sessions.map((s) => ({ ...s, project: p })));
49
+ }
50
+
51
+ export function findSession(sid) {
52
+ return allSessions().find((s) => s.id === sid);
53
+ }
54
+
55
+ export function sessionTitle(s) {
56
+ return s.name || `${shellLabel(s.shell)} ${s.id.slice(0, 4)}`;
57
+ }
58
+
59
+ function focusedProject() {
60
+ const s = S.focused && findSession(S.focused);
61
+ return s?.project || orderedProjects()[0] || null;
62
+ }
63
+
64
+ // ------------------------------------------------------------------ theme
65
+
66
+ const XTERM_THEMES = {
67
+ dark: {
68
+ background: "#131316", foreground: "#e4e4e7", cursor: "#f4f4f5", cursorAccent: "#131316",
69
+ selectionBackground: "rgba(167,139,250,0.32)",
70
+ black: "#27272a", red: "#f87171", green: "#4ade80", yellow: "#facc15", blue: "#60a5fa", magenta: "#c084fc", cyan: "#22d3ee", white: "#e4e4e7",
71
+ brightBlack: "#71717a", brightRed: "#fca5a5", brightGreen: "#86efac", brightYellow: "#fde047", brightBlue: "#93c5fd", brightMagenta: "#d8b4fe", brightCyan: "#67e8f9", brightWhite: "#fafafa",
72
+ },
73
+ light: {
74
+ background: "#ffffff", foreground: "#27272a", cursor: "#18181b", cursorAccent: "#ffffff",
75
+ selectionBackground: "rgba(124,58,237,0.2)",
76
+ black: "#18181b", red: "#dc2626", green: "#16a34a", yellow: "#ca8a04", blue: "#2563eb", magenta: "#9333ea", cyan: "#0891b2", white: "#d4d4d8",
77
+ brightBlack: "#71717a", brightRed: "#ef4444", brightGreen: "#22c55e", brightYellow: "#eab308", brightBlue: "#3b82f6", brightMagenta: "#a855f7", brightCyan: "#06b6d4", brightWhite: "#f4f4f5",
78
+ },
79
+ };
80
+
81
+ // Terminal color presets (setting terminal_theme; keep names in sync with TERMINAL_THEMES in server.py).
82
+ // "default" follows the app theme. Backgrounds must be #rrggbb so project tint can mix in.
83
+ const pal = (bg, fg, cursor, sel, c) => {
84
+ const [black, red, green, yellow, blue, magenta, cyan, white, bBlack, bRed, bGreen, bYellow, bBlue, bMagenta, bCyan, bWhite] = c.split(" ");
85
+ return {
86
+ background: bg, foreground: fg, cursor, cursorAccent: bg, selectionBackground: sel,
87
+ black, red, green, yellow, blue, magenta, cyan, white,
88
+ brightBlack: bBlack, brightRed: bRed, brightGreen: bGreen, brightYellow: bYellow, brightBlue: bBlue, brightMagenta: bMagenta, brightCyan: bCyan, brightWhite: bWhite,
89
+ };
90
+ };
91
+ const PRESETS = {
92
+ dracula: pal("#282a36", "#f8f8f2", "#f8f8f2", "rgba(68,71,90,0.9)", "#21222c #ff5555 #50fa7b #f1fa8c #bd93f9 #ff79c6 #8be9fd #f8f8f2 #6272a4 #ff6e6e #69ff94 #ffffa5 #d6acff #ff92df #a4ffff #ffffff"),
93
+ "one-dark": pal("#282c34", "#abb2bf", "#528bff", "rgba(62,68,81,0.9)", "#3f4451 #e06c75 #98c379 #e5c07b #61afef #c678dd #56b6c2 #abb2bf #4f5666 #ff7b86 #b1e18b #efb074 #67cdff #e48bff #63d4e0 #ffffff"),
94
+ nord: pal("#2e3440", "#d8dee9", "#d8dee9", "rgba(67,76,94,0.9)", "#3b4252 #bf616a #a3be8c #ebcb8b #81a1c1 #b48ead #88c0d0 #e5e9f0 #4c566a #bf616a #a3be8c #ebcb8b #81a1c1 #b48ead #8fbcbb #eceff4"),
95
+ "gruvbox-dark": pal("#282828", "#ebdbb2", "#ebdbb2", "rgba(80,73,69,0.9)", "#282828 #cc241d #98971a #d79921 #458588 #b16286 #689d6a #a89984 #928374 #fb4934 #b8bb26 #fabd2f #83a598 #d3869b #8ec07c #ebdbb2"),
96
+ "solarized-dark": pal("#002b36", "#839496", "#93a1a1", "rgba(7,54,66,0.9)", "#073642 #dc322f #859900 #b58900 #268bd2 #d33682 #2aa198 #eee8d5 #586e75 #cb4b16 #859900 #b58900 #268bd2 #6c71c4 #2aa198 #fdf6e3"),
97
+ "solarized-light": pal("#fdf6e3", "#657b83", "#586e75", "rgba(147,161,161,0.3)", "#073642 #dc322f #859900 #b58900 #268bd2 #d33682 #2aa198 #eee8d5 #586e75 #cb4b16 #859900 #b58900 #268bd2 #6c71c4 #2aa198 #002b36"),
98
+ "github-light": pal("#ffffff", "#24292f", "#0969da", "rgba(84,174,255,0.3)", "#24292f #cf222e #116329 #4d2d00 #0969da #8250df #1b7c83 #6e7781 #57606a #a40e26 #1a7f37 #633c01 #218bff #a475f9 #3192aa #8c959f"),
99
+ };
100
+ export const TERMINAL_THEMES = ["default", ...Object.keys(PRESETS)];
101
+
102
+ const DEFAULT_FONT = '"CaskaydiaCove Nerd Font", "CaskaydiaCove NF", "CaskaydiaMono Nerd Font", "Cascadia Code NF", "Cascadia Code", "Cascadia Mono", Consolas, monospace';
103
+
104
+ function fontFamily() {
105
+ const f = (S.settings.font_family || "").trim();
106
+ if (!f) return DEFAULT_FONT;
107
+ return `${/[,"']/.test(f) ? f : `"${f}"`}, ${DEFAULT_FONT}`;
108
+ }
109
+
110
+ const prefersDark = matchMedia("(prefers-color-scheme: dark)");
111
+
112
+ function resolvedTheme() {
113
+ const t = S.settings.theme;
114
+ return t === "system" ? (prefersDark.matches ? "dark" : "light") : t === "light" ? "light" : "dark";
115
+ }
116
+
117
+ function themeFor(sid) {
118
+ const base = PRESETS[S.settings.terminal_theme] || XTERM_THEMES[resolvedTheme()];
119
+ const dark = parseInt(base.background.slice(1, 3), 16) < 0x80;
120
+ const s = findSession(sid);
121
+ if (!s || S.settings.project_tint === "off") return base;
122
+ const bg = mix(base.background, projectColor(s.project), dark ? 0.09 : 0.07);
123
+ return { ...base, background: bg, cursorAccent: bg };
124
+ }
125
+
126
+ let lastMode = null;
127
+ export function applySettings() {
128
+ document.documentElement.dataset.theme = resolvedTheme();
129
+ for (const t of S.terms.values()) {
130
+ t.xterm.options.theme = themeFor(t.sid);
131
+ t.xterm.options.fontSize = +S.settings.font_size || 13;
132
+ t.xterm.options.fontFamily = fontFamily();
133
+ t.fit();
134
+ }
135
+ for (const p of $$(".pane[data-sid]")) paintPane(p, p.dataset.sid);
136
+ for (const b of $$("[data-action=split]")) b.hidden = isFree();
137
+ $("[data-action=tile]").hidden = !isFree();
138
+ const mode = S.settings.layout_mode;
139
+ if (lastMode && mode !== lastMode) {
140
+ if (mode === "free" && !Object.keys(S.free).length) {
141
+ for (const sid of leaves(S.layout)) S.free[sid] = {};
142
+ tileAll(false);
143
+ }
144
+ renderLayout();
145
+ }
146
+ lastMode = mode;
147
+ }
148
+
149
+ function paintPane(pane, sid) {
150
+ const s = findSession(sid);
151
+ pane.style.setProperty("--proj", s ? projectColor(s.project) : "transparent");
152
+ pane.style.background = themeFor(sid).background;
153
+ }
154
+ prefersDark.addEventListener("change", applySettings);
155
+
156
+ // -------------------------------------------------------------- terminals
157
+
158
+ class Term {
159
+ constructor(sid) {
160
+ this.sid = sid;
161
+ this.el = document.createElement("div");
162
+ this.el.className = "term-host";
163
+ this.el.style.height = "100%";
164
+ this.xterm = new Terminal({
165
+ fontFamily: fontFamily(),
166
+ fontSize: +S.settings.font_size || 13,
167
+ lineHeight: 1.15,
168
+ cursorBlink: true,
169
+ scrollback: 10000,
170
+ allowProposedApi: true,
171
+ theme: themeFor(sid),
172
+ });
173
+ this.fitAddon = new FitAddon.FitAddon();
174
+ this.xterm.loadAddon(this.fitAddon);
175
+ this.xterm.loadAddon(new WebLinksAddon.WebLinksAddon((e, uri) => window.open(uri, "_blank", "noopener")));
176
+ this.search = new SearchAddon.SearchAddon();
177
+ this.xterm.loadAddon(this.search);
178
+ this.serializer = new SerializeAddon.SerializeAddon();
179
+ this.xterm.loadAddon(this.serializer);
180
+ this.lastSnapshot = "";
181
+ this.changed = false; // output since the last snapshot
182
+ // shell integration (shelldeck/integration): 133 = prompt/command marks, 633 = command line + cwd
183
+ this.integrated = false;
184
+ this.prompts = []; // xterm markers at each prompt, for Ctrl+Shift+Up/Down
185
+ this.running = null; // {cmd, at} from Enter until the shell reports the command finished
186
+ this.xterm.parser.registerOscHandler(133, (data) => this.osc133(data));
187
+ this.xterm.parser.registerOscHandler(633, (data) => this.osc633(data));
188
+ this.opened = false;
189
+ this.ws = null;
190
+ this.connectedOnce = false;
191
+ this.closed = false;
192
+ this.retry = 0;
193
+ this.size = null;
194
+ this.readyWaiters = [];
195
+
196
+ // ignore xterm's auto-replies (e.g. to DA queries) while replaying scrollback
197
+ this.replaying = false;
198
+ this.xterm.onData((data) => {
199
+ if (this.replaying) return;
200
+ this.track(data);
201
+ this.send({ type: "input", data });
202
+ });
203
+ this.line = "";
204
+ this.edited = false; // arrows/tab used: typed buffer no longer matches the line
205
+ this.cmd = "";
206
+ this.xterm.attachCustomKeyEventHandler((e) => this.keyHandler(e));
207
+ }
208
+
209
+ keyHandler(e) {
210
+ if (e.type !== "keydown") return true;
211
+ const k = e.key.toLowerCase();
212
+ if (isAppShortcut(e)) return false;
213
+ if (e.ctrlKey && !e.altKey && !e.shiftKey && k === "f") {
214
+ e.preventDefault();
215
+ this.openFind();
216
+ return false;
217
+ }
218
+ if (e.ctrlKey && e.shiftKey && !e.altKey && (k === "arrowup" || k === "arrowdown")) {
219
+ e.preventDefault();
220
+ this.jumpPrompt(k === "arrowup" ? -1 : 1);
221
+ return false;
222
+ }
223
+ if (e.ctrlKey && !e.altKey && (k === "c" || k === "insert") && this.xterm.hasSelection()) {
224
+ navigator.clipboard.writeText(this.xterm.getSelection()).catch(() => {});
225
+ this.xterm.clearSelection();
226
+ e.preventDefault();
227
+ return false;
228
+ }
229
+ if (e.ctrlKey && e.shiftKey && k === "c") {
230
+ if (this.xterm.hasSelection()) navigator.clipboard.writeText(this.xterm.getSelection()).catch(() => {});
231
+ e.preventDefault();
232
+ return false;
233
+ }
234
+ // let the browser fire a paste event; xterm handles it
235
+ if ((e.ctrlKey && !e.altKey && k === "v") || (e.shiftKey && k === "insert")) return false;
236
+ return true;
237
+ }
238
+
239
+ // ponytail: heuristic command tracking from keystrokes; shell integration (OSC 633) if it proves flaky.
240
+ track(data) {
241
+ for (const part of data.split(/(\r)/)) {
242
+ if (part === "\r") this.commit();
243
+ else if (part === "\x03") (this.line = ""), (this.edited = false);
244
+ else if (part === "\x7f" || part === "\b") this.line = this.line.slice(0, -1);
245
+ else if (/^\x1b\[[IO]$/.test(part)) continue; // focus in/out reports
246
+ else if (part.startsWith("\x1b") || part.includes("\t")) this.edited = true;
247
+ else if (part >= " ") this.line += part;
248
+ }
249
+ }
250
+
251
+ commit() {
252
+ let cmd = this.line;
253
+ if (this.edited || !cmd.trim()) {
254
+ const b = this.xterm.buffer.active;
255
+ // join soft-wrapped rows so long commands read as one line
256
+ let y = b.baseY + b.cursorY;
257
+ let text = b.getLine(y)?.translateToString(true) || "";
258
+ while (y > 0 && b.getLine(y)?.isWrapped) text = (b.getLine(--y)?.translateToString(true) || "") + text;
259
+ cmd = text.match(/[>$#❯➜%]\s+(.+)$/)?.[1] || (this.edited ? "" : cmd);
260
+ }
261
+ this.line = "";
262
+ this.edited = false;
263
+ cmd = cmd.trim().split(/\s{3,}/)[0]; // drop right-aligned prompt segments
264
+ if (!cmd) return;
265
+ this.cmd = cmd;
266
+ if (!this.running) this.running = { cmd, at: Date.now() };
267
+ if (!this.integrated) this.send({ type: "command", cmd, at: new Date().toISOString() });
268
+ this.exit = null;
269
+ this.paintCmd();
270
+ }
271
+
272
+ paintCmd() {
273
+ const el = document.querySelector(`.pane[data-sid="${this.sid}"] .title .cmd`);
274
+ if (!el) return;
275
+ el.textContent = shortCmd(this.cmd);
276
+ el.title = this.exit ? `${this.cmd} (exit ${this.exit})` : this.cmd;
277
+ el.classList.toggle("fail", !!this.exit);
278
+ }
279
+
280
+ osc133(data) {
281
+ const [kind, code] = data.split(";");
282
+ this.integrated = true;
283
+ if (kind === "A") {
284
+ const m = this.xterm.registerMarker(0);
285
+ if (m) this.prompts.push(m);
286
+ if (this.prompts.length > 500) this.prompts.shift().dispose();
287
+ } else if (kind === "D") {
288
+ const r = this.running;
289
+ this.running = null;
290
+ if (!r || this.replaying) return true;
291
+ this.exit = code ? +code || 0 : null;
292
+ this.paintCmd();
293
+ this.send({ type: "command", cmd: this.cmd || r.cmd, exit: this.exit, ms: Date.now() - r.at, at: new Date(r.at).toISOString() });
294
+ if (Date.now() - r.at >= LONG_CMD_MS) notifyDone(this, this.cmd || r.cmd, Date.now() - r.at);
295
+ }
296
+ return true;
297
+ }
298
+
299
+ osc633(data) {
300
+ if (data.startsWith("E;")) {
301
+ this.cmd = data.slice(2).trim() || this.cmd;
302
+ this.paintCmd();
303
+ }
304
+ return true; // P;Cwd= is recorded server-side
305
+ }
306
+
307
+ /** Send the rendered screen to the server; it is what comes back after a restart. */
308
+ snapshot() {
309
+ if (!this.changed || this.replaying || this.closed) return;
310
+ this.changed = false;
311
+ // drop the trailing cursor moves / mode sets: the restore banner must land after the content
312
+ const data = this.serializer.serialize({ scrollback: 2000 }).replace(/(?:\x1b\[[0-9;]*[ABCDGHf]|\x1b\[\?[0-9;]*[hl])+$/, "");
313
+ if (data === this.lastSnapshot) return;
314
+ this.lastSnapshot = data;
315
+ this.send({ type: "snapshot", data });
316
+ }
317
+
318
+ jumpPrompt(dir) {
319
+ const top = this.xterm.buffer.active.viewportY;
320
+ const lines = this.prompts.filter((m) => !m.isDisposed && m.line >= 0).map((m) => m.line);
321
+ const target = dir < 0 ? lines.filter((l) => l < top).pop() : lines.find((l) => l > top);
322
+ if (target === undefined) return dir > 0 && this.xterm.scrollToBottom();
323
+ this.xterm.scrollToLine(target);
324
+ }
325
+
326
+ openFind(query = "") {
327
+ let bar = this.el.querySelector(".find-bar");
328
+ if (!bar) {
329
+ bar = document.createElement("div");
330
+ bar.className = "find-bar";
331
+ bar.innerHTML = `<input type="text" placeholder="Find" spellcheck="false" aria-label="Find in terminal" />
332
+ <span class="find-count"></span>
333
+ <button class="icon-btn sm" data-find="prev" title="Previous (Shift+Enter)" aria-label="Previous match">${icon("chevron-up")}</button>
334
+ <button class="icon-btn sm" data-find="next" title="Next (Enter)" aria-label="Next match">${icon("chevron")}</button>
335
+ <button class="icon-btn sm" data-find="close" title="Close (Esc)" aria-label="Close find">${icon("x")}</button>`;
336
+ const input = bar.querySelector("input");
337
+ const count = bar.querySelector(".find-count");
338
+ this.search.onDidChangeResults(({ resultIndex, resultCount }) => {
339
+ count.textContent = !input.value ? "" : resultCount ? `${resultIndex + 1}/${resultCount}` : "No results";
340
+ });
341
+ const go = (dir, incremental = false) => {
342
+ if (!input.value) return this.search.clearDecorations();
343
+ const opts = { incremental, decorations: FIND_DECORATIONS };
344
+ dir < 0 ? this.search.findPrevious(input.value, opts) : this.search.findNext(input.value, opts);
345
+ };
346
+ input.addEventListener("input", () => go(1, true));
347
+ input.addEventListener("keydown", (e) => {
348
+ e.stopPropagation();
349
+ if (e.key === "Enter") go(e.shiftKey ? -1 : 1);
350
+ else if (e.key === "Escape") this.closeFind();
351
+ else if (e.key.toLowerCase() === "f" && e.ctrlKey) {
352
+ e.preventDefault();
353
+ input.select();
354
+ }
355
+ });
356
+ bar.addEventListener("click", (e) => {
357
+ const a = e.target.closest("[data-find]")?.dataset.find;
358
+ if (a === "close") this.closeFind();
359
+ else if (a) go(a === "prev" ? -1 : 1);
360
+ });
361
+ this.el.append(bar);
362
+ }
363
+ const input = bar.querySelector("input");
364
+ const sel = query || this.xterm.getSelection();
365
+ if (sel && !sel.includes("\n")) input.value = sel;
366
+ input.focus();
367
+ input.select();
368
+ if (input.value) this.search.findNext(input.value, { decorations: FIND_DECORATIONS });
369
+ }
370
+
371
+ closeFind() {
372
+ this.search.clearDecorations();
373
+ this.el.querySelector(".find-bar")?.remove();
374
+ this.focus();
375
+ }
376
+
377
+ mount(container) {
378
+ container.append(this.el);
379
+ if (!this.opened) {
380
+ this.xterm.open(this.el);
381
+ try {
382
+ const gl = new WebglAddon.WebglAddon();
383
+ gl.onContextLoss(() => gl.dispose());
384
+ this.xterm.loadAddon(gl);
385
+ } catch {
386
+ /* DOM renderer */
387
+ }
388
+ this.xterm.textarea.addEventListener("focus", () => setFocus(this.sid));
389
+ this.xterm.registerLinkProvider(pathLinks(this));
390
+ this.opened = true;
391
+ this.connect();
392
+ }
393
+ }
394
+
395
+ ready() {
396
+ if (this.ws?.readyState === WebSocket.OPEN) return Promise.resolve();
397
+ return new Promise((r) => this.readyWaiters.push(r));
398
+ }
399
+
400
+ connect() {
401
+ if (this.closed) return;
402
+ const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws/${this.sid}`);
403
+ this.ws = ws;
404
+ this.status("");
405
+ this.heard = Date.now();
406
+ ws.onopen = () => {
407
+ this.heard = Date.now();
408
+ if (this.connectedOnce) this.xterm.reset(); // server replays scrollback
409
+ this.connectedOnce = true;
410
+ this.retry = 0;
411
+ this.size = null;
412
+ this.fit(true);
413
+ if (!this.gotOutput) this.status("Starting shell…");
414
+ this.readyWaiters.splice(0).forEach((r) => r());
415
+ };
416
+ ws.onmessage = (ev) => {
417
+ this.heard = Date.now();
418
+ let msg;
419
+ try {
420
+ msg = JSON.parse(ev.data);
421
+ } catch {
422
+ return;
423
+ }
424
+ if (msg.type === "output") {
425
+ if (!this.gotOutput) {
426
+ this.gotOutput = true;
427
+ this.status("");
428
+ }
429
+ if (msg.replay) {
430
+ this.replaying = true;
431
+ this.xterm.write(msg.data, () => (this.replaying = false));
432
+ } else {
433
+ this.xterm.write(msg.data);
434
+ this.changed = true;
435
+ markUnread(this.sid);
436
+ }
437
+ }
438
+ else if (msg.type === "exit") this.exited();
439
+ else if (msg.type === "error") this.status(msg.message);
440
+ else if (msg.type === "clipboard") this.send({ type: "input", data: msg.data });
441
+ };
442
+ ws.onclose = (ev) => {
443
+ if (this.closed) return;
444
+ if (ev.code === 4404 || ev.code === 1000) return this.exited();
445
+ if (ev.code === 1008) return bus.dispatchEvent(new Event("locked")); // logging in reloads the page
446
+ this.status("Reconnecting…");
447
+ const delay = Math.min(10000, 500 * 2 ** this.retry++);
448
+ clearTimeout(this.retryTimer);
449
+ this.retryTimer = setTimeout(() => this.connect(), delay);
450
+ };
451
+ }
452
+
453
+ /** Heartbeat: a socket silent for 45s is dead (sleep, Wi-Fi switch, VPN drop), so replace it. */
454
+ heartbeat() {
455
+ if (this.closed || this.ws?.readyState !== WebSocket.OPEN) return;
456
+ if (Date.now() - this.heard > 45000) return this.ws.close();
457
+ this.send({ type: "ping" });
458
+ }
459
+
460
+ /** Reconnect now instead of waiting out the backoff (network back, tab visible again). */
461
+ reconnectNow() {
462
+ if (this.closed || this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) return;
463
+ clearTimeout(this.retryTimer);
464
+ this.retry = 0;
465
+ this.connect();
466
+ }
467
+
468
+ status(text) {
469
+ let el = this.el.querySelector(".pane-status");
470
+ if (!text) return el?.remove();
471
+ if (!el) {
472
+ el = document.createElement("div");
473
+ el.className = "pane-status";
474
+ this.el.append(el);
475
+ }
476
+ el.textContent = text;
477
+ }
478
+
479
+ send(msg) {
480
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
481
+ }
482
+
483
+ fit(force = false) {
484
+ if (!this.opened || !this.el.offsetParent) return;
485
+ try {
486
+ this.fitAddon.fit();
487
+ } catch {
488
+ return;
489
+ }
490
+ const { rows, cols } = this.xterm;
491
+ if (force || !this.size || this.size.rows !== rows || this.size.cols !== cols) {
492
+ this.size = { rows, cols };
493
+ this.send({ type: "resize", rows, cols });
494
+ }
495
+ }
496
+
497
+ focus() {
498
+ this.xterm.focus();
499
+ }
500
+
501
+ exited() {
502
+ if (this.closed) return;
503
+ this.dispose();
504
+ removeSessionLocally(this.sid);
505
+ api(`/api/sessions/${this.sid}`, { method: "DELETE" }).catch(() => {});
506
+ }
507
+
508
+ dispose() {
509
+ this.closed = true;
510
+ this.ws?.close();
511
+ this.xterm.dispose();
512
+ this.el.remove();
513
+ S.terms.delete(this.sid);
514
+ }
515
+ }
516
+
517
+ // file paths in output (src/app.py:12:5, C:\x\y.ts(3,4), File "a.py", line 7) that exist become links
518
+ const PATH_RE = /(?<![\w/\\.:-])((?:[A-Za-z]:[\\/]|~[\\/]|\.{1,2}[\\/]|[\\/])?(?:[\w.@+-]+[\\/])*[\w@+-][\w.@+-]*\.[A-Za-z][\w]{0,7})(?:(?::|\()(\d+)(?:[:,](\d+))?\)?|", line (\d+))?/g;
519
+ // `${sid}\n${raw}` -> {ok, at}; short-lived because relative paths change meaning with `cd`
520
+ const pathCache = new Map();
521
+ const PATH_TTL = 10000;
522
+
523
+ async function existing(sid, raws) {
524
+ const now = Date.now();
525
+ const fresh = (r) => now - (pathCache.get(`${sid}\n${r}`)?.at ?? 0) < PATH_TTL;
526
+ const todo = raws.filter((r) => !fresh(r));
527
+ if (todo.length) {
528
+ try {
529
+ const { files } = await api("/api/fs/check", { method: "POST", body: { session_id: sid, paths: todo } });
530
+ for (const r of todo) pathCache.set(`${sid}\n${r}`, { ok: files.includes(r), at: now });
531
+ } catch {
532
+ return new Set();
533
+ }
534
+ if (pathCache.size > 5000) pathCache.clear();
535
+ }
536
+ return new Set(raws.filter((r) => pathCache.get(`${sid}\n${r}`)?.ok));
537
+ }
538
+
539
+ function pathLinks(term) {
540
+ return {
541
+ async provideLinks(y, done) {
542
+ const text = term.xterm.buffer.active.getLine(y - 1)?.translateToString(true) || "";
543
+ const found = [...text.matchAll(PATH_RE)].filter((m) => !/^https?:/i.test(text.slice(Math.max(0, m.index - 8), m.index + 1))).slice(0, 20);
544
+ if (!found.length) return done(undefined);
545
+ const ok = await existing(term.sid, [...new Set(found.map((m) => m[1]))]);
546
+ const links = found
547
+ .filter((m) => ok.has(m[1]))
548
+ .map((m) => ({
549
+ range: { start: { x: m.index + 1, y }, end: { x: m.index + m[0].length, y } },
550
+ text: m[0],
551
+ decorations: { underline: true, pointerCursor: true },
552
+ activate: () =>
553
+ api("/api/open", { method: "POST", body: { session_id: term.sid, path: m[1], line: +(m[2] || m[4] || 0), col: +(m[3] || 0) } })
554
+ .catch((e) => toastError(e)),
555
+ }));
556
+ done(links.length ? links : undefined);
557
+ },
558
+ };
559
+ }
560
+
561
+ export function portChips(sid) {
562
+ return (S.ports[sid] || []).map((p) => `<button class="port-chip" data-port="${p}" title="Open http://localhost:${p}">${icon("external")}${p}</button>`).join("");
563
+ }
564
+
565
+ const CMD_MAX = 32;
566
+ const LONG_CMD_MS = 10000; // commands at least this long notify when they finish out of view
567
+ // search decorations must be #rrggbb
568
+ const FIND_DECORATIONS = { matchBackground: "#3f3f46", activeMatchBackground: "#7c3aed", matchOverviewRuler: "#71717a", activeMatchColorOverviewRuler: "#a78bfa" };
569
+
570
+ function fmtDur(ms) {
571
+ const s = Math.round(ms / 1000);
572
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
573
+ }
574
+
575
+ function notifyDone(t, cmd, ms) {
576
+ const away = document.hidden || !document.hasFocus();
577
+ if (!away && S.view === "terminals" && S.focused === t.sid) return;
578
+ const s = findSession(t.sid);
579
+ const title = `${t.exit ? "Failed" : "Finished"}: ${shortCmd(cmd) || "command"}`;
580
+ const body = `${s ? `${sessionTitle(s)} · ` : ""}${fmtDur(ms)}${t.exit ? ` · exit ${t.exit}` : ""}`;
581
+ const show = () => {
582
+ window.focus();
583
+ showSession(t.sid);
584
+ };
585
+ const canAsk = "Notification" in window && Notification.permission === "default";
586
+ toast({
587
+ title,
588
+ body,
589
+ kind: t.exit ? "error" : "",
590
+ timeout: 10000,
591
+ actions: [{ label: "Show", onClick: show }, ...(canAsk ? [{ label: "Enable desktop alerts", onClick: () => Notification.requestPermission() }] : [])],
592
+ });
593
+ if (away && "Notification" in window && Notification.permission === "granted") {
594
+ const n = new Notification(title, { body, icon: "/static/icon-192.png", tag: t.sid });
595
+ n.onclick = () => {
596
+ show();
597
+ n.close();
598
+ };
599
+ }
600
+ }
601
+
602
+ // sessions that printed output while out of view; cleared when shown
603
+ function isVisible(sid) {
604
+ return S.view === "terminals" && leaves().includes(sid);
605
+ }
606
+
607
+ function markUnread(sid) {
608
+ if (S.unread.has(sid) || isVisible(sid)) return;
609
+ S.unread.add(sid);
610
+ $(`.sess-row[data-sid="${sid}"]`)?.classList.add("unread");
611
+ }
612
+
613
+ function clearUnread() {
614
+ for (const sid of [...S.unread]) {
615
+ if (!isVisible(sid)) continue;
616
+ S.unread.delete(sid);
617
+ $(`.sess-row[data-sid="${sid}"]`)?.classList.remove("unread");
618
+ }
619
+ }
620
+
621
+ function shortCmd(cmd) {
622
+ if (!cmd) return "";
623
+ return cmd.length > CMD_MAX ? `${cmd.slice(0, CMD_MAX - 1)}…` : cmd;
624
+ }
625
+
626
+ function getTerm(sid) {
627
+ let t = S.terms.get(sid);
628
+ if (!t) {
629
+ t = new Term(sid);
630
+ S.terms.set(sid, t);
631
+ }
632
+ return t;
633
+ }
634
+
635
+ // ----------------------------------------------------------------- layout
636
+
637
+ const isLeaf = (n) => n && "sid" in n;
638
+
639
+ export function leaves(n) {
640
+ if (n === undefined) {
641
+ if (isFree()) return Object.keys(S.free);
642
+ n = S.layout;
643
+ }
644
+ if (!n) return [];
645
+ return isLeaf(n) ? [n.sid] : n.kids.flatMap((k) => leaves(k));
646
+ }
647
+
648
+ function mapTree(n, fn) {
649
+ if (!n) return null;
650
+ if (isLeaf(n)) return fn(n);
651
+ const kids = [];
652
+ const sizes = [];
653
+ n.kids.forEach((k, i) => {
654
+ const r = mapTree(k, fn);
655
+ if (r) {
656
+ kids.push(r);
657
+ sizes.push(n.sizes[i]);
658
+ }
659
+ });
660
+ if (!kids.length) return null;
661
+ if (kids.length === 1) return kids[0];
662
+ const total = sizes.reduce((a, b) => a + b, 0);
663
+ return { dir: n.dir, kids, sizes: sizes.map((s) => s / total) };
664
+ }
665
+
666
+ function removeLeaf(sid) {
667
+ delete S.free[sid];
668
+ S.layout = mapTree(S.layout, (l) => (l.sid === sid ? null : l));
669
+ }
670
+
671
+ function replaceLeaf(targetSid, sid) {
672
+ S.layout = mapTree(S.layout, (l) => (l.sid === targetSid ? { sid } : l));
673
+ }
674
+
675
+ /** Put `sid` next to `targetSid`. side: left|right|top|bottom */
676
+ function splitAt(targetSid, sid, side) {
677
+ const dir = side === "left" || side === "right" ? "row" : "col";
678
+ const before = side === "left" || side === "top";
679
+ S.layout = mapTree(S.layout, (l) => {
680
+ if (l.sid !== targetSid) return l;
681
+ const kids = before ? [{ sid }, l] : [l, { sid }];
682
+ return { dir, kids, sizes: [0.5, 0.5] };
683
+ });
684
+ // flatten same-direction nesting so gutters stay simple
685
+ S.layout = flatten(S.layout);
686
+ }
687
+
688
+ function flatten(n) {
689
+ if (isLeaf(n)) return n;
690
+ const kids = [];
691
+ const sizes = [];
692
+ n.kids.forEach((k, i) => {
693
+ const f = flatten(k);
694
+ if (!isLeaf(f) && f.dir === n.dir) {
695
+ f.kids.forEach((kk, j) => {
696
+ kids.push(kk);
697
+ sizes.push(n.sizes[i] * f.sizes[j]);
698
+ });
699
+ } else {
700
+ kids.push(f);
701
+ sizes.push(n.sizes[i]);
702
+ }
703
+ });
704
+ return { dir: n.dir, kids, sizes };
705
+ }
706
+
707
+ function saveLayout() {
708
+ store.set("layout", S.layout);
709
+ store.set("free", S.free);
710
+ store.set("focused", S.focused);
711
+ }
712
+
713
+ export function renderLayout() {
714
+ const root = $("#layout");
715
+ const hold = $("#term-store");
716
+ for (const t of S.terms.values()) hold.append(t.el);
717
+ root.innerHTML = "";
718
+ if (!leaves().includes(S.focused)) S.focused = leaves()[0] || null;
719
+ // small screens and maximize show only the focused pane
720
+ const single = mobile.matches || $("#app").classList.contains("maximized");
721
+ const free = !single && isFree();
722
+ if (free) root.append(buildFree());
723
+ else if (S.focused) root.append(single ? buildPane(S.focused) : buildNode(S.layout));
724
+ root.classList.toggle("free", free);
725
+ $("#empty").hidden = leaves().length > 0;
726
+ markFocus();
727
+ saveLayout();
728
+ clearUnread();
729
+ fitVisible(true);
730
+ }
731
+
732
+ function buildNode(n) {
733
+ if (isLeaf(n)) return buildPane(n.sid);
734
+ const el = document.createElement("div");
735
+ el.className = `split split-${n.dir}`;
736
+ n.kids.forEach((k, i) => {
737
+ if (i) el.append(buildGutter(n, i, el));
738
+ const child = buildNode(k);
739
+ child.style.flex = `${n.sizes[i]} 1 0`;
740
+ el.append(child);
741
+ });
742
+ return el;
743
+ }
744
+
745
+ function buildGutter(node, i, splitEl) {
746
+ const g = document.createElement("div");
747
+ g.className = "gutter";
748
+ g.addEventListener("pointerdown", (e) => {
749
+ e.preventDefault();
750
+ g.setPointerCapture(e.pointerId);
751
+ g.classList.add("dragging");
752
+ const horizontal = node.dir === "row";
753
+ const kids = [...splitEl.children].filter((c) => !c.classList.contains("gutter"));
754
+ const a = kids[i - 1].getBoundingClientRect();
755
+ const b = kids[i].getBoundingClientRect();
756
+ const start = horizontal ? e.clientX : e.clientY;
757
+ const sizeA = horizontal ? a.width : a.height;
758
+ const sizeB = horizontal ? b.width : b.height;
759
+ const share = node.sizes[i - 1] + node.sizes[i];
760
+ const move = (ev) => {
761
+ const delta = (horizontal ? ev.clientX : ev.clientY) - start;
762
+ const min = 80;
763
+ const na = Math.max(min, Math.min(sizeA + sizeB - min, sizeA + delta));
764
+ node.sizes[i - 1] = (share * na) / (sizeA + sizeB);
765
+ node.sizes[i] = share - node.sizes[i - 1];
766
+ kids[i - 1].style.flex = `${node.sizes[i - 1]} 1 0`;
767
+ kids[i].style.flex = `${node.sizes[i]} 1 0`;
768
+ fitVisible();
769
+ };
770
+ const up = () => {
771
+ g.classList.remove("dragging");
772
+ g.removeEventListener("pointermove", move);
773
+ saveLayout();
774
+ fitVisible();
775
+ };
776
+ g.addEventListener("pointermove", move);
777
+ g.addEventListener("pointerup", up, { once: true });
778
+ });
779
+ g.addEventListener("dblclick", () => {
780
+ node.sizes = node.sizes.map(() => 1 / node.sizes.length);
781
+ renderLayout();
782
+ });
783
+ return g;
784
+ }
785
+
786
+ let fitTimer = 0;
787
+ /** Debounced by default for continuous resize (drag); pass true for a one-shot structural
788
+ * change (opening/closing/splitting a pane), where the 60ms coalescing delay just shows up
789
+ * as the terminal snapping to size after the layout already moved. */
790
+ function fitVisible(immediate = false) {
791
+ clearTimeout(fitTimer);
792
+ const run = () => requestAnimationFrame(() => {
793
+ for (const sid of leaves()) S.terms.get(sid)?.fit();
794
+ });
795
+ if (immediate) run();
796
+ else fitTimer = setTimeout(run, 60);
797
+ }
798
+
799
+ function buildPane(sid) {
800
+ const s = findSession(sid);
801
+ const pane = document.createElement("div");
802
+ pane.className = "pane";
803
+ pane.dataset.sid = sid;
804
+ pane.innerHTML = `<div class="pane-head" draggable="true" title="Drag to move or split">
805
+ ${icon("terminal")}
806
+ <span class="title">${esc(s ? sessionTitle(s) : sid)}<span class="cmd${S.terms.get(sid)?.exit ? " fail" : ""}" title="${esc(S.terms.get(sid)?.cmd || "")}">${esc(shortCmd(S.terms.get(sid)?.cmd))}</span><small>${esc(s?.project.name || "")}</small></span>
807
+ <span class="ports">${portChips(sid)}</span>
808
+ <button class="icon-btn sm" data-pane="max" title="Maximize (Ctrl+Alt+Enter)" aria-label="Maximize">${icon($("#app").classList.contains("maximized") ? "minimize" : "maximize")}</button>
809
+ <button class="icon-btn sm" data-pane="menu" title="More" aria-label="More">${icon("more")}</button>
810
+ <button class="icon-btn sm danger" data-pane="kill" title="Close terminal (Ctrl+Alt+Q)" aria-label="Close terminal">${icon("x")}</button>
811
+ </div><div class="pane-body"></div>`;
812
+ const head = pane.firstElementChild;
813
+ head.addEventListener("click", (e) => {
814
+ const port = e.target.closest("[data-port]")?.dataset.port;
815
+ if (port) return window.open(`http://localhost:${port}`, "_blank", "noopener");
816
+ const b = e.target.closest("[data-pane]");
817
+ setFocus(sid);
818
+ if (!b) return;
819
+ if (b.dataset.pane === "max") toggleMaximize();
820
+ if (b.dataset.pane === "kill") killSession(sid);
821
+ if (b.dataset.pane === "menu") paneMenu(b, sid);
822
+ });
823
+ head.addEventListener("dblclick", (e) => {
824
+ if (!e.target.closest("button")) toggleMaximize();
825
+ });
826
+ head.addEventListener("dragstart", (e) => {
827
+ e.dataTransfer.setData("application/x-shelldeck-session", sid);
828
+ e.dataTransfer.effectAllowed = "move";
829
+ });
830
+ pane.addEventListener("mousedown", () => setFocus(sid));
831
+ paintPane(pane, sid);
832
+ if (!isFree()) wireDrop(pane, sid);
833
+ const term = getTerm(sid);
834
+ term.mount(pane.querySelector(".pane-body"));
835
+ new ResizeObserver(() => fitVisible()).observe(pane.querySelector(".pane-body"));
836
+ return pane;
837
+ }
838
+
839
+ // ---------------------------------------------------------- free layout
840
+
841
+ let topZ = 1;
842
+
843
+ function buildFree() {
844
+ const canvas = document.createElement("div");
845
+ canvas.className = "free-canvas";
846
+ let maxX = 0;
847
+ let maxY = 0;
848
+ for (const [sid, r] of Object.entries(S.free)) {
849
+ topZ = Math.max(topZ, r.z || 1);
850
+ const pane = buildPane(sid);
851
+ pane.classList.add("floating");
852
+ place(pane, r);
853
+ wireFloating(pane, sid);
854
+ canvas.append(pane);
855
+ maxX = Math.max(maxX, r.x + r.w);
856
+ maxY = Math.max(maxY, r.y + r.h);
857
+ }
858
+ const spacer = document.createElement("div");
859
+ spacer.className = "free-spacer";
860
+ spacer.style.left = `${maxX + 8}px`;
861
+ spacer.style.top = `${maxY + 8}px`;
862
+ canvas.append(spacer);
863
+ return canvas;
864
+ }
865
+
866
+ function place(pane, r) {
867
+ Object.assign(pane.style, { left: `${r.x}px`, top: `${r.y}px`, width: `${r.w}px`, height: `${r.h}px`, zIndex: r.z || 1 });
868
+ }
869
+
870
+ function raise(sid) {
871
+ const r = S.free[sid];
872
+ if (!r) return;
873
+ r.z = ++topZ;
874
+ const pane = $(`.pane.floating[data-sid="${sid}"]`);
875
+ if (pane) pane.style.zIndex = r.z;
876
+ }
877
+
878
+ /** Pointer-drag the title to move, the corner grip to resize. */
879
+ function wireFloating(pane, sid) {
880
+ const head = pane.querySelector(".pane-head");
881
+ head.draggable = false;
882
+ const grip = document.createElement("div");
883
+ grip.className = "grip";
884
+ grip.title = "Drag to resize";
885
+ pane.append(grip);
886
+ const drag = (el, apply) =>
887
+ el.addEventListener("pointerdown", (e) => {
888
+ if (e.button !== 0 || e.target.closest("button")) return;
889
+ e.preventDefault();
890
+ el.setPointerCapture(e.pointerId);
891
+ raise(sid);
892
+ const r = S.free[sid];
893
+ const start = { x: e.clientX, y: e.clientY, r: { ...r } };
894
+ let frame = 0;
895
+ pane.classList.add("dragging");
896
+ const move = (ev) => {
897
+ apply(r, start.r, ev.clientX - start.x, ev.clientY - start.y);
898
+ cancelAnimationFrame(frame);
899
+ frame = requestAnimationFrame(() => {
900
+ if (r.w === start.r.w && r.h === start.r.h) {
901
+ pane.style.transform = `translate(${r.x - start.r.x}px, ${r.y - start.r.y}px)`;
902
+ } else place(pane, r);
903
+ });
904
+ };
905
+ el.addEventListener("pointermove", move);
906
+ el.addEventListener(
907
+ "pointerup",
908
+ () => {
909
+ el.removeEventListener("pointermove", move);
910
+ cancelAnimationFrame(frame);
911
+ pane.classList.remove("dragging");
912
+ pane.style.transform = "";
913
+ saveLayout();
914
+ renderLayout();
915
+ S.terms.get(sid)?.focus();
916
+ },
917
+ { once: true },
918
+ );
919
+ });
920
+ drag(head, (r, o, dx, dy) => {
921
+ r.x = Math.max(0, o.x + dx);
922
+ r.y = Math.max(0, o.y + dy);
923
+ });
924
+ drag(grip, (r, o, dx, dy) => {
925
+ r.w = Math.max(280, o.w + dx);
926
+ r.h = Math.max(160, o.h + dy);
927
+ });
928
+ }
929
+
930
+ function addFree(sid) {
931
+ const box = $("#layout").getBoundingClientRect();
932
+ const n = Object.keys(S.free).length;
933
+ const w = Math.round(Math.min(760, Math.max(360, box.width * 0.55)));
934
+ const h = Math.round(Math.min(460, Math.max(220, box.height * 0.55)));
935
+ const step = 30;
936
+ S.free[sid] = {
937
+ x: 12 + ((n * step) % Math.max(step, box.width - w - 24)),
938
+ y: 12 + ((n * step) % Math.max(step, box.height - h - 24)),
939
+ w,
940
+ h,
941
+ z: ++topZ,
942
+ };
943
+ }
944
+
945
+ /** Arrange free windows in a grid; the canvas scrolls when they don't fit. */
946
+ export function tileAll(render = true) {
947
+ const sids = Object.keys(S.free);
948
+ if (!sids.length) return;
949
+ const box = $("#layout").getBoundingClientRect();
950
+ const gap = 10;
951
+ const W = Math.max(400, box.width - gap);
952
+ const H = Math.max(300, box.height - gap);
953
+ const cols = Math.max(1, Math.min(Math.ceil(Math.sqrt(sids.length)), Math.floor(W / 360)));
954
+ const rows = Math.ceil(sids.length / cols);
955
+ const w = Math.floor(W / cols) - gap;
956
+ const h = Math.max(240, Math.floor(H / rows) - gap);
957
+ sids.forEach((sid, i) => {
958
+ S.free[sid] = { x: gap + (i % cols) * (w + gap), y: gap + Math.floor(i / cols) * (h + gap), w, h, z: 1 };
959
+ });
960
+ if (render) renderLayout();
961
+ }
962
+
963
+ function paneMenu(anchor, sid) {
964
+ menu(anchor, [
965
+ { label: "Rename", icon: "pencil", onClick: () => renameSession(sid) },
966
+ { label: "Split right", icon: "split-h", hint: "Ctrl+Alt+\\", onClick: () => splitNew("row", sid) },
967
+ { label: "Split down", icon: "split-v", hint: "Ctrl+Alt+-", onClick: () => splitNew("col", sid) },
968
+ { label: "Hide pane (keep running)", icon: "eye", onClick: () => hidePane(sid) },
969
+ { label: "Paste shared clipboard", icon: "clipboard", onClick: () => S.terms.get(sid)?.send({ type: "clipboard_get" }) },
970
+ { label: "Copy selection to shared clipboard", icon: "clipboard", onClick: () => copyToShared(sid) },
971
+ "sep",
972
+ { label: "Close terminal", icon: "trash", danger: true, onClick: () => killSession(sid) },
973
+ ]);
974
+ }
975
+
976
+ function copyToShared(sid) {
977
+ const t = S.terms.get(sid);
978
+ const text = t?.xterm.getSelection() || "";
979
+ if (!text) return toast({ title: "Select text in the terminal first" });
980
+ t.send({ type: "clipboard_set", data: text });
981
+ toast({ title: "Copied to shared clipboard" });
982
+ }
983
+
984
+ // Drag a pane head or a sidebar session onto a pane: edges split, centre swaps.
985
+ function wireDrop(pane, sid) {
986
+ let hint = null;
987
+ const zoneOf = (e) => {
988
+ const r = pane.getBoundingClientRect();
989
+ const x = (e.clientX - r.left) / r.width;
990
+ const y = (e.clientY - r.top) / r.height;
991
+ const d = { left: x, right: 1 - x, top: y, bottom: 1 - y };
992
+ const [side, dist] = Object.entries(d).sort((a, b) => a[1] - b[1])[0];
993
+ return dist < 0.28 ? side : "center";
994
+ };
995
+ const clear = () => {
996
+ hint?.remove();
997
+ hint = null;
998
+ };
999
+ pane.addEventListener("dragover", (e) => {
1000
+ if (!e.dataTransfer.types.includes("application/x-shelldeck-session")) return;
1001
+ e.preventDefault();
1002
+ const zone = zoneOf(e);
1003
+ if (!hint) {
1004
+ hint = document.createElement("div");
1005
+ hint.className = "drop-hint";
1006
+ pane.append(hint);
1007
+ }
1008
+ const pos = { left: "0 50% 0 0", right: "0 0 0 50%", top: "0 0 50% 0", bottom: "50% 0 0 0", center: "0 0 0 0" }[zone];
1009
+ hint.style.inset = pos;
1010
+ });
1011
+ pane.addEventListener("dragleave", (e) => {
1012
+ if (!pane.contains(e.relatedTarget)) clear();
1013
+ });
1014
+ pane.addEventListener("drop", (e) => {
1015
+ const src = e.dataTransfer.getData("application/x-shelldeck-session");
1016
+ const zone = zoneOf(e);
1017
+ clear();
1018
+ if (!src || src === sid) return;
1019
+ e.preventDefault();
1020
+ const inLayout = leaves().includes(src);
1021
+ if (zone === "center") {
1022
+ if (inLayout) {
1023
+ S.layout = mapTree(S.layout, (l) => (l.sid === sid ? { sid: src } : l.sid === src ? { sid } : l));
1024
+ } else replaceLeaf(sid, src);
1025
+ } else {
1026
+ if (inLayout) removeLeaf(src);
1027
+ splitAt(sid, src, zone);
1028
+ }
1029
+ S.focused = src;
1030
+ renderLayout();
1031
+ S.terms.get(src)?.focus();
1032
+ });
1033
+ }
1034
+
1035
+ function markFocus() {
1036
+ for (const p of $$(".pane")) p.classList.toggle("focused", p.dataset.sid === S.focused);
1037
+ renderCrumbs();
1038
+ renderSidebarActive();
1039
+ }
1040
+
1041
+ export function setFocus(sid) {
1042
+ if (isFree()) raise(sid);
1043
+ if (S.focused === sid) return;
1044
+ S.focused = sid;
1045
+ markFocus();
1046
+ saveLayout();
1047
+ }
1048
+
1049
+ function toggleMaximize(force) {
1050
+ const app = $("#app");
1051
+ app.classList.toggle("maximized", force);
1052
+ renderLayout();
1053
+ S.terms.get(S.focused)?.focus();
1054
+ }
1055
+
1056
+ /** Show a session: focus if visible, else open it in the focused pane (or split with `split`). */
1057
+ export function showSession(sid, { split = null } = {}) {
1058
+ switchView("terminals");
1059
+ if (mobile.matches) $("#app").classList.remove("sb-mobile-open");
1060
+ if (isFree()) {
1061
+ if (!S.free[sid]) addFree(sid);
1062
+ raise(sid);
1063
+ } else if (leaves().includes(sid)) {
1064
+ S.focused = sid;
1065
+ } else if (!S.layout) {
1066
+ S.layout = { sid };
1067
+ } else if (split) {
1068
+ splitAt(S.focused || leaves()[0], sid, split === "row" ? "right" : "bottom");
1069
+ } else {
1070
+ replaceLeaf(S.focused || leaves()[0], sid);
1071
+ }
1072
+ S.focused = sid;
1073
+ renderLayout();
1074
+ const t = S.terms.get(sid);
1075
+ setTimeout(() => t?.focus(), 0);
1076
+ return t;
1077
+ }
1078
+
1079
+ function autoSplitDir() {
1080
+ // Split the largest pane on its long side; panes have min sizes, so #layout scrolls past that.
1081
+ if (mobile.matches) return null;
1082
+ let best = null;
1083
+ for (const p of $$("#layout .pane[data-sid]")) {
1084
+ const r = p.getBoundingClientRect();
1085
+ if (!best || r.width * r.height > best.area) best = { sid: p.dataset.sid, area: r.width * r.height, r };
1086
+ }
1087
+ if (!best) return null;
1088
+ S.focused = best.sid;
1089
+ // only split sideways while both halves stay wide; otherwise stack (vertical scroll only)
1090
+ return best.r.width >= 760 && best.r.width >= best.r.height * 1.3 ? "row" : "col";
1091
+ }
1092
+
1093
+ export async function newTerminal(projectId, shell = "", { split } = {}) {
1094
+ const project = S.projects.find((p) => p.id === projectId) || focusedProject();
1095
+ if (!project) return views.addProjectDialog();
1096
+ try {
1097
+ const s = await api("/api/sessions", { method: "POST", body: { project_id: project.id, shell } });
1098
+ await refreshProjects();
1099
+ S.collapsed.delete(project.id);
1100
+ return showSession(s.id, { split: split === undefined ? autoSplitDir() : split });
1101
+ } catch (e) {
1102
+ toastError(e);
1103
+ }
1104
+ }
1105
+
1106
+ function splitNew(dir, sid = S.focused) {
1107
+ const s = sid && findSession(sid);
1108
+ if (sid) S.focused = sid;
1109
+ return newTerminal(s?.project.id, s?.shell || "", { split: dir });
1110
+ }
1111
+
1112
+ function hidePane(sid) {
1113
+ removeLeaf(sid);
1114
+ renderLayout();
1115
+ }
1116
+
1117
+ async function killSession(sid) {
1118
+ const t = S.terms.get(sid);
1119
+ if (t) t.dispose();
1120
+ removeSessionLocally(sid);
1121
+ try {
1122
+ await api(`/api/sessions/${sid}`, { method: "DELETE" });
1123
+ } catch (e) {
1124
+ toastError(e);
1125
+ }
1126
+ refreshProjects();
1127
+ }
1128
+
1129
+ function removeSessionLocally(sid) {
1130
+ removeLeaf(sid);
1131
+ for (const p of S.projects) p.sessions = p.sessions.filter((s) => s.id !== sid);
1132
+ renderLayout();
1133
+ renderSidebar();
1134
+ S.terms.get(S.focused)?.focus();
1135
+ }
1136
+
1137
+ async function renameSession(sid) {
1138
+ const s = findSession(sid);
1139
+ const name = await promptDialog("Rename terminal", s ? sessionTitle(s) : "");
1140
+ if (!name) return;
1141
+ try {
1142
+ await api(`/api/sessions/${sid}`, { method: "PATCH", body: { name } });
1143
+ await refreshProjects();
1144
+ renderLayout();
1145
+ } catch (e) {
1146
+ toastError(e);
1147
+ }
1148
+ }
1149
+
1150
+ function focusDirection(dir) {
1151
+ const cur = $(`.pane[data-sid="${S.focused}"]`);
1152
+ if (!cur) return;
1153
+ const a = cur.getBoundingClientRect();
1154
+ const ac = { x: a.left + a.width / 2, y: a.top + a.height / 2 };
1155
+ let best = null;
1156
+ for (const p of $$(".pane")) {
1157
+ if (p === cur) continue;
1158
+ const b = p.getBoundingClientRect();
1159
+ const bc = { x: b.left + b.width / 2, y: b.top + b.height / 2 };
1160
+ const dx = bc.x - ac.x;
1161
+ const dy = bc.y - ac.y;
1162
+ const ok = { left: dx < -1, right: dx > 1, up: dy < -1, down: dy > 1 }[dir];
1163
+ if (!ok) continue;
1164
+ const dist = Math.hypot(dx, dy);
1165
+ if (!best || dist < best.dist) best = { sid: p.dataset.sid, dist };
1166
+ }
1167
+ if (best) {
1168
+ setFocus(best.sid);
1169
+ S.terms.get(best.sid)?.focus();
1170
+ }
1171
+ }
1172
+
1173
+ // ---------------------------------------------------------------- sidebar
1174
+
1175
+ export function orderedProjects() {
1176
+ const idx = (id) => {
1177
+ const i = S.order.indexOf(id);
1178
+ return i < 0 ? Infinity : i;
1179
+ };
1180
+ return [...S.projects].sort((a, b) => idx(a.id) - idx(b.id) || a.name.localeCompare(b.name));
1181
+ }
1182
+
1183
+ export async function refreshProjects() {
1184
+ try {
1185
+ const { projects } = await api("/api/projects");
1186
+ S.projects = projects;
1187
+ setOnline(true);
1188
+ } catch (e) {
1189
+ if (e.message !== "locked") setOnline(false);
1190
+ return;
1191
+ }
1192
+ // drop panes whose sessions are gone
1193
+ const known = new Set(allSessions().map((s) => s.id));
1194
+ const before = leaves().length;
1195
+ S.layout = mapTree(S.layout, (l) => (known.has(l.sid) ? l : null));
1196
+ for (const sid of Object.keys(S.free)) if (!known.has(sid)) delete S.free[sid];
1197
+ for (const [sid, t] of S.terms) if (!known.has(sid)) t.dispose();
1198
+ if (leaves().length !== before) renderLayout();
1199
+ renderSidebar();
1200
+ renderCrumbs();
1201
+ }
1202
+
1203
+ function setOnline(online) {
1204
+ S.online = online;
1205
+ const c = $("#conn");
1206
+ c.classList.toggle("down", !online);
1207
+ c.title = online ? "Connected to server" : "Server unreachable";
1208
+ }
1209
+
1210
+ const seenRows = new Set();
1211
+ const seenPanes = new Set();
1212
+ let firstSidebar = true;
1213
+
1214
+ export function renderSidebar() {
1215
+ const nav = $("#projects");
1216
+ if (!firstSidebar) nav.classList.add("settled");
1217
+ const visible = new Set(leaves());
1218
+ const projects = orderedProjects();
1219
+ if (!projects.length) {
1220
+ nav.innerHTML = `<div class="sb-empty">No projects yet. Add a folder to get started.</div>`;
1221
+ return;
1222
+ }
1223
+ nav.innerHTML = projects
1224
+ .map(
1225
+ (p, i) => `<div style="--i:${i}" class="proj ${S.collapsed.has(p.id) ? "collapsed" : ""} ${p.exists ? "" : "missing"}" data-pid="${p.id}">
1226
+ <div class="proj-row" draggable="true" data-act="toggle" title="${esc(p.path)}">
1227
+ <span class="chev">${icon("chevron")}</span>
1228
+ <span class="proj-dot" style="background:${projectColor(p)}"></span>
1229
+ <span class="name">${esc(p.name)}</span>
1230
+ <span class="count">${p.sessions.length || ""}</span>
1231
+ <span class="row-actions">
1232
+ ${p.has_git ? `<button class="icon-btn sm" data-act="git" title="Git graph" aria-label="Git graph">${icon("git-branch")}</button>` : ""}
1233
+ <button class="icon-btn sm" data-act="new" title="New ${esc(shellLabel(""))} terminal" aria-label="New terminal">${icon("plus")}</button>
1234
+ <button class="icon-btn sm" data-act="menu" title="More" aria-label="Project actions">${icon("more")}</button>
1235
+ </span>
1236
+ </div>
1237
+ <div class="sessions"><div class="sessions-inner">
1238
+ ${p.sessions
1239
+ .map(
1240
+ (s) => `<div class="sess-row ${S.unread.has(s.id) ? "unread" : ""} ${visible.has(s.id) ? "visible" : ""} ${s.id === S.focused && S.view === "terminals" ? "active" : ""}" data-sid="${s.id}" draggable="true" data-act="open" title="${esc(s.cwd || "")}">
1241
+ <span class="dot ${s.alive ? "alive" : ""}"></span>
1242
+ <span class="name">${esc(sessionTitle(s))}</span>
1243
+ <span class="badge">${esc(shellLabel(s.shell))}</span>
1244
+ <span class="row-actions">
1245
+ <button class="icon-btn sm" data-act="smenu" title="More" aria-label="Terminal actions">${icon("more")}</button>
1246
+ <button class="icon-btn sm danger" data-act="kill" title="Close terminal" aria-label="Close terminal">${icon("x")}</button>
1247
+ </span>
1248
+ </div>`,
1249
+ )
1250
+ .join("")}
1251
+ </div></div>
1252
+ </div>`,
1253
+ )
1254
+ .join("");
1255
+ markSeen();
1256
+ }
1257
+
1258
+ function markSeen() {
1259
+ for (const p of S.projects) for (const s of p.sessions) seenRows.add(s.id);
1260
+ firstSidebar = false;
1261
+ }
1262
+
1263
+ function renderSidebarActive() {
1264
+ const visible = new Set(leaves());
1265
+ for (const r of $$(".sess-row")) {
1266
+ r.classList.toggle("active", r.dataset.sid === S.focused && S.view === "terminals");
1267
+ r.classList.toggle("visible", visible.has(r.dataset.sid));
1268
+ }
1269
+ }
1270
+
1271
+ function projectMenu(anchor, p) {
1272
+ const shellItems = S.shells.available.map((k) => ({
1273
+ label: `New ${shellLabel(k)} terminal`,
1274
+ icon: "terminal",
1275
+ hint: k === S.settings.default_shell ? "default" : "",
1276
+ onClick: () => newTerminal(p.id, k),
1277
+ }));
1278
+ menu(anchor, [
1279
+ ...shellItems,
1280
+ "sep",
1281
+ { label: "Rename project", icon: "pencil", onClick: () => renameProject(p) },
1282
+ { label: "Copy path", icon: "clipboard", onClick: () => navigator.clipboard.writeText(p.path) },
1283
+ "sep",
1284
+ { label: "Remove project", icon: "trash", danger: true, onClick: () => removeProject(p) },
1285
+ ]);
1286
+ }
1287
+
1288
+ async function renameProject(p) {
1289
+ const name = await promptDialog("Rename project", p.name);
1290
+ if (!name) return;
1291
+ try {
1292
+ await api(`/api/projects/${p.id}`, { method: "PATCH", body: { name } });
1293
+ refreshProjects();
1294
+ } catch (e) {
1295
+ toastError(e);
1296
+ }
1297
+ }
1298
+
1299
+ async function removeProject(p) {
1300
+ const n = p.sessions.length;
1301
+ const ok = await confirmDialog(`Remove "${p.name}" from shelldeck?${n ? ` Its ${n} terminal${n > 1 ? "s" : ""} will be closed.` : ""} Files on disk are not touched.`, { ok: "Remove", danger: true });
1302
+ if (!ok) return;
1303
+ for (const s of p.sessions) S.terms.get(s.id)?.dispose();
1304
+ for (const s of p.sessions) removeLeaf(s.id);
1305
+ try {
1306
+ await api(`/api/projects/${p.id}`, { method: "DELETE" });
1307
+ } catch (e) {
1308
+ toastError(e);
1309
+ }
1310
+ renderLayout();
1311
+ refreshProjects();
1312
+ }
1313
+
1314
+ function wireSidebar() {
1315
+ const nav = $("#projects");
1316
+ nav.addEventListener("click", (e) => {
1317
+ const act = e.target.closest("[data-act]");
1318
+ if (!act) return;
1319
+ const pid = e.target.closest(".proj")?.dataset.pid;
1320
+ const sid = e.target.closest(".sess-row")?.dataset.sid;
1321
+ const p = S.projects.find((x) => x.id === pid);
1322
+ switch (act.dataset.act) {
1323
+ case "toggle":
1324
+ S.collapsed.has(pid) ? S.collapsed.delete(pid) : S.collapsed.add(pid);
1325
+ store.set("collapsed", [...S.collapsed]);
1326
+ act.closest(".proj").classList.toggle("collapsed");
1327
+ break;
1328
+ case "new":
1329
+ e.stopPropagation();
1330
+ newTerminal(pid);
1331
+ break;
1332
+ case "git":
1333
+ e.stopPropagation();
1334
+ showGitGraph(p);
1335
+ break;
1336
+ case "menu":
1337
+ e.stopPropagation();
1338
+ projectMenu(act, p);
1339
+ break;
1340
+ case "open":
1341
+ showSession(sid, { split: e.ctrlKey || e.metaKey ? "row" : null });
1342
+ break;
1343
+ case "smenu":
1344
+ e.stopPropagation();
1345
+ menu(act, [
1346
+ { label: "Open in split", icon: "split-h", onClick: () => showSession(sid, { split: "row" }) },
1347
+ { label: "Rename", icon: "pencil", onClick: () => renameSession(sid) },
1348
+ "sep",
1349
+ { label: "Close terminal", icon: "trash", danger: true, onClick: () => killSession(sid) },
1350
+ ]);
1351
+ break;
1352
+ case "kill":
1353
+ e.stopPropagation();
1354
+ killSession(sid);
1355
+ break;
1356
+ }
1357
+ });
1358
+ nav.addEventListener("auxclick", (e) => {
1359
+ const sid = e.target.closest(".sess-row")?.dataset.sid;
1360
+ if (e.button === 1 && sid) showSession(sid, { split: "row" });
1361
+ });
1362
+ nav.addEventListener("contextmenu", (e) => {
1363
+ const sid = e.target.closest(".sess-row")?.dataset.sid;
1364
+ const pid = e.target.closest(".proj")?.dataset.pid;
1365
+ if (!pid) return;
1366
+ e.preventDefault();
1367
+ if (sid) {
1368
+ menu({ x: e.clientX, y: e.clientY }, [
1369
+ { label: "Open in split", icon: "split-h", onClick: () => showSession(sid, { split: "row" }) },
1370
+ { label: "Rename", icon: "pencil", onClick: () => renameSession(sid) },
1371
+ "sep",
1372
+ { label: "Close terminal", icon: "trash", danger: true, onClick: () => killSession(sid) },
1373
+ ]);
1374
+ } else projectMenu({ x: e.clientX, y: e.clientY }, S.projects.find((x) => x.id === pid));
1375
+ });
1376
+
1377
+ // drag: sessions onto panes, projects to reorder
1378
+ let dragPid = null;
1379
+ nav.addEventListener("dragstart", (e) => {
1380
+ const sid = e.target.closest(".sess-row")?.dataset.sid;
1381
+ if (sid) {
1382
+ e.dataTransfer.setData("application/x-shelldeck-session", sid);
1383
+ e.dataTransfer.effectAllowed = "move";
1384
+ return;
1385
+ }
1386
+ dragPid = e.target.closest(".proj")?.dataset.pid;
1387
+ e.dataTransfer.setData("text/plain", dragPid || "");
1388
+ e.dataTransfer.effectAllowed = "move";
1389
+ });
1390
+ nav.addEventListener("dragover", (e) => {
1391
+ if (!dragPid) return;
1392
+ e.preventDefault();
1393
+ $$(".proj", nav).forEach((p) => p.classList.remove("drop-before"));
1394
+ e.target.closest(".proj")?.classList.add("drop-before");
1395
+ });
1396
+ nav.addEventListener("drop", (e) => {
1397
+ if (!dragPid) return;
1398
+ e.preventDefault();
1399
+ const target = e.target.closest(".proj")?.dataset.pid;
1400
+ const ids = orderedProjects().map((p) => p.id).filter((id) => id !== dragPid);
1401
+ const at = target ? ids.indexOf(target) : ids.length;
1402
+ ids.splice(at < 0 ? ids.length : at, 0, dragPid);
1403
+ S.order = ids;
1404
+ store.set("order", ids);
1405
+ renderSidebar();
1406
+ });
1407
+ nav.addEventListener("dragend", () => {
1408
+ dragPid = null;
1409
+ $$(".proj", nav).forEach((p) => p.classList.remove("drop-before"));
1410
+ });
1411
+
1412
+ // resizable sidebar
1413
+ const handle = $(".sb-resize");
1414
+ const app = $("#app");
1415
+ const w = store.get("sidebarWidth", 264);
1416
+ app.style.setProperty("--sb-w", `${w}px`);
1417
+ if (store.get("sidebarHidden", false)) app.classList.add("sb-hidden");
1418
+ handle.addEventListener("pointerdown", (e) => {
1419
+ e.preventDefault();
1420
+ handle.setPointerCapture(e.pointerId);
1421
+ handle.classList.add("dragging");
1422
+ const move = (ev) => {
1423
+ const width = Math.max(200, Math.min(480, ev.clientX));
1424
+ app.style.setProperty("--sb-w", `${width}px`);
1425
+ fitVisible();
1426
+ };
1427
+ handle.addEventListener("pointermove", move);
1428
+ handle.addEventListener(
1429
+ "pointerup",
1430
+ () => {
1431
+ handle.classList.remove("dragging");
1432
+ handle.removeEventListener("pointermove", move);
1433
+ store.set("sidebarWidth", parseInt(getComputedStyle(app).getPropertyValue("--sb-w"), 10));
1434
+ },
1435
+ { once: true },
1436
+ );
1437
+ });
1438
+ }
1439
+
1440
+ function toggleSidebar() {
1441
+ const app = $("#app");
1442
+ if (mobile.matches) {
1443
+ app.classList.toggle("sb-mobile-open");
1444
+ return;
1445
+ }
1446
+ app.classList.toggle("sb-hidden");
1447
+ store.set("sidebarHidden", app.classList.contains("sb-hidden"));
1448
+ fitVisible();
1449
+ }
1450
+
1451
+ // ------------------------------------------------------------- top bar/views
1452
+
1453
+ const VIEW_TITLES = { bookmarks: "Bookmarks", scheduler: "Scheduler", tasks: "Tasks", monitor: "Task manager", history: "Command history" };
1454
+
1455
+ export function switchView(view) {
1456
+ if (S.view === view) return;
1457
+ S.view = view;
1458
+ for (const v of ["terminals", "bookmarks", "scheduler", "tasks", "monitor", "history"]) $(`#view-${v}`).hidden = v !== view;
1459
+ for (const b of $$(".sb-link[data-view]")) b.classList.toggle("active", b.dataset.view === view && view !== "terminals");
1460
+ $("#term-actions").hidden = view !== "terminals";
1461
+ if (mobile.matches) $("#app").classList.remove("sb-mobile-open");
1462
+ if (view === "terminals") {
1463
+ clearUnread();
1464
+ fitVisible();
1465
+ S.terms.get(S.focused)?.focus();
1466
+ } else views.show(view, $(`#view-${view}`));
1467
+ renderCrumbs();
1468
+ renderSidebarActive();
1469
+ }
1470
+
1471
+ function renderCrumbs() {
1472
+ const c = $("#crumbs");
1473
+ if (S.view !== "terminals") {
1474
+ c.innerHTML = `<b>${esc(VIEW_TITLES[S.view])}</b>`;
1475
+ return;
1476
+ }
1477
+ const s = S.focused && findSession(S.focused);
1478
+ c.innerHTML = s
1479
+ ? `<span>${esc(s.project.name)}</span><span class="sep">/</span><b>${esc(sessionTitle(s))}</b><span class="path">${esc(s.cwd || s.project.path)}</span>`
1480
+ : `<b>Terminals</b>`;
1481
+ }
1482
+
1483
+ // --------------------------------------------------------------- palette
1484
+
1485
+ function fuzzy(q, text) {
1486
+ if (!q) return 1;
1487
+ text = text.toLowerCase();
1488
+ let score = 0;
1489
+ let pos = 0;
1490
+ for (const ch of q.toLowerCase()) {
1491
+ const i = text.indexOf(ch, pos);
1492
+ if (i < 0) return 0;
1493
+ score += i === pos ? 2 : 1;
1494
+ pos = i + 1;
1495
+ }
1496
+ return score + (text.includes(q.toLowerCase()) ? 10 : 0);
1497
+ }
1498
+
1499
+ /** Generic palette. items: {group, label, hint, icon, run(e)} ; multi: allow checking several. */
1500
+ export function palette({ placeholder = "Type a command or search…", items, multi = null, footer = "" }) {
1501
+ const d = dialog({
1502
+ cls: "palette",
1503
+ body: `<input type="search" placeholder="${esc(placeholder)}" autofocus aria-label="Search" /><div class="palette-list" role="listbox"></div>${footer ? `<div class="palette-foot">${footer}</div>` : ""}`,
1504
+ });
1505
+ d.el.querySelector(".dialog-body").style.padding = "0";
1506
+ const input = $("input", d.el);
1507
+ const list = $(".palette-list", d.el);
1508
+ const checked = new Set();
1509
+ let shown = [];
1510
+ let sel = 0;
1511
+ const render = () => {
1512
+ const q = input.value.trim();
1513
+ shown = items
1514
+ .map((it) => ({ it, score: fuzzy(q, `${it.label} ${it.hint || ""} ${it.group || ""}`) }))
1515
+ .filter((x) => x.score > 0)
1516
+ .sort((a, b) => (q ? b.score - a.score : 0))
1517
+ .map((x) => x.it);
1518
+ sel = Math.min(sel, Math.max(0, shown.length - 1));
1519
+ let group = null;
1520
+ list.innerHTML = shown.length
1521
+ ? shown
1522
+ .map((it, i) => {
1523
+ const head = !q && it.group && it.group !== group ? `<div class="palette-group">${esc((group = it.group))}</div>` : "";
1524
+ return `${head}<div class="palette-item ${i === sel ? "sel" : ""}" data-i="${i}" role="option">
1525
+ ${multi ? `<input type="checkbox" tabindex="-1" ${checked.has(it) ? "checked" : ""} />` : ""}
1526
+ ${icon(it.icon || "terminal")}<span class="label">${esc(it.label)}</span><span class="hint">${esc(it.hint || "")}</span></div>`;
1527
+ })
1528
+ .join("")
1529
+ : `<div class="placeholder">No matches</div>`;
1530
+ list.querySelector(".sel")?.scrollIntoView({ block: "nearest" });
1531
+ };
1532
+ const run = (e) => {
1533
+ const picked = multi && checked.size ? [...checked] : shown[sel] ? [shown[sel]] : [];
1534
+ if (!picked.length) return;
1535
+ d.close();
1536
+ if (multi) multi(picked, e);
1537
+ else picked[0].run(e);
1538
+ };
1539
+ input.addEventListener("input", () => {
1540
+ sel = 0;
1541
+ render();
1542
+ });
1543
+ input.addEventListener("keydown", (e) => {
1544
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1545
+ e.preventDefault();
1546
+ sel = (sel + (e.key === "ArrowDown" ? 1 : -1) + shown.length) % Math.max(1, shown.length);
1547
+ render();
1548
+ } else if (e.key === "Enter") {
1549
+ e.preventDefault();
1550
+ run(e);
1551
+ } else if (e.key === " " && multi && !input.value) {
1552
+ e.preventDefault();
1553
+ const it = shown[sel];
1554
+ if (it) checked.has(it) ? checked.delete(it) : checked.add(it);
1555
+ render();
1556
+ }
1557
+ });
1558
+ list.addEventListener("mousemove", (e) => {
1559
+ const el = e.target.closest(".palette-item");
1560
+ if (el && +el.dataset.i !== sel) {
1561
+ sel = +el.dataset.i;
1562
+ $$(".palette-item", list).forEach((x) => x.classList.toggle("sel", +x.dataset.i === sel));
1563
+ }
1564
+ });
1565
+ list.addEventListener("click", (e) => {
1566
+ const el = e.target.closest(".palette-item");
1567
+ if (!el) return;
1568
+ sel = +el.dataset.i;
1569
+ const it = shown[sel];
1570
+ if (multi && (e.ctrlKey || e.metaKey || e.target.type === "checkbox")) {
1571
+ checked.has(it) ? checked.delete(it) : checked.add(it);
1572
+ render();
1573
+ return;
1574
+ }
1575
+ run(e);
1576
+ });
1577
+ render();
1578
+ return d;
1579
+ }
1580
+
1581
+ function commandPalette() {
1582
+ const items = [];
1583
+ for (const s of allSessions()) {
1584
+ items.push({ group: "Terminals", label: sessionTitle(s), hint: s.project.name, icon: "terminal", run: () => showSession(s.id) });
1585
+ }
1586
+ for (const p of orderedProjects()) {
1587
+ items.push({ group: "New terminal", label: `New terminal in ${p.name}`, hint: shellLabel(""), icon: "plus", run: () => newTerminal(p.id) });
1588
+ }
1589
+ for (const b of views.bookmarkCache()) {
1590
+ items.push({ group: "Bookmarks", label: b.name, hint: b.command, icon: "bookmark", run: (e) => views.runBookmarks([b], { execute: e?.shiftKey }) });
1591
+ }
1592
+ const cmds = [
1593
+ ["Add project…", "folder-plus", () => views.addProjectDialog()],
1594
+ ["Split right", "split-h", () => splitNew("row"), "Ctrl+Alt+\\"],
1595
+ ["Split down", "split-v", () => splitNew("col"), "Ctrl+Alt+-"],
1596
+ ["Toggle maximize pane", "maximize", () => toggleMaximize(), "Ctrl+Alt+Enter"],
1597
+ ["Layout: tiled (fit window)", "split-h", () => views.saveSettings({ layout_mode: "tiled" })],
1598
+ ["Layout: free windows", "grid", () => views.saveSettings({ layout_mode: "free" })],
1599
+ ["Tile all windows", "grid", () => (isFree() ? tileAll() : toast({ title: "Tiling applies to the free layout" }))],
1600
+ ["Toggle sidebar", "panel", () => toggleSidebar(), "Ctrl+Alt+E"],
1601
+ ["Search all terminals", "search", () => searchAllDialog(), "Ctrl+Shift+F"],
1602
+ ["Command history", "history", () => switchView("history"), "Ctrl+Alt+R"],
1603
+ ["Task manager", "activity", () => switchView("monitor"), "Ctrl+Alt+M"],
1604
+ ["Open terminals", "terminal", () => switchView("terminals")],
1605
+ ["Open bookmarks", "bookmark", () => switchView("bookmarks")],
1606
+ ["Open scheduler", "clock", () => switchView("scheduler"), "Ctrl+Alt+S"],
1607
+ ["Open tasks", "kanban", () => switchView("tasks"), "Ctrl+Alt+T"],
1608
+ ["Settings", "settings", () => views.settingsDialog(), "Ctrl+Alt+,"],
1609
+ ["Toggle light/dark theme", "sun", () => views.saveSettings({ theme: resolvedTheme() === "dark" ? "light" : "dark" })],
1610
+ ["Keyboard shortcuts", "keyboard", () => shortcutsDialog(), "Ctrl+Alt+H"],
1611
+ ["Lock", "lock", () => lockNow(), "Ctrl+Alt+L"],
1612
+ ];
1613
+ for (const [label, ic, run, hint] of cmds) items.push({ group: "Commands", label, icon: ic, run, hint });
1614
+ palette({ items, footer: "↑↓ to navigate · Enter to open · Shift+Enter runs a bookmark · Esc to close" });
1615
+ }
1616
+
1617
+ function shortcutsDialog() {
1618
+ const rows = [
1619
+ ["Ctrl+Shift+P", "Command palette (Ctrl+K outside terminals)"],
1620
+ ["Ctrl+Alt+N", "New terminal in current project"],
1621
+ ["Ctrl+Alt+\\ / Ctrl+Alt+-", "Split right / down"],
1622
+ ["Ctrl+Alt+Arrows", "Focus pane in direction"],
1623
+ ["Ctrl+Alt+1…9", "Focus pane 1–9"],
1624
+ ["Ctrl+Alt+Enter", "Maximize / restore pane"],
1625
+ ["Ctrl+Alt+Q", "Close focused terminal"],
1626
+ ["Ctrl+Alt+B", "Bookmark picker (Space selects several)"],
1627
+ ["Ctrl+Alt+E", "Toggle sidebar"],
1628
+ ["Ctrl+Alt+S / Ctrl+Alt+T", "Scheduler / Tasks"],
1629
+ ["Ctrl+Alt+M", "Task manager (CPU, RAM, GPU per terminal)"],
1630
+ ["Ctrl+Alt+R", "Command history"],
1631
+ ["Ctrl+Shift+F", "Search all terminals"],
1632
+ ["Ctrl+Alt+,", "Settings"],
1633
+ ["Ctrl+Alt+L", "Lock"],
1634
+ ["Ctrl+C / Ctrl+V", "Copy selection / paste"],
1635
+ ["Ctrl+F", "Find in terminal (Enter / Shift+Enter: next / previous)"],
1636
+ ["Ctrl+Shift+Up / Down", "Jump to previous / next prompt"],
1637
+ ["Drag pane title", "Move pane; drop on an edge to split"],
1638
+ ["Ctrl+click terminal", "Open in split (sidebar)"],
1639
+ ];
1640
+ dialog({
1641
+ title: "Keyboard shortcuts",
1642
+ body: `<table style="width:100%;border-collapse:collapse">${rows
1643
+ .map(([k, v]) => `<tr><td style="padding:5px 12px 5px 0;white-space:nowrap"><kbd>${esc(k)}</kbd></td><td class="muted">${esc(v)}</td></tr>`)
1644
+ .join("")}</table>`,
1645
+ });
1646
+ }
1647
+
1648
+ // -------------------------------------------------------------- keyboard
1649
+
1650
+ function isAppShortcut(e) {
1651
+ const k = e.key.toLowerCase();
1652
+ if (e.ctrlKey && e.shiftKey && !e.altKey && (k === "p" || k === "f")) return true;
1653
+ if (e.ctrlKey && e.altKey && !e.shiftKey) return /^(n|b|e|s|t|m|r|l|h|q|,|\\|-|enter|arrow(left|right|up|down)|[1-9])$/.test(k);
1654
+ return false;
1655
+ }
1656
+
1657
+ function onKey(e) {
1658
+ if (!$("#lock").hidden) return;
1659
+ const k = e.key.toLowerCase();
1660
+ const inTerm = !!e.target.closest?.(".xterm");
1661
+ if (e.ctrlKey && !e.altKey && !e.shiftKey && k === "k" && !inTerm && !topDialog()) {
1662
+ e.preventDefault();
1663
+ return commandPalette();
1664
+ }
1665
+ if (!isAppShortcut(e) || topDialog()) return;
1666
+ e.preventDefault();
1667
+ e.stopPropagation();
1668
+ if (e.shiftKey && k === "p") return commandPalette();
1669
+ if (e.shiftKey && k === "f") return searchAllDialog();
1670
+ const map = {
1671
+ n: () => newTerminal(),
1672
+ b: () => views.bookmarkPicker(),
1673
+ e: () => toggleSidebar(),
1674
+ s: () => switchView("scheduler"),
1675
+ t: () => switchView("tasks"),
1676
+ m: () => switchView(S.view === "monitor" ? "terminals" : "monitor"),
1677
+ r: () => switchView(S.view === "history" ? "terminals" : "history"),
1678
+ l: () => lockNow(),
1679
+ h: () => shortcutsDialog(),
1680
+ q: () => S.focused && killSession(S.focused),
1681
+ ",": () => views.settingsDialog(),
1682
+ "\\": () => splitNew("row"),
1683
+ "-": () => splitNew("col"),
1684
+ enter: () => toggleMaximize(),
1685
+ arrowleft: () => focusDirection("left"),
1686
+ arrowright: () => focusDirection("right"),
1687
+ arrowup: () => focusDirection("up"),
1688
+ arrowdown: () => focusDirection("down"),
1689
+ };
1690
+ if (/^[1-9]$/.test(k)) {
1691
+ const sid = leaves()[+k - 1];
1692
+ if (sid) {
1693
+ switchView("terminals");
1694
+ setFocus(sid);
1695
+ S.terms.get(sid)?.focus();
1696
+ }
1697
+ return;
1698
+ }
1699
+ closeMenu();
1700
+ map[k]?.();
1701
+ }
1702
+
1703
+ // ------------------------------------------------------------- lock/alarms
1704
+
1705
+ async function lockNow() {
1706
+ try {
1707
+ await api("/api/auth/logout", { method: "POST" });
1708
+ } catch (e) {
1709
+ return toastError(e);
1710
+ }
1711
+ showAuth();
1712
+ }
1713
+
1714
+ let authMode = null; // "login" | "setup" while the lock screen is up
1715
+
1716
+ /** Lock screen: log in, or create the first password. */
1717
+ async function showAuth(status = null) {
1718
+ status ||= await api("/api/auth/status").catch(() => ({ has_password: true }));
1719
+ const mode = status.has_password ? "login" : "setup";
1720
+ if (!$("#lock").hidden && authMode === mode) return;
1721
+ authMode = mode;
1722
+ const form = $("#lock-form");
1723
+ form.innerHTML =
1724
+ mode === "setup"
1725
+ ? `<img src="/static/icon.svg" alt="" width="40" height="40" />
1726
+ <h2>Create a password</h2>
1727
+ <p class="muted">shelldeck needs a password before it opens. You'll use it to unlock the app in any browser, including over the network.</p>
1728
+ <input type="password" name="password" placeholder="New password (8+ characters)" autocomplete="new-password" minlength="${status.min_length || 8}" required />
1729
+ <input type="password" name="confirm" placeholder="Confirm password" autocomplete="new-password" required />
1730
+ ${status.setup_code_required ? `<input type="text" name="code" placeholder="Setup code from the host" autocomplete="off" spellcheck="false" required />
1731
+ <p class="faint small">You are connecting from another machine. The setup code is printed where shelldeck runs, and saved in its data folder as <code>setup-code</code>.</p>` : ""}
1732
+ <div class="error" id="lock-error"></div>
1733
+ <button class="btn primary" type="submit">Create password</button>`
1734
+ : `<img src="/static/icon.svg" alt="" width="40" height="40" />
1735
+ <h2>shelldeck is locked</h2>
1736
+ <input type="password" name="password" placeholder="Password" autocomplete="current-password" required />
1737
+ <div class="error" id="lock-error"></div>
1738
+ <button class="btn primary" type="submit">Unlock</button>
1739
+ <p class="faint small">Forgot it? On the machine running shelldeck, run <code>shelldeck login-link</code> or <code>shelldeck reset-password</code>.</p>`;
1740
+ withEyes(form);
1741
+ $("#lock").hidden = false;
1742
+ form.querySelector("input").focus();
1743
+ }
1744
+
1745
+ function wireLock() {
1746
+ bus.addEventListener("locked", () => showAuth());
1747
+ $("#lock-form").addEventListener("submit", async (e) => {
1748
+ e.preventDefault();
1749
+ const f = e.target;
1750
+ const btn = f.querySelector("button[type=submit]");
1751
+ btn.disabled = true;
1752
+ try {
1753
+ if (authMode === "setup") {
1754
+ await api("/api/auth/setup", { method: "POST", body: { password: f.password.value, confirm: f.confirm.value, code: f.code?.value || "" } });
1755
+ } else {
1756
+ await api("/api/auth/login", { method: "POST", body: { password: f.password.value } });
1757
+ }
1758
+ location.reload();
1759
+ } catch (err) {
1760
+ $("#lock-error").textContent = authError(err);
1761
+ btn.disabled = false;
1762
+ }
1763
+ });
1764
+ }
1765
+
1766
+ let alarmSocket = null;
1767
+ function connectAlarms() {
1768
+ const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws/alarms`);
1769
+ alarmSocket = ws;
1770
+ ws.onmessage = (ev) => {
1771
+ const msg = JSON.parse(ev.data);
1772
+ if (msg.type === "alarm") views.showAlarm(msg.task, true);
1773
+ if (msg.type === "alarm_snapshot") msg.alarms.forEach((t) => views.showAlarm(t, false));
1774
+ };
1775
+ ws.onclose = (ev) => {
1776
+ if (ev.code !== 1008) setTimeout(connectAlarms, 3000);
1777
+ };
1778
+ }
1779
+
1780
+ // ----------------------------------------------------------------- init
1781
+
1782
+ function wireGlobal() {
1783
+ document.addEventListener("keydown", onKey, true);
1784
+ document.addEventListener("click", (e) => {
1785
+ const a = e.target.closest("[data-action]");
1786
+ if (!a) return;
1787
+ const act = a.dataset.action;
1788
+ if (act === "toggle-sidebar") toggleSidebar();
1789
+ else if (act === "new-terminal") newTerminal();
1790
+ else if (act === "palette") commandPalette();
1791
+ else if (act === "add-project") views.addProjectDialog();
1792
+ else if (act === "view") switchView(S.view === a.dataset.view ? "terminals" : a.dataset.view);
1793
+ else if (act === "settings") views.settingsDialog();
1794
+ else if (act === "split") splitNew(a.dataset.dir);
1795
+ else if (act === "bookmark-picker") views.bookmarkPicker();
1796
+ else if (act === "tile") tileAll();
1797
+ });
1798
+ window.addEventListener("resize", () => fitVisible());
1799
+ const snapshotAll = () => S.terms.forEach((t) => t.snapshot());
1800
+ setInterval(() => S.terms.forEach((t) => t.heartbeat()), 15000);
1801
+ const reconnectAll = () => S.terms.forEach((t) => t.reconnectNow());
1802
+ window.addEventListener("online", reconnectAll);
1803
+ document.addEventListener("visibilitychange", () => !document.hidden && reconnectAll());
1804
+ setInterval(snapshotAll, 15000);
1805
+ document.addEventListener("visibilitychange", () => document.hidden && snapshotAll());
1806
+ window.addEventListener("pagehide", snapshotAll);
1807
+ mobile.addEventListener("change", () => {
1808
+ $("#app").classList.remove("sb-mobile-open");
1809
+ renderLayout();
1810
+ });
1811
+ }
1812
+
1813
+ const bootStart = performance.now();
1814
+
1815
+ /** Resolves when the boot animation has had its time; heavy DOM work waits for this. */
1816
+ function finishBoot() {
1817
+ const boot = $("#boot");
1818
+ if (!boot) return Promise.resolve();
1819
+ const wait = Math.max(0, 2000 - (performance.now() - bootStart));
1820
+ return new Promise((resolve) =>
1821
+ setTimeout(() => {
1822
+ boot.classList.add("done");
1823
+ setTimeout(() => boot.remove(), 600);
1824
+ requestAnimationFrame(() => resolve());
1825
+ }, wait),
1826
+ );
1827
+ }
1828
+
1829
+ async function init() {
1830
+ hydrateIcons();
1831
+ wireGlobal();
1832
+ wireSidebar();
1833
+ wireLock();
1834
+ views.init();
1835
+ const status = await api("/api/auth/status").catch(() => null);
1836
+ if (!status?.authenticated) {
1837
+ await finishBoot();
1838
+ return showAuth(status);
1839
+ }
1840
+ try {
1841
+ [S.settings, S.shells] = await Promise.all([api("/api/settings"), api("/api/shells")]);
1842
+ } catch (e) {
1843
+ toastError(e);
1844
+ }
1845
+ applySettings();
1846
+ await refreshProjects();
1847
+ startMonitor();
1848
+ await finishBoot();
1849
+ // `?session=<id>` from `shelldeck open`
1850
+ const want = new URLSearchParams(location.search).get("session");
1851
+ if (want && findSession(want)) {
1852
+ history.replaceState(null, "", "/");
1853
+ showSession(want, { split: S.layout && !leaves().includes(want) ? autoSplitDir() : null });
1854
+ } else {
1855
+ renderLayout();
1856
+ S.terms.get(S.focused)?.focus();
1857
+ }
1858
+ views.loadBookmarks();
1859
+ connectAlarms();
1860
+ setInterval(refreshProjects, 5000);
1861
+ }
1862
+
1863
+ init();