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.
- shelldeck/__init__.py +0 -0
- shelldeck/__main__.py +4 -0
- shelldeck/addons/__init__.py +1 -0
- shelldeck/addons/fast_context.py +1346 -0
- shelldeck/auth.py +207 -0
- shelldeck/cli.py +587 -0
- shelldeck/db.py +794 -0
- shelldeck/gitgraph.py +150 -0
- shelldeck/integration/bash.sh +27 -0
- shelldeck/integration/shelldeck.fish +5 -0
- shelldeck/integration/shelldeck.ps1 +35 -0
- shelldeck/integration/zsh/.zshenv +6 -0
- shelldeck/integration/zsh/.zshrc +10 -0
- shelldeck/pty.py +352 -0
- shelldeck/runner.py +75 -0
- shelldeck/scheduler.py +283 -0
- shelldeck/server.py +1216 -0
- shelldeck/shells.py +174 -0
- shelldeck/static/app.css +2288 -0
- shelldeck/static/app.js +1863 -0
- shelldeck/static/gitgraph.js +142 -0
- shelldeck/static/history.js +172 -0
- shelldeck/static/icon-192.png +0 -0
- shelldeck/static/icon-32.png +0 -0
- shelldeck/static/icon-512.png +0 -0
- shelldeck/static/icon.svg +1 -0
- shelldeck/static/index.html +102 -0
- shelldeck/static/manifest.webmanifest +1 -0
- shelldeck/static/monitor.js +143 -0
- shelldeck/static/ui.js +316 -0
- shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
- shelldeck/static/vendor/addon-fit.js +2 -0
- shelldeck/static/vendor/addon-search.js +2 -0
- shelldeck/static/vendor/addon-serialize.js +2 -0
- shelldeck/static/vendor/addon-web-links.js +2 -0
- shelldeck/static/vendor/addon-webgl.js +2 -0
- shelldeck/static/vendor/xterm.css +218 -0
- shelldeck/static/vendor/xterm.js +2 -0
- shelldeck/static/views.js +658 -0
- shelldeck/stats.py +128 -0
- shelldeck-0.0.1.dist-info/METADATA +254 -0
- shelldeck-0.0.1.dist-info/RECORD +45 -0
- shelldeck-0.0.1.dist-info/WHEEL +4 -0
- shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
- shelldeck-0.0.1.dist-info/licenses/LICENSE +21 -0
shelldeck/static/app.js
ADDED
|
@@ -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();
|