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,142 @@
1
+ import { $, $$, api, dialog, esc, fmtTime, icon, menu, toast, toastError } from "./ui.js";
2
+
3
+ const ROW_H = 28;
4
+ const LANE_W = 16;
5
+ const R = 4;
6
+
7
+ function laneX(col) {
8
+ return col * LANE_W + LANE_W / 2;
9
+ }
10
+
11
+ // A curve if the lane changes column, a straight line otherwise.
12
+ function path(x1, y1, x2, y2) {
13
+ if (x1 === x2) return `M${x1},${y1} L${x2},${y2}`;
14
+ const my = (y1 + y2) / 2;
15
+ return `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`;
16
+ }
17
+
18
+ function rowSvg(commit, lanes) {
19
+ const midY = ROW_H / 2;
20
+ const cx = laneX(commit.column);
21
+ const lines = [
22
+ ...commit.through.map((t) => `<path d="${path(laneX(t.col), 0, laneX(t.col), ROW_H)}" stroke="${t.color}" />`),
23
+ ...commit.enter.map((e) => `<path d="${path(laneX(e.from_col), 0, cx, midY)}" stroke="${e.color}" />`),
24
+ ...commit.exit.map((x) => `<path d="${path(cx, midY, laneX(x.to_col), ROW_H)}" stroke="${x.color}" />`),
25
+ ].join("");
26
+ return `<svg width="${lanes * LANE_W}" height="${ROW_H}" class="git-lanes">${lines}<circle cx="${cx}" cy="${midY}" r="${R}" fill="${commit.color}" /></svg>`;
27
+ }
28
+
29
+ function refPills(refs) {
30
+ return refs
31
+ .map((r) => {
32
+ const tag = r.startsWith("tag: ");
33
+ const name = r.replace("HEAD -> ", "").replace("tag: ", "");
34
+ return `<span class="git-ref ${tag ? "tag" : ""}" data-ref="${esc(name)}">${esc(name)}</span>`;
35
+ })
36
+ .join("");
37
+ }
38
+
39
+ function renderCommits(commits, lanes) {
40
+ return commits
41
+ .map(
42
+ (c) => `<div class="git-row" data-hash="${c.hash}">
43
+ ${rowSvg(c, lanes)}
44
+ <span class="git-hash mono">${c.short}</span>
45
+ <span class="git-subject" title="${esc(c.subject)}">${esc(c.subject)}</span>
46
+ <span class="git-refs">${refPills(c.refs)}</span>
47
+ <span class="git-author">${esc(c.author)}</span>
48
+ <span class="git-date faint">${fmtTime(c.date)}</span>
49
+ </div>`,
50
+ )
51
+ .join("");
52
+ }
53
+
54
+ const PAGE = 300;
55
+ const SCROLL_MARGIN = 120; // px from the bottom that triggers loading the next page
56
+
57
+ export function showGitGraph(project) {
58
+ let limit = PAGE;
59
+ let hasMore = false;
60
+ let loadingMore = false;
61
+ const d = dialog({ title: `Git \u2014 ${project.name}`, cls: "git-dialog", wide: true, body: `<div class="placeholder">Loading\u2026</div>` });
62
+ const body = $(".dialog-body", d.el);
63
+
64
+ async function load() {
65
+ limit = PAGE;
66
+ body.innerHTML = `<div class="placeholder">Loading\u2026</div>`;
67
+ try {
68
+ render(await api(`/api/projects/${project.id}/git/log?limit=${limit}`));
69
+ } catch (e) {
70
+ body.innerHTML = `<div class="placeholder">${esc(e.message.replaceAll("_", " "))}</div>`;
71
+ }
72
+ }
73
+
74
+ async function loadMore() {
75
+ if (loadingMore || !hasMore) return;
76
+ loadingMore = true;
77
+ limit += PAGE;
78
+ const scrollTop = $(".git-rows", body)?.scrollTop || 0;
79
+ try {
80
+ render(await api(`/api/projects/${project.id}/git/log?limit=${limit}`), scrollTop);
81
+ } catch (e) {
82
+ toastError(e);
83
+ } finally {
84
+ loadingMore = false;
85
+ }
86
+ }
87
+
88
+ function render(log, scrollTop = 0) {
89
+ hasMore = log.has_more;
90
+ if (!log.commits.length) {
91
+ body.innerHTML = `<div class="placeholder">No commits yet.</div>`;
92
+ return;
93
+ }
94
+ const lanes = 1 + Math.max(0, ...log.commits.flatMap((c) => [c.column, ...c.through.map((t) => t.col), ...c.enter.map((e) => e.from_col), ...c.exit.map((x) => x.to_col)]));
95
+ body.innerHTML = `<div class="git-head">
96
+ <span class="faint">On <strong>${esc(log.current_branch)}</strong></span>
97
+ <button class="icon-btn sm" data-git="refresh" title="Refresh" aria-label="Refresh">${icon("refresh")}</button>
98
+ </div>
99
+ <div class="git-rows">${renderCommits(log.commits, lanes)}</div>`;
100
+
101
+ $("[data-git=refresh]", body).onclick = load;
102
+ const rows = $(".git-rows", body);
103
+ rows.scrollTop = scrollTop;
104
+ rows.addEventListener("scroll", () => {
105
+ if (rows.scrollTop + rows.clientHeight >= rows.scrollHeight - SCROLL_MARGIN) loadMore();
106
+ });
107
+ const byHash = new Map(log.commits.map((c) => [c.hash, c]));
108
+ rows.addEventListener("contextmenu", (e) => {
109
+ const row = e.target.closest(".git-row");
110
+ if (!row) return;
111
+ e.preventDefault();
112
+ rowMenu({ x: e.clientX, y: e.clientY }, project, byHash.get(row.dataset.hash));
113
+ });
114
+ $$(".git-ref", body).forEach((pill) => {
115
+ pill.addEventListener("click", (e) => {
116
+ e.stopPropagation();
117
+ checkout(project, pill.dataset.ref);
118
+ });
119
+ });
120
+ }
121
+
122
+ function rowMenu(anchor, project, commit) {
123
+ const items = commit.refs
124
+ .filter((r) => !r.startsWith("tag: "))
125
+ .map((r) => r.replace("HEAD -> ", ""))
126
+ .map((name) => ({ label: `Checkout branch ${name}`, icon: "git-branch", onClick: () => checkout(project, name) }));
127
+ items.push({ label: "Checkout commit (detached)", icon: "git-branch", onClick: () => checkout(project, commit.hash) });
128
+ menu(anchor, items);
129
+ }
130
+
131
+ async function checkout(project, ref) {
132
+ try {
133
+ await api(`/api/projects/${project.id}/git/checkout`, { method: "POST", body: { ref } });
134
+ toast({ title: `Checked out ${ref}` });
135
+ load();
136
+ } catch (e) {
137
+ toastError(e);
138
+ }
139
+ }
140
+
141
+ load();
142
+ }
@@ -0,0 +1,172 @@
1
+ // Command history page and search across every terminal's output.
2
+ import { S, findSession, orderedProjects, projectColor, sessionTitle, showSession } from "./app.js";
3
+ import { runBookmarks } from "./views.js";
4
+ import { $, api, confirmDialog, dialog, esc, fmtTime, icon, toast, toastError } from "./ui.js";
5
+
6
+ function fmtMs(ms) {
7
+ if (ms == null) return "";
8
+ if (ms < 1000) return `${ms} ms`;
9
+ const s = ms / 1000;
10
+ return s < 60 ? `${s.toFixed(s < 10 ? 1 : 0)} s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
11
+ }
12
+
13
+ function terminalLabel(sid, projectId) {
14
+ const s = sid && findSession(sid);
15
+ if (s) return `<span class="proj-dot" style="background:${projectColor(s.project)}"></span>${esc(s.project.name)} · ${esc(sessionTitle(s))}`;
16
+ const p = S.projects.find((x) => x.id === projectId);
17
+ return p ? `<span class="proj-dot" style="background:${projectColor(p)}"></span>${esc(p.name)} <span class="faint">· closed</span>` : '<span class="faint">closed terminal</span>';
18
+ }
19
+
20
+ // ------------------------------------------------------------ history page
21
+
22
+ let rows = [];
23
+
24
+ export function renderHistory(el) {
25
+ if (!el.dataset.wired) {
26
+ el.dataset.wired = "1";
27
+ el.innerHTML = `<div class="page-head">
28
+ <div><h1>Command history</h1><p>Every command run in shelldeck, with its folder, exit code and duration. Insert types it into the focused terminal; Run also presses Enter.</p></div>
29
+ <button class="btn" data-h="clear">${icon("trash")}Clear history</button>
30
+ </div>
31
+ <div class="row hist-filters">
32
+ <input type="search" class="hist-q" placeholder="Search commands…" spellcheck="false" aria-label="Search commands" />
33
+ <select class="hist-project" aria-label="Project"><option value="">All projects</option></select>
34
+ <label class="hist-failed"><input type="checkbox" /> Failed only</label>
35
+ </div>
36
+ <div class="card hist-table"><table><tbody></tbody></table></div>`;
37
+ let timer = 0;
38
+ const reload = () => {
39
+ clearTimeout(timer);
40
+ timer = setTimeout(() => loadHistory(el), 200);
41
+ };
42
+ el.querySelector(".hist-q").addEventListener("input", reload);
43
+ el.querySelector(".hist-project").addEventListener("change", reload);
44
+ el.querySelector(".hist-failed input").addEventListener("change", reload);
45
+ el.addEventListener("click", (e) => onHistoryClick(e, el));
46
+ }
47
+ const sel = el.querySelector(".hist-project");
48
+ const current = sel.value;
49
+ sel.innerHTML = `<option value="">All projects</option>${orderedProjects().map((p) => `<option value="${p.id}" ${p.id === current ? "selected" : ""}>${esc(p.name)}</option>`).join("")}`;
50
+ loadHistory(el);
51
+ }
52
+
53
+ async function loadHistory(el) {
54
+ const params = new URLSearchParams({
55
+ q: el.querySelector(".hist-q").value,
56
+ project_id: el.querySelector(".hist-project").value,
57
+ failed: el.querySelector(".hist-failed input").checked,
58
+ });
59
+ try {
60
+ rows = (await api(`/api/history?${params}`)).commands;
61
+ } catch (e) {
62
+ return toastError(e);
63
+ }
64
+ el.querySelector(".hist-table tbody").innerHTML = rows.length
65
+ ? rows
66
+ .map(
67
+ (r, i) => `<tr>
68
+ <td class="faint nowrap">${esc(fmtTime(r.started_at))}</td>
69
+ <td class="hist-cmd"><code title="${esc(r.command)}">${esc(r.command)}</code>
70
+ <div class="hist-meta">${terminalLabel(r.session_id, r.project_id)}${r.cwd ? `<span class="faint"> · ${esc(r.cwd)}</span>` : ""}</div></td>
71
+ <td class="nowrap">${r.exit_code == null ? "" : `<span class="exit ${r.exit_code ? "bad" : "ok"}" title="Exit code">${r.exit_code ? `exit ${r.exit_code}` : "ok"}</span>`}</td>
72
+ <td class="num faint nowrap">${fmtMs(r.duration_ms)}</td>
73
+ <td class="nowrap hist-actions">
74
+ <button class="icon-btn sm" data-h="insert" data-i="${i}" title="Insert into focused terminal" aria-label="Insert">${icon("terminal")}</button>
75
+ <button class="icon-btn sm" data-h="run" data-i="${i}" title="Run in focused terminal" aria-label="Run">${icon("play")}</button>
76
+ <button class="icon-btn sm" data-h="copy" data-i="${i}" title="Copy" aria-label="Copy">${icon("clipboard")}</button>
77
+ <button class="icon-btn sm" data-h="bookmark" data-i="${i}" title="Save as bookmark" aria-label="Save as bookmark">${icon("bookmark")}</button>
78
+ </td>
79
+ </tr>`,
80
+ )
81
+ .join("")
82
+ : `<tr><td class="faint empty-row">No commands yet. Commands appear here once you run them in a terminal.</td></tr>`;
83
+ }
84
+
85
+ async function onHistoryClick(e, el) {
86
+ const b = e.target.closest("[data-h]");
87
+ if (!b) return;
88
+ const a = b.dataset.h;
89
+ if (a === "clear") {
90
+ if (await confirmDialog("Delete every saved command? Terminals are not affected.", { ok: "Clear history", danger: true })) {
91
+ await api("/api/history", { method: "DELETE" });
92
+ loadHistory(el);
93
+ }
94
+ return;
95
+ }
96
+ const r = rows[+b.dataset.i];
97
+ if (!r) return;
98
+ if (a === "copy") {
99
+ await navigator.clipboard.writeText(r.command).catch(() => {});
100
+ toast({ title: "Copied", body: r.command, timeout: 2000 });
101
+ } else if (a === "insert" || a === "run") {
102
+ const focused = S.focused && findSession(S.focused);
103
+ if (focused) showSession(focused.id);
104
+ runBookmarks([{ command: r.command, project_id: focused ? null : r.project_id }], { execute: a === "run" });
105
+ } else if (a === "bookmark") {
106
+ try {
107
+ await api("/api/bookmarks", { method: "POST", body: { name: r.command.slice(0, 40), command: r.command, project_id: r.project_id || null } });
108
+ toast({ title: "Saved as bookmark", body: r.command, timeout: 2500 });
109
+ } catch (err) {
110
+ toastError(err);
111
+ }
112
+ }
113
+ }
114
+
115
+ // ------------------------------------------------------ search all terminals
116
+
117
+ function highlight(text, q) {
118
+ const i = text.toLowerCase().indexOf(q.toLowerCase());
119
+ if (i < 0) return esc(text);
120
+ return `${esc(text.slice(0, i))}<mark>${esc(text.slice(i, i + q.length))}</mark>${esc(text.slice(i + q.length))}`;
121
+ }
122
+
123
+ export function searchAllDialog() {
124
+ const d = dialog({
125
+ title: "Search all terminals",
126
+ wide: true,
127
+ cls: "search-all",
128
+ body: `<input type="search" class="sa-q" placeholder="Text in any terminal's output (2+ characters)…" spellcheck="false" aria-label="Search text" />
129
+ <div class="sa-results"><p class="faint">Searches the saved output of every terminal, including ones not on screen.</p></div>`,
130
+ });
131
+ const input = $(".sa-q", d.el);
132
+ const out = $(".sa-results", d.el);
133
+ let timer = 0;
134
+ let seq = 0;
135
+ input.addEventListener("input", () => {
136
+ clearTimeout(timer);
137
+ timer = setTimeout(async () => {
138
+ const q = input.value.trim();
139
+ const mine = ++seq;
140
+ if (q.length < 2) {
141
+ out.innerHTML = `<p class="faint">Type at least 2 characters.</p>`;
142
+ return;
143
+ }
144
+ let results;
145
+ try {
146
+ results = (await api(`/api/search?q=${encodeURIComponent(q)}`)).results;
147
+ } catch (e) {
148
+ return toastError(e);
149
+ }
150
+ if (mine !== seq) return;
151
+ out.innerHTML = results.length
152
+ ? results
153
+ .map(
154
+ (r) => `<div class="sa-group">
155
+ <div class="sa-head">${terminalLabel(r.session_id)}<span class="faint"> · ${r.count} match${r.count === 1 ? "" : "es"}</span></div>
156
+ ${r.hits.map((h) => `<button class="sa-hit" data-sid="${r.session_id}"><code>${highlight(h.text, q)}</code></button>`).join("")}
157
+ </div>`,
158
+ )
159
+ .join("")
160
+ : `<p class="faint">No matches.</p>`;
161
+ }, 200);
162
+ });
163
+ out.addEventListener("click", (e) => {
164
+ const hit = e.target.closest(".sa-hit");
165
+ if (!hit) return;
166
+ const q = input.value.trim();
167
+ d.close();
168
+ const t = showSession(hit.dataset.sid);
169
+ setTimeout(() => (t || S.terms.get(hit.dataset.sid))?.openFind(q), 50);
170
+ });
171
+ input.focus();
172
+ }
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="0.5" y="0.5" width="31" height="31" rx="8" fill="#0a0a0b" stroke="#3f3f46"/><path d="M9 11l5 5-5 5" fill="none" stroke="#fafafa" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 21h6" stroke="#fafafa" stroke-width="3" stroke-linecap="round"/></svg>
@@ -0,0 +1,102 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <title>shelldeck</title>
8
+ <link rel="icon" href="/static/icon.svg" type="image/svg+xml" />
9
+ <link rel="icon" href="/static/icon-32.png" sizes="32x32" type="image/png" />
10
+ <link rel="apple-touch-icon" href="/static/icon-192.png" />
11
+ <link rel="manifest" href="/static/manifest.webmanifest" />
12
+ <meta name="theme-color" content="#0a0a0b" />
13
+ <link rel="stylesheet" href="/static/vendor/xterm.css" />
14
+ <link rel="stylesheet" href="/static/app.css" />
15
+ <script src="/static/vendor/xterm.js"></script>
16
+ <script src="/static/vendor/addon-fit.js"></script>
17
+ <script src="/static/vendor/addon-web-links.js"></script>
18
+ <script src="/static/vendor/addon-webgl.js"></script>
19
+ <script src="/static/vendor/addon-search.js"></script>
20
+ <script src="/static/vendor/addon-serialize.js"></script>
21
+ <script type="module" src="/static/app.js"></script>
22
+ </head>
23
+ <body>
24
+ <div id="boot" aria-hidden="true">
25
+ <div class="boot-mark">
26
+ <svg viewBox="0 0 32 32"><rect x="0.5" y="0.5" width="31" height="31" rx="8" fill="#0a0a0b" stroke="#3f3f46"/><path class="chev" d="M9 11l5 5-5 5" fill="none" stroke="#fafafa" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path class="cursor" d="M17 21h6" stroke="#fafafa" stroke-width="3" stroke-linecap="round"/></svg>
27
+ <span class="boot-name">shelldeck<span class="boot-cover"></span></span>
28
+ </div>
29
+ </div>
30
+ <div id="app">
31
+ <aside id="sidebar" aria-label="Projects">
32
+ <div class="sb-head">
33
+ <div class="brand"><img src="/static/icon.svg" alt="" width="18" height="18" /> shelldeck</div>
34
+ <button class="icon-btn" data-action="toggle-sidebar" title="Hide sidebar (Ctrl+Alt+E)" aria-label="Hide sidebar"><i data-icon="panel"></i></button>
35
+ </div>
36
+ <button class="sb-new" data-action="new-terminal"><i data-icon="plus"></i><span>New terminal</span><kbd>Ctrl Alt N</kbd></button>
37
+ <button class="sb-search" data-action="palette"><i data-icon="search"></i><span>Search</span><kbd>Ctrl K</kbd></button>
38
+ <div class="sb-section">
39
+ <span>Projects</span>
40
+ <button class="icon-btn sm" data-action="add-project" title="Add project" aria-label="Add project"><i data-icon="folder-plus"></i></button>
41
+ </div>
42
+ <nav id="projects" class="sb-projects"></nav>
43
+ <div class="sb-foot">
44
+ <button class="sb-link" data-action="view" data-view="terminals"><i data-icon="terminal"></i>Terminals</button>
45
+ <button class="sb-link" data-action="view" data-view="bookmarks"><i data-icon="bookmark"></i>Bookmarks</button>
46
+ <button class="sb-link" data-action="view" data-view="scheduler"><i data-icon="clock"></i>Scheduler</button>
47
+ <button class="sb-link" data-action="view" data-view="tasks"><i data-icon="kanban"></i>Tasks</button>
48
+ <button class="sb-link" data-action="view" data-view="history"><i data-icon="history"></i>History</button>
49
+ <button class="sb-link" data-action="view" data-view="monitor"><i data-icon="activity"></i>Task manager</button>
50
+ <div class="sb-row">
51
+ <button class="sb-link" data-action="settings"><i data-icon="settings"></i>Settings</button>
52
+ <span id="conn" class="conn" title="Server connection"></span>
53
+ </div>
54
+ </div>
55
+ <div class="sb-resize" data-resize="sidebar" aria-hidden="true"></div>
56
+ </aside>
57
+ <div id="scrim" data-action="toggle-sidebar"></div>
58
+
59
+ <main id="main">
60
+ <header id="topbar">
61
+ <button class="icon-btn" id="sb-open" data-action="toggle-sidebar" title="Show sidebar (Ctrl+Alt+E)" aria-label="Show sidebar"><i data-icon="panel"></i></button>
62
+ <div id="crumbs" class="crumbs"></div>
63
+ <button id="sysmon" class="sysmon" data-action="view" data-view="monitor" title="Task manager (Ctrl+Alt+M)" aria-label="System usage, open task manager"></button>
64
+ <div class="top-actions" id="term-actions">
65
+ <button class="icon-btn" data-action="bookmark-picker" title="Run bookmark (Ctrl+Alt+B)" aria-label="Run bookmark"><i data-icon="bookmark"></i></button>
66
+ <button class="icon-btn" data-action="split" data-dir="row" title="Split right (Ctrl+Alt+\)" aria-label="Split right"><i data-icon="split-h"></i></button>
67
+ <button class="icon-btn" data-action="tile" title="Tile all windows" aria-label="Tile all windows" hidden><i data-icon="grid"></i></button>
68
+ <button class="icon-btn" data-action="split" data-dir="col" title="Split down (Ctrl+Alt+-)" aria-label="Split down"><i data-icon="split-v"></i></button>
69
+ </div>
70
+ </header>
71
+
72
+ <section id="view-terminals" class="view">
73
+ <div id="layout"></div>
74
+ <div id="empty" class="empty">
75
+ <div class="empty-card">
76
+ <img src="/static/icon.svg" alt="" width="40" height="40" />
77
+ <h2>No terminal open</h2>
78
+ <p>Add a project folder, then open a terminal in it.</p>
79
+ <div class="row">
80
+ <button class="btn primary" data-action="add-project"><i data-icon="folder-plus"></i>Add project</button>
81
+ <button class="btn" data-action="new-terminal"><i data-icon="plus"></i>New terminal</button>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </section>
86
+ <section id="view-bookmarks" class="view page" hidden></section>
87
+ <section id="view-scheduler" class="view page" hidden></section>
88
+ <section id="view-tasks" class="view page" hidden></section>
89
+ <section id="view-monitor" class="view page" hidden></section>
90
+ <section id="view-history" class="view page" hidden></section>
91
+ </main>
92
+ </div>
93
+
94
+ <div id="term-store" hidden></div>
95
+ <div id="dialog-root"></div>
96
+ <div id="menu-root"></div>
97
+ <div id="toasts" aria-live="polite"></div>
98
+ <div id="lock" hidden>
99
+ <form class="lock-card" id="lock-form"></form>
100
+ </div>
101
+ </body>
102
+ </html>
@@ -0,0 +1 @@
1
+ {"name":"shelldeck","short_name":"shelldeck","display":"standalone","background_color":"#0a0a0b","theme_color":"#0a0a0b","start_url":"/","icons":[{"src":"/static/icon.svg","sizes":"any","type":"image/svg+xml","purpose":"any"},{"src":"/static/icon-192.png","sizes":"192x192","type":"image/png"},{"src":"/static/icon-512.png","sizes":"512x512","type":"image/png"}]}
@@ -0,0 +1,143 @@
1
+ // Resource usage: top-bar meters and the Task manager page. One poller feeds both.
2
+ import { S, findSession, portChips, projectColor, sessionTitle, shellLabel, showSession } from "./app.js";
3
+ import { $, api, esc } from "./ui.js";
4
+
5
+ const POLL_MS = 2000;
6
+ const HISTORY = 60; // samples kept for sparklines (2 minutes)
7
+ const hist = { cpu: [], mem: [], gpu: [] };
8
+ let stats = null;
9
+ let pageEl = null;
10
+
11
+ export function startMonitor() {
12
+ poll();
13
+ setInterval(() => !document.hidden && poll(), POLL_MS);
14
+ }
15
+
16
+ async function poll() {
17
+ try {
18
+ stats = await api("/api/stats");
19
+ } catch {
20
+ return;
21
+ }
22
+ const s = stats.system;
23
+ push("cpu", s.cpu);
24
+ push("mem", (100 * s.mem_used) / s.mem_total);
25
+ if (s.gpu) push("gpu", s.gpu.util);
26
+ renderMeters();
27
+ updatePorts();
28
+ if (pageEl && !pageEl.hidden) renderMonitor(pageEl);
29
+ }
30
+
31
+ // dev servers etc. listening in a terminal: chips in its pane header
32
+ function updatePorts() {
33
+ const next = {};
34
+ for (const [sid, u] of Object.entries(stats.sessions)) if (u.ports?.length) next[sid] = u.ports;
35
+ if (JSON.stringify(next) === JSON.stringify(S.ports)) return;
36
+ S.ports = next;
37
+ for (const el of document.querySelectorAll(".pane[data-sid] .pane-head .ports")) {
38
+ el.innerHTML = portChips(el.closest(".pane").dataset.sid);
39
+ }
40
+ }
41
+
42
+ function push(k, v) {
43
+ hist[k].push(v);
44
+ if (hist[k].length > HISTORY) hist[k].shift();
45
+ }
46
+
47
+ export function fmtBytes(b) {
48
+ return b >= 2 ** 30 ? `${(b / 2 ** 30).toFixed(1)} GB` : `${Math.round(b / 2 ** 20)} MB`;
49
+ }
50
+
51
+ const level = (pct) => (pct >= 85 ? "high" : pct >= 60 ? "mid" : "");
52
+
53
+ function meter(label, pct, text, title) {
54
+ return `<span class="meter" data-level="${level(pct)}" title="${esc(title)}">
55
+ <span class="m-label">${label}</span><span class="m-bar"><i style="width:${Math.min(100, pct).toFixed(1)}%"></i></span><span class="m-val">${esc(text)}</span>
56
+ </span>`;
57
+ }
58
+
59
+ function renderMeters() {
60
+ const el = $("#sysmon");
61
+ if (!el || !stats) return;
62
+ const s = stats.system;
63
+ const memPct = (100 * s.mem_used) / s.mem_total;
64
+ el.innerHTML =
65
+ meter("CPU", s.cpu, `${Math.round(s.cpu)}%`, `CPU ${s.cpu.toFixed(1)}%`) +
66
+ meter("RAM", memPct, `${(s.mem_used / 2 ** 30).toFixed(1)}/${Math.round(s.mem_total / 2 ** 30)}G`, `Memory ${fmtBytes(s.mem_used)} of ${fmtBytes(s.mem_total)} (${Math.round(memPct)}%)`) +
67
+ (s.gpu ? meter("GPU", s.gpu.util, `${Math.round(s.gpu.util)}%`, `${s.gpu.name}: ${s.gpu.util}% · VRAM ${fmtBytes(s.gpu.mem_used)} of ${fmtBytes(s.gpu.mem_total)}`) : "");
68
+ }
69
+
70
+ function spark(values) {
71
+ if (values.length < 2) return `<svg class="spark" viewBox="0 0 100 32" preserveAspectRatio="none"></svg>`;
72
+ const step = 100 / (HISTORY - 1);
73
+ const x0 = 100 - step * (values.length - 1);
74
+ const pts = values.map((v, i) => `${(x0 + i * step).toFixed(2)},${(31 - (Math.min(100, v) / 100) * 30).toFixed(2)}`);
75
+ return `<svg class="spark" viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true">
76
+ <polygon points="${x0.toFixed(2)},32 ${pts.join(" ")} 100,32" /><polyline points="${pts.join(" ")}" />
77
+ </svg>`;
78
+ }
79
+
80
+ function card(label, value, sub, key, pct) {
81
+ return `<div class="card mon-card" data-level="${level(pct)}">
82
+ <div class="mon-label">${label}</div>
83
+ <div class="mon-value">${esc(value)}</div>
84
+ <div class="mon-sub">${esc(sub)}</div>
85
+ ${key ? spark(hist[key]) : ""}
86
+ </div>`;
87
+ }
88
+
89
+ export function renderMonitor(el) {
90
+ pageEl = el;
91
+ if (!el.dataset.wired) {
92
+ el.dataset.wired = "1";
93
+ el.addEventListener("click", (e) => {
94
+ const port = e.target.closest("[data-port]")?.dataset.port;
95
+ if (port) return window.open(`http://localhost:${port}`, "_blank", "noopener");
96
+ const sid = e.target.closest("tr[data-sid]")?.dataset.sid;
97
+ if (sid) showSession(sid);
98
+ });
99
+ }
100
+ const head = `<div class="page-head"><div><h1>Task manager</h1>
101
+ <p>Live usage of this machine and of each running terminal (its shell plus every process started from it). Updates every 2 seconds.</p></div></div>`;
102
+ if (!stats) {
103
+ el.innerHTML = `${head}<p class="faint">Loading…</p>`;
104
+ return;
105
+ }
106
+ const s = stats.system;
107
+ const memPct = (100 * s.mem_used) / s.mem_total;
108
+ const cards = [
109
+ card("CPU", `${s.cpu.toFixed(0)}%`, `${navigator.hardwareConcurrency || "?"} logical cores`, "cpu", s.cpu),
110
+ card("Memory", `${fmtBytes(s.mem_used)} / ${fmtBytes(s.mem_total)}`, `${Math.round(memPct)}% in use`, "mem", memPct),
111
+ s.gpu
112
+ ? card("GPU", `${Math.round(s.gpu.util)}%`, `${s.gpu.name} · VRAM ${fmtBytes(s.gpu.mem_used)} / ${fmtBytes(s.gpu.mem_total)}`, "gpu", s.gpu.util)
113
+ : card("GPU", "n/a", "No NVIDIA GPU found (usage comes from nvidia-smi)", null, 0),
114
+ ].join("");
115
+
116
+ const rows = Object.entries(stats.sessions)
117
+ .map(([sid, u]) => ({ sid, u, s: findSession(sid) }))
118
+ .filter((r) => r.s)
119
+ .sort((a, b) => b.u.cpu - a.u.cpu || b.u.mem - a.u.mem);
120
+ const total = rows.reduce((t, r) => ({ cpu: t.cpu + r.u.cpu, mem: t.mem + r.u.mem, procs: t.procs + r.u.procs }), { cpu: 0, mem: 0, procs: 0 });
121
+ const body = rows.length
122
+ ? rows
123
+ .map(
124
+ ({ sid, u, s: sess }) => `<tr data-sid="${sid}" title="Open terminal">
125
+ <td><span class="proj-dot" style="background:${projectColor(sess.project)}"></span>${esc(sessionTitle(sess))}<span class="faint"> · ${esc(shellLabel(sess.shell))}</span></td>
126
+ <td class="faint">${esc(sess.project.name)}</td>
127
+ <td class="num"><span class="cell-bar" data-level="${level(u.cpu)}"><i style="width:${Math.min(100, u.cpu)}%"></i></span>${u.cpu.toFixed(1)}%</td>
128
+ <td class="num">${fmtBytes(u.mem)}</td>
129
+ <td class="num">${u.procs}</td>
130
+ <td class="mono">${esc((u.top || "").replace(/\.exe$/i, "")) || '<span class="faint" title="No processes besides the shell">—</span>'}</td>
131
+ <td>${portChips(sid)}</td>
132
+ </tr>`,
133
+ )
134
+ .join("")
135
+ : `<tr><td colspan="7" class="faint empty-row">No running terminals. A terminal starts when you open it.</td></tr>`;
136
+
137
+ el.innerHTML = `${head}<div class="mon-cards">${cards}</div>
138
+ <div class="card mon-table"><table>
139
+ <thead><tr><th>Terminal</th><th>Project</th><th class="num">CPU</th><th class="num">Memory</th><th class="num">Processes</th><th>Busiest process</th><th>Ports</th></tr></thead>
140
+ <tbody>${body}</tbody>
141
+ ${rows.length ? `<tfoot><tr><td>All terminals</td><td></td><td class="num">${total.cpu.toFixed(1)}%</td><td class="num">${fmtBytes(total.mem)}</td><td class="num">${total.procs}</td><td></td><td></td></tr></tfoot>` : ""}
142
+ </table></div>`;
143
+ }