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
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
import { S, TERMINAL_THEMES, applySettings, findSession, newTerminal, orderedProjects, palette, refreshProjects, shellLabel } from "./app.js";
|
|
2
|
+
import { renderMonitor } from "./monitor.js";
|
|
3
|
+
import { renderHistory } from "./history.js";
|
|
4
|
+
import { $, $$, api, authError, confirmDialog, dialog, esc, fmtTime, fromLocalInput, hydrateIcons, icon, toLocalInput, toast, toastError, withEyes } from "./ui.js";
|
|
5
|
+
|
|
6
|
+
let bookmarks = [];
|
|
7
|
+
let current = null; // visible page view
|
|
8
|
+
let pollTimer = null;
|
|
9
|
+
|
|
10
|
+
export function init() {}
|
|
11
|
+
|
|
12
|
+
export function show(view, el) {
|
|
13
|
+
current = view;
|
|
14
|
+
clearInterval(pollTimer);
|
|
15
|
+
const render = { bookmarks: renderBookmarks, scheduler: renderScheduler, tasks: renderTasks, monitor: renderMonitor, history: renderHistory }[view];
|
|
16
|
+
render(el);
|
|
17
|
+
if (!["bookmarks", "monitor", "history"].includes(view)) { // monitor refreshes from its own 2s poller
|
|
18
|
+
pollTimer = setInterval(() => {
|
|
19
|
+
if (current === view && !el.hidden && !document.querySelector(".dialog-bg")) render(el);
|
|
20
|
+
else if (el.hidden) clearInterval(pollTimer);
|
|
21
|
+
}, 5000);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function projectOptions(selected, { none = "" } = {}) {
|
|
26
|
+
const opts = orderedProjects()
|
|
27
|
+
.map((p) => `<option value="${p.id}" ${p.id === selected ? "selected" : ""}>${esc(p.name)}</option>`)
|
|
28
|
+
.join("");
|
|
29
|
+
return (none ? `<option value="">${esc(none)}</option>` : "") + opts;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function projectName(id) {
|
|
33
|
+
return S.projects.find((p) => p.id === id)?.name;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// -------------------------------------------------------------- bookmarks
|
|
37
|
+
|
|
38
|
+
export function bookmarkCache() {
|
|
39
|
+
return bookmarks;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadBookmarks() {
|
|
43
|
+
try {
|
|
44
|
+
bookmarks = (await api("/api/bookmarks")).bookmarks;
|
|
45
|
+
} catch {
|
|
46
|
+
/* offline; keep cache */
|
|
47
|
+
}
|
|
48
|
+
return bookmarks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function renderBookmarks(el) {
|
|
52
|
+
await loadBookmarks();
|
|
53
|
+
const q = el.querySelector("input[type=search]")?.value || "";
|
|
54
|
+
const list = bookmarks.filter((b) => `${b.name} ${b.command}`.toLowerCase().includes(q.toLowerCase()));
|
|
55
|
+
el.innerHTML = `<div class="page-head">
|
|
56
|
+
<div><h1>Bookmarks</h1><p>Saved commands. Run inserts the command into a terminal; Shift+click runs it.</p></div>
|
|
57
|
+
<div class="row"><input type="search" placeholder="Filter…" value="${esc(q)}" style="width:200px" aria-label="Filter bookmarks" />
|
|
58
|
+
<button class="btn primary" data-b="new">${icon("plus")}New bookmark</button></div>
|
|
59
|
+
</div>
|
|
60
|
+
${
|
|
61
|
+
list.length
|
|
62
|
+
? `<div class="list">${list
|
|
63
|
+
.map(
|
|
64
|
+
(b) => `<div class="list-item" data-id="${b.id}">
|
|
65
|
+
<div><b>${esc(b.name)}</b></div>
|
|
66
|
+
<div class="cmd" title="${esc(b.command)}">${esc(b.command)}</div>
|
|
67
|
+
<span class="badge scope">${esc(projectName(b.project_id) || "Any project")}</span>
|
|
68
|
+
<div class="actions">
|
|
69
|
+
<button class="icon-btn sm" data-b="run" title="Insert into terminal (Shift+click to run)" aria-label="Run">${icon("play")}</button>
|
|
70
|
+
<button class="icon-btn sm" data-b="edit" title="Edit" aria-label="Edit">${icon("pencil")}</button>
|
|
71
|
+
<button class="icon-btn sm danger" data-b="del" title="Delete" aria-label="Delete">${icon("trash")}</button>
|
|
72
|
+
</div></div>`,
|
|
73
|
+
)
|
|
74
|
+
.join("")}</div>`
|
|
75
|
+
: `<div class="list"><div class="placeholder">${bookmarks.length ? "No bookmarks match." : "No bookmarks yet. Save commands you run often."}</div></div>`
|
|
76
|
+
}`;
|
|
77
|
+
const search = el.querySelector("input[type=search]");
|
|
78
|
+
search.oninput = () => {
|
|
79
|
+
const pos = search.selectionStart;
|
|
80
|
+
renderBookmarks(el).then(() => {
|
|
81
|
+
const s = el.querySelector("input[type=search]");
|
|
82
|
+
s.focus();
|
|
83
|
+
s.setSelectionRange(pos, pos);
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
el.onclick = async (e) => {
|
|
87
|
+
const b = e.target.closest("[data-b]");
|
|
88
|
+
if (!b) return;
|
|
89
|
+
const id = e.target.closest("[data-id]")?.dataset.id;
|
|
90
|
+
const bm = bookmarks.find((x) => x.id === id);
|
|
91
|
+
if (b.dataset.b === "new") bookmarkDialog(null, () => renderBookmarks(el));
|
|
92
|
+
if (b.dataset.b === "edit") bookmarkDialog(bm, () => renderBookmarks(el));
|
|
93
|
+
if (b.dataset.b === "run") runBookmarks([bm], { execute: e.shiftKey });
|
|
94
|
+
if (b.dataset.b === "del" && (await confirmDialog(`Delete bookmark "${bm.name}"?`, { ok: "Delete", danger: true }))) {
|
|
95
|
+
await api(`/api/bookmarks/${id}`, { method: "DELETE" }).catch(toastError);
|
|
96
|
+
renderBookmarks(el);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function bookmarkDialog(bm, onSaved, preset = {}) {
|
|
102
|
+
const v = bm || { name: "", command: "", project_id: preset.project_id || "", ...preset };
|
|
103
|
+
const d = dialog({
|
|
104
|
+
title: bm ? "Edit bookmark" : "New bookmark",
|
|
105
|
+
body: `<form>
|
|
106
|
+
<label class="field"><span>Name</span><input type="text" name="name" value="${esc(v.name)}" required autofocus /></label>
|
|
107
|
+
<label class="field"><span>Command</span><textarea name="command" class="mono" required>${esc(v.command)}</textarea></label>
|
|
108
|
+
<label class="field"><span>Project</span><select name="project_id">${projectOptions(v.project_id, { none: "Any project" })}</select></label>
|
|
109
|
+
<div class="error"></div></form>`,
|
|
110
|
+
foot: `<button class="btn" data-close>Cancel</button><button class="btn primary" data-ok>${bm ? "Save" : "Create"}</button>`,
|
|
111
|
+
});
|
|
112
|
+
const form = $("form", d.el);
|
|
113
|
+
const submit = async (e) => {
|
|
114
|
+
e?.preventDefault();
|
|
115
|
+
const body = { name: form.name.value, command: form.command.value, project_id: form.project_id.value || null };
|
|
116
|
+
try {
|
|
117
|
+
await api(bm ? `/api/bookmarks/${bm.id}` : "/api/bookmarks", { method: bm ? "PUT" : "POST", body });
|
|
118
|
+
await loadBookmarks();
|
|
119
|
+
d.close();
|
|
120
|
+
onSaved?.();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
$(".error", d.el).textContent = err.message.replaceAll("_", " ");
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
form.onsubmit = submit;
|
|
126
|
+
$("[data-ok]", d.el).onclick = submit;
|
|
127
|
+
form.command.addEventListener("keydown", (e) => {
|
|
128
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) submit(e);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Insert (or with execute, run) bookmarks. Reuses the focused terminal when the project matches. */
|
|
133
|
+
export async function runBookmarks(list, { execute = false } = {}) {
|
|
134
|
+
const focused = S.focused && findSession(S.focused);
|
|
135
|
+
for (const [i, b] of list.entries()) {
|
|
136
|
+
const sameProject = focused && (!b.project_id || b.project_id === focused.project.id);
|
|
137
|
+
let term;
|
|
138
|
+
if (i === 0 && sameProject) {
|
|
139
|
+
term = S.terms.get(focused.id);
|
|
140
|
+
} else {
|
|
141
|
+
term = await newTerminal(b.project_id || focused?.project.id);
|
|
142
|
+
}
|
|
143
|
+
if (!term) continue;
|
|
144
|
+
await term.ready();
|
|
145
|
+
term.send({ type: "input", data: b.command + (execute ? "\r" : "") });
|
|
146
|
+
term.focus();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function bookmarkPicker() {
|
|
151
|
+
await loadBookmarks();
|
|
152
|
+
if (!bookmarks.length) {
|
|
153
|
+
toast({ title: "No bookmarks yet", actions: [{ label: "Create one", onClick: () => bookmarkDialog(null) }] });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const items = bookmarks.map((b) => ({ label: b.name, hint: b.command, icon: "bookmark", group: projectName(b.project_id) || "Any project", b }));
|
|
157
|
+
palette({
|
|
158
|
+
placeholder: "Run bookmark…",
|
|
159
|
+
items,
|
|
160
|
+
multi: (picked, e) => runBookmarks(picked.map((x) => x.b), { execute: e?.shiftKey }),
|
|
161
|
+
footer: "Enter inserts · Shift+Enter runs · Space or Ctrl+click selects several",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// -------------------------------------------------------------- scheduler
|
|
166
|
+
|
|
167
|
+
const JOB_COLUMNS = [
|
|
168
|
+
["never_run", "Never run"],
|
|
169
|
+
["running", "Running"],
|
|
170
|
+
["success", "Success"],
|
|
171
|
+
["failed", "Failed"],
|
|
172
|
+
["disabled", "Disabled"],
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
async function renderScheduler(el) {
|
|
176
|
+
let jobs = [];
|
|
177
|
+
try {
|
|
178
|
+
jobs = (await api("/api/schedule/jobs")).jobs;
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return toastError(e);
|
|
181
|
+
}
|
|
182
|
+
const col = (id) => jobs.filter((j) => (id === "disabled" ? !j.enabled : j.enabled && (j.last_status || "never_run") === id));
|
|
183
|
+
el.innerHTML = `<div class="page-head">
|
|
184
|
+
<div><h1>Scheduler</h1><p>Cron jobs that run a command in a project folder with your default shell.</p></div>
|
|
185
|
+
<button class="btn primary" data-j="new">${icon("plus")}New job</button>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="board">${JOB_COLUMNS.map(
|
|
188
|
+
([id, label]) => `<div class="col"><div class="col-head"><span>${label}</span><span class="count">${col(id).length}</span></div>
|
|
189
|
+
${col(id)
|
|
190
|
+
.map(
|
|
191
|
+
(j) => `<div class="card" data-id="${j.id}" tabindex="0">
|
|
192
|
+
<div class="title"><span>${esc(j.name)}</span><span class="status-dot ${j.enabled ? j.last_status : ""}"></span></div>
|
|
193
|
+
<div class="meta mono">${esc(j.cron)} · ${esc(j.timezone)}</div>
|
|
194
|
+
<div class="meta">${esc(projectName(j.project_id) || "?")} · next ${j.next_run_on ? fmtTime(j.next_run_on) : "–"} · ${j.successful_runs}/${j.total_runs} ok</div>
|
|
195
|
+
</div>`,
|
|
196
|
+
)
|
|
197
|
+
.join("")}</div>`,
|
|
198
|
+
).join("")}</div>`;
|
|
199
|
+
el.onclick = (e) => {
|
|
200
|
+
if (e.target.closest("[data-j=new]")) return jobDialog(null, () => renderScheduler(el));
|
|
201
|
+
const card = e.target.closest(".card");
|
|
202
|
+
if (card) jobDialog(jobs.find((j) => j.id === card.dataset.id), () => renderScheduler(el));
|
|
203
|
+
};
|
|
204
|
+
el.onkeydown = (e) => {
|
|
205
|
+
if (e.key === "Enter" && e.target.classList.contains("card")) e.target.click();
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function jobDialog(job, onDone) {
|
|
210
|
+
if (!S.projects.length) return toast({ title: "Add a project first" });
|
|
211
|
+
const v = job || { name: "", command: "", cron: "0 * * * *", timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", timeout_seconds: 60, enabled: true, project_id: S.focused ? findSession(S.focused)?.project.id : "" };
|
|
212
|
+
const d = dialog({
|
|
213
|
+
title: job ? "Edit job" : "New job",
|
|
214
|
+
wide: !!job,
|
|
215
|
+
body: `<form>
|
|
216
|
+
<div class="field-row">
|
|
217
|
+
<label class="field"><span>Name</span><input type="text" name="name" value="${esc(v.name)}" required autofocus /></label>
|
|
218
|
+
<label class="field"><span>Project</span><select name="project_id">${projectOptions(v.project_id)}</select></label>
|
|
219
|
+
</div>
|
|
220
|
+
<label class="field"><span>Command</span><textarea name="command" class="mono" required>${esc(v.command)}</textarea></label>
|
|
221
|
+
<div class="field-row">
|
|
222
|
+
<label class="field"><span>Cron (min hour day month weekday)</span><input type="text" name="cron" class="mono" value="${esc(v.cron)}" required /></label>
|
|
223
|
+
<label class="field"><span>Timezone</span><input type="text" name="timezone" value="${esc(v.timezone)}" /></label>
|
|
224
|
+
<label class="field"><span>Timeout (s)</span><input type="number" name="timeout_seconds" min="1" value="${v.timeout_seconds}" /></label>
|
|
225
|
+
</div>
|
|
226
|
+
<label class="check"><input type="checkbox" name="enabled" ${v.enabled ? "checked" : ""} /> Enabled</label>
|
|
227
|
+
<div class="error"></div>
|
|
228
|
+
${job ? `<div class="logs"><div class="faint">Loading runs…</div></div>` : ""}
|
|
229
|
+
</form>`,
|
|
230
|
+
foot: `${job ? `<button class="btn danger" data-a="del">${icon("trash")}Delete</button><button class="btn" data-a="run">${icon("play")}Run now</button>` : ""}<span class="spacer"></span><button class="btn" data-close>Cancel</button><button class="btn primary" data-a="save">Save</button>`,
|
|
231
|
+
});
|
|
232
|
+
const form = $("form", d.el);
|
|
233
|
+
const save = async (e) => {
|
|
234
|
+
e?.preventDefault();
|
|
235
|
+
const body = {
|
|
236
|
+
name: form.name.value,
|
|
237
|
+
project_id: form.project_id.value,
|
|
238
|
+
command: form.command.value,
|
|
239
|
+
cron: form.cron.value,
|
|
240
|
+
timezone: form.timezone.value || "UTC",
|
|
241
|
+
timeout_seconds: +form.timeout_seconds.value || 60,
|
|
242
|
+
enabled: form.enabled.checked,
|
|
243
|
+
};
|
|
244
|
+
try {
|
|
245
|
+
await api(job ? `/api/schedule/jobs/${job.id}` : "/api/schedule/jobs", { method: job ? "PUT" : "POST", body });
|
|
246
|
+
d.close();
|
|
247
|
+
onDone();
|
|
248
|
+
} catch (err) {
|
|
249
|
+
$(".error", d.el).textContent = err.message.replaceAll("_", " ");
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
form.onsubmit = save;
|
|
253
|
+
d.el.addEventListener("click", async (e) => {
|
|
254
|
+
const a = e.target.closest("[data-a]")?.dataset.a;
|
|
255
|
+
if (a === "save") save(e);
|
|
256
|
+
if (a === "run") {
|
|
257
|
+
await api(`/api/schedule/jobs/${job.id}/run`, { method: "POST" }).catch(toastError);
|
|
258
|
+
toast({ title: `Queued "${job.name}"` });
|
|
259
|
+
setTimeout(loadRuns, 1500);
|
|
260
|
+
}
|
|
261
|
+
if (a === "del" && (await confirmDialog(`Delete job "${job.name}" and its run history?`, { ok: "Delete", danger: true }))) {
|
|
262
|
+
await api(`/api/schedule/jobs/${job.id}`, { method: "DELETE" }).catch(toastError);
|
|
263
|
+
d.close();
|
|
264
|
+
onDone();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
const loadRuns = async () => {
|
|
268
|
+
if (!job) return;
|
|
269
|
+
const box = $(".logs", d.el);
|
|
270
|
+
try {
|
|
271
|
+
const { runs } = await api(`/api/schedule/jobs/${job.id}/runs?limit=20`);
|
|
272
|
+
box.innerHTML = `<div class="faint" style="margin-bottom:6px">Recent runs</div>${
|
|
273
|
+
runs.length
|
|
274
|
+
? runs
|
|
275
|
+
.map(
|
|
276
|
+
(r) => `<div class="log"><div class="log-head"><span class="status-dot ${esc(r.status)}"></span>${esc(fmtTime(r.run_at))} · ${esc(r.status)} · ${r.duration_ms ?? 0} ms${r.exit_code != null ? ` · exit ${r.exit_code}` : ""}</div>
|
|
277
|
+
<pre>${esc((r.output || "") + (r.error ? `\n${r.error}` : "")) || "(no output)"}</pre></div>`,
|
|
278
|
+
)
|
|
279
|
+
.join("")
|
|
280
|
+
: `<div class="faint">No runs yet.</div>`
|
|
281
|
+
}`;
|
|
282
|
+
} catch {
|
|
283
|
+
box.innerHTML = "";
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
loadRuns();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ------------------------------------------------------------------ tasks
|
|
290
|
+
|
|
291
|
+
const TASK_COLUMNS = [
|
|
292
|
+
["backlog", "Backlog"],
|
|
293
|
+
["todo", "To do"],
|
|
294
|
+
["inprogress", "In progress"],
|
|
295
|
+
["done", "Done"],
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
async function renderTasks(el) {
|
|
299
|
+
let tasks = [];
|
|
300
|
+
try {
|
|
301
|
+
tasks = (await api("/api/tasks")).tasks;
|
|
302
|
+
} catch (e) {
|
|
303
|
+
return toastError(e);
|
|
304
|
+
}
|
|
305
|
+
const overdue = (t) => t.due_at && t.column !== "done" && new Date(t.due_at) <= new Date();
|
|
306
|
+
el.innerHTML = `<div class="page-head">
|
|
307
|
+
<div><h1>Tasks</h1><p>Personal board with due dates and reminders. Drag cards between columns.</p></div>
|
|
308
|
+
<button class="btn primary" data-t="new">${icon("plus")}New task</button>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="board">${TASK_COLUMNS.map(([id, label]) => {
|
|
311
|
+
const items = tasks.filter((t) => t.column === id);
|
|
312
|
+
return `<div class="col" data-col="${id}"><div class="col-head"><span>${label}</span><span class="count">${items.length}</span></div>
|
|
313
|
+
${id !== "done" ? `<button class="add-card" data-t="add" data-col="${id}">+ Add task</button>` : ""}
|
|
314
|
+
${items
|
|
315
|
+
.map(
|
|
316
|
+
(t) => `<div class="card ${overdue(t) ? "overdue" : ""}" draggable="true" data-id="${t.id}" tabindex="0">
|
|
317
|
+
<div class="title"><span>${esc(t.title)}</span><span class="prio ${esc(t.priority)}">${esc(t.priority)}</span></div>
|
|
318
|
+
${t.due_at || t.reminder_at ? `<div class="meta">${t.due_at ? `due ${fmtTime(t.due_at)}` : `remind ${fmtTime(t.reminder_at)}`}</div>` : ""}
|
|
319
|
+
${(t.tags || "")
|
|
320
|
+
.split(",")
|
|
321
|
+
.map((x) => x.trim())
|
|
322
|
+
.filter(Boolean)
|
|
323
|
+
.map((x) => `<span class="tag">${esc(x)}</span>`)
|
|
324
|
+
.join("")}
|
|
325
|
+
</div>`,
|
|
326
|
+
)
|
|
327
|
+
.join("")}</div>`;
|
|
328
|
+
}).join("")}</div>`;
|
|
329
|
+
el.onclick = (e) => {
|
|
330
|
+
const t = e.target.closest("[data-t]");
|
|
331
|
+
if (t) return taskDialog(null, t.dataset.col || "backlog", () => renderTasks(el));
|
|
332
|
+
const card = e.target.closest(".card");
|
|
333
|
+
if (card) taskDialog(tasks.find((x) => x.id === card.dataset.id), null, () => renderTasks(el));
|
|
334
|
+
};
|
|
335
|
+
el.onkeydown = (e) => {
|
|
336
|
+
if (e.key === "Enter" && e.target.classList.contains("card")) e.target.click();
|
|
337
|
+
};
|
|
338
|
+
let dragId = null;
|
|
339
|
+
el.ondragstart = (e) => {
|
|
340
|
+
const card = e.target.closest(".card");
|
|
341
|
+
if (!card) return;
|
|
342
|
+
dragId = card.dataset.id;
|
|
343
|
+
card.classList.add("dragging");
|
|
344
|
+
e.dataTransfer.effectAllowed = "move";
|
|
345
|
+
};
|
|
346
|
+
el.ondragend = () => {
|
|
347
|
+
dragId = null;
|
|
348
|
+
$$(".dragging, .drag-over", el).forEach((x) => x.classList.remove("dragging", "drag-over"));
|
|
349
|
+
};
|
|
350
|
+
el.ondragover = (e) => {
|
|
351
|
+
const col = e.target.closest(".col");
|
|
352
|
+
if (!dragId || !col) return;
|
|
353
|
+
e.preventDefault();
|
|
354
|
+
$$(".col", el).forEach((c) => c.classList.toggle("drag-over", c === col));
|
|
355
|
+
};
|
|
356
|
+
el.ondrop = async (e) => {
|
|
357
|
+
const col = e.target.closest(".col");
|
|
358
|
+
if (!dragId || !col) return;
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
await api(`/api/tasks/${dragId}/move`, { method: "POST", body: { column: col.dataset.col } }).catch(toastError);
|
|
361
|
+
renderTasks(el);
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function taskDialog(task, column, onDone) {
|
|
366
|
+
const v = task || { title: "", description: "", column, priority: "medium", due_at: null, reminder_at: null, tags: "" };
|
|
367
|
+
const d = dialog({
|
|
368
|
+
title: task ? "Edit task" : "New task",
|
|
369
|
+
body: `<form>
|
|
370
|
+
<label class="field"><span>Title</span><input type="text" name="title" value="${esc(v.title)}" required autofocus /></label>
|
|
371
|
+
<label class="field"><span>Description</span><textarea name="description">${esc(v.description)}</textarea></label>
|
|
372
|
+
<div class="field-row">
|
|
373
|
+
<label class="field"><span>Column</span><select name="column">${TASK_COLUMNS.map(([id, l]) => `<option value="${id}" ${id === v.column ? "selected" : ""}>${l}</option>`).join("")}</select></label>
|
|
374
|
+
<label class="field"><span>Priority</span><select name="priority">${["low", "medium", "high", "urgent"].map((p) => `<option ${p === v.priority ? "selected" : ""}>${p}</option>`).join("")}</select></label>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="field-row">
|
|
377
|
+
<label class="field"><span>Due</span><input type="datetime-local" name="due_at" value="${toLocalInput(v.due_at)}" /></label>
|
|
378
|
+
<label class="field"><span>Reminder</span><input type="datetime-local" name="reminder_at" value="${toLocalInput(v.reminder_at)}" /></label>
|
|
379
|
+
</div>
|
|
380
|
+
<label class="field"><span>Tags (comma separated)</span><input type="text" name="tags" value="${esc(v.tags)}" /></label>
|
|
381
|
+
<div class="error"></div></form>`,
|
|
382
|
+
foot: `${task ? `<button class="btn danger" data-a="del">${icon("trash")}Delete</button>` : ""}<span class="spacer"></span><button class="btn" data-close>Cancel</button><button class="btn primary" data-a="save">Save</button>`,
|
|
383
|
+
});
|
|
384
|
+
const form = $("form", d.el);
|
|
385
|
+
const save = async (e) => {
|
|
386
|
+
e?.preventDefault();
|
|
387
|
+
const reminder = fromLocalInput(form.reminder_at.value);
|
|
388
|
+
const body = {
|
|
389
|
+
title: form.title.value,
|
|
390
|
+
description: form.description.value,
|
|
391
|
+
column: form.column.value,
|
|
392
|
+
priority: form.priority.value,
|
|
393
|
+
due_at: fromLocalInput(form.due_at.value),
|
|
394
|
+
reminder_at: reminder,
|
|
395
|
+
tags: form.tags.value,
|
|
396
|
+
};
|
|
397
|
+
if (task && reminder !== task.reminder_at) body.reminder_acknowledged = false;
|
|
398
|
+
try {
|
|
399
|
+
await api(task ? `/api/tasks/${task.id}` : "/api/tasks", { method: task ? "PUT" : "POST", body });
|
|
400
|
+
if (reminder) requestNotify();
|
|
401
|
+
d.close();
|
|
402
|
+
onDone();
|
|
403
|
+
} catch (err) {
|
|
404
|
+
$(".error", d.el).textContent = err.message.replaceAll("_", " ");
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
form.onsubmit = save;
|
|
408
|
+
d.el.addEventListener("click", async (e) => {
|
|
409
|
+
const a = e.target.closest("[data-a]")?.dataset.a;
|
|
410
|
+
if (a === "save") save(e);
|
|
411
|
+
if (a === "del" && (await confirmDialog(`Delete task "${task.title}"?`, { ok: "Delete", danger: true }))) {
|
|
412
|
+
await api(`/api/tasks/${task.id}`, { method: "DELETE" }).catch(toastError);
|
|
413
|
+
d.close();
|
|
414
|
+
onDone();
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ----------------------------------------------------------------- alarms
|
|
420
|
+
|
|
421
|
+
const alarmToasts = new Map();
|
|
422
|
+
|
|
423
|
+
function requestNotify() {
|
|
424
|
+
if ("Notification" in window && Notification.permission === "default") Notification.requestPermission();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function chime() {
|
|
428
|
+
try {
|
|
429
|
+
const ctx = new AudioContext();
|
|
430
|
+
[660, 880, 990].forEach((f, i) => {
|
|
431
|
+
const o = ctx.createOscillator();
|
|
432
|
+
const g = ctx.createGain();
|
|
433
|
+
o.frequency.value = f;
|
|
434
|
+
g.gain.setValueAtTime(0.0001, ctx.currentTime + i * 0.18);
|
|
435
|
+
g.gain.exponentialRampToValueAtTime(0.15, ctx.currentTime + i * 0.18 + 0.02);
|
|
436
|
+
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + i * 0.18 + 0.4);
|
|
437
|
+
o.connect(g).connect(ctx.destination);
|
|
438
|
+
o.start(ctx.currentTime + i * 0.18);
|
|
439
|
+
o.stop(ctx.currentTime + i * 0.18 + 0.45);
|
|
440
|
+
});
|
|
441
|
+
} catch {
|
|
442
|
+
/* audio blocked until user interaction */
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function showAlarm(task, fresh) {
|
|
447
|
+
if (alarmToasts.has(task.id)) return;
|
|
448
|
+
if (fresh) {
|
|
449
|
+
chime();
|
|
450
|
+
if ("Notification" in window && Notification.permission === "granted" && document.hidden) {
|
|
451
|
+
new Notification(task.title, { body: task.due_at ? `Due ${fmtTime(task.due_at)}` : "Reminder", tag: task.id });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const t = toast({
|
|
455
|
+
title: task.title,
|
|
456
|
+
body: task.due_at ? `Due ${fmtTime(task.due_at)}` : "Reminder",
|
|
457
|
+
kind: "alarm",
|
|
458
|
+
timeout: 0,
|
|
459
|
+
actions: [
|
|
460
|
+
{ label: "Acknowledge", onClick: () => api(`/api/tasks/${task.id}/ack`, { method: "POST" }).catch(toastError) },
|
|
461
|
+
{ label: "Snooze 15 min", onClick: () => api(`/api/tasks/${task.id}/snooze`, { method: "POST", body: { minutes: 15 } }).catch(toastError) },
|
|
462
|
+
],
|
|
463
|
+
});
|
|
464
|
+
alarmToasts.set(task.id, t);
|
|
465
|
+
new MutationObserver((_, obs) => {
|
|
466
|
+
if (!t.el.isConnected) {
|
|
467
|
+
alarmToasts.delete(task.id);
|
|
468
|
+
obs.disconnect();
|
|
469
|
+
}
|
|
470
|
+
}).observe($("#toasts"), { childList: true });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------- add project
|
|
474
|
+
|
|
475
|
+
export function addProjectDialog() {
|
|
476
|
+
const d = dialog({
|
|
477
|
+
title: "Add project",
|
|
478
|
+
wide: true,
|
|
479
|
+
body: `<form>
|
|
480
|
+
<label class="field"><span>Folder path</span><input type="text" name="path" class="mono" placeholder="C:\\src\\my-app" autofocus /></label>
|
|
481
|
+
<div class="row"><button type="button" class="btn sm" data-fs="up">${icon("up")}Up</button><span class="faint" data-fs="where"></span></div>
|
|
482
|
+
<div class="fs-list" role="listbox" aria-label="Folders"></div>
|
|
483
|
+
<label class="check" style="margin-top:12px"><input type="checkbox" name="open" checked /> Open a terminal in it</label>
|
|
484
|
+
<div class="error"></div></form>`,
|
|
485
|
+
foot: `<button class="btn" data-close>Cancel</button><button class="btn primary" data-a="add">Add project</button>`,
|
|
486
|
+
});
|
|
487
|
+
const form = $("form", d.el);
|
|
488
|
+
const list = $(".fs-list", d.el);
|
|
489
|
+
let parent = null;
|
|
490
|
+
const browse = async (path) => {
|
|
491
|
+
try {
|
|
492
|
+
const r = await api(`/api/fs/dirs?path=${encodeURIComponent(path)}`);
|
|
493
|
+
parent = r.parent;
|
|
494
|
+
if (r.path) form.path.value = r.path;
|
|
495
|
+
$("[data-fs=where]", d.el).textContent = r.path || "Home and drives";
|
|
496
|
+
const join = (name) => (r.path ? `${r.path.replace(/[\\/]$/, "")}${r.sep}${name}` : name);
|
|
497
|
+
list.innerHTML = r.dirs.length
|
|
498
|
+
? r.dirs.map((n) => `<div class="fs-item" data-path="${esc(join(n))}">${icon("folder")}${esc(n)}</div>`).join("")
|
|
499
|
+
: `<div class="placeholder">No sub-folders</div>`;
|
|
500
|
+
$(".error", d.el).textContent = "";
|
|
501
|
+
} catch (e) {
|
|
502
|
+
$(".error", d.el).textContent = e.message.replaceAll("_", " ");
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
list.addEventListener("click", (e) => {
|
|
506
|
+
const it = e.target.closest(".fs-item");
|
|
507
|
+
if (it) browse(it.dataset.path);
|
|
508
|
+
});
|
|
509
|
+
$("[data-fs=up]", d.el).onclick = () => parent !== null && browse(parent);
|
|
510
|
+
form.path.addEventListener("keydown", (e) => {
|
|
511
|
+
if (e.key === "Enter") {
|
|
512
|
+
e.preventDefault();
|
|
513
|
+
browse(form.path.value.trim());
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
const add = async (e) => {
|
|
517
|
+
e?.preventDefault();
|
|
518
|
+
try {
|
|
519
|
+
const p = await api("/api/projects", { method: "POST", body: { path: form.path.value.trim() } });
|
|
520
|
+
const open = form.open.checked;
|
|
521
|
+
d.close();
|
|
522
|
+
await refreshProjects();
|
|
523
|
+
if (open) newTerminal(p.id);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
$(".error", d.el).textContent = err.message.replaceAll("_", " ");
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
form.onsubmit = add;
|
|
529
|
+
$("[data-a=add]", d.el).onclick = add;
|
|
530
|
+
browse("");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --------------------------------------------------------------- settings
|
|
534
|
+
|
|
535
|
+
export async function saveSettings(patch) {
|
|
536
|
+
try {
|
|
537
|
+
S.settings = await api("/api/settings", { method: "PUT", body: patch });
|
|
538
|
+
applySettings();
|
|
539
|
+
return true;
|
|
540
|
+
} catch (e) {
|
|
541
|
+
toastError(e);
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export async function settingsDialog() {
|
|
547
|
+
let auth = { enabled: false };
|
|
548
|
+
try {
|
|
549
|
+
[S.shells, auth] = await Promise.all([api("/api/shells"), api("/api/auth/status")]);
|
|
550
|
+
} catch {
|
|
551
|
+
/* use cached */
|
|
552
|
+
}
|
|
553
|
+
const s = S.settings;
|
|
554
|
+
const shellOpts = S.shells.kinds
|
|
555
|
+
.map((k) => {
|
|
556
|
+
const ok = S.shells.available.includes(k);
|
|
557
|
+
return `<option value="${k}" ${k === s.default_shell ? "selected" : ""} ${ok ? "" : "disabled"}>${esc(shellLabel(k))}${ok ? "" : " (not installed)"}</option>`;
|
|
558
|
+
})
|
|
559
|
+
.join("");
|
|
560
|
+
const distros = S.shells.wsl_distros;
|
|
561
|
+
const d = dialog({
|
|
562
|
+
title: "Settings",
|
|
563
|
+
body: `<form>
|
|
564
|
+
<div class="field-row">
|
|
565
|
+
<label class="field"><span>Default shell</span><select name="default_shell">${shellOpts}</select></label>
|
|
566
|
+
<label class="field"><span>WSL distribution</span><select name="wsl_distro" ${distros.length ? "" : "disabled"}>
|
|
567
|
+
<option value="">${distros.length ? "System default" : "WSL not available"}</option>
|
|
568
|
+
${distros.map((x) => `<option ${x === s.wsl_distro ? "selected" : ""}>${esc(x)}</option>`).join("")}
|
|
569
|
+
</select></label>
|
|
570
|
+
</div>
|
|
571
|
+
<div class="field-row">
|
|
572
|
+
<label class="field"><span>Theme</span><select name="theme">${["dark", "light", "system"].map((t) => `<option ${t === s.theme ? "selected" : ""}>${t}</option>`).join("")}</select></label>
|
|
573
|
+
<label class="field"><span>Terminal font size</span><input type="number" name="font_size" min="8" max="32" value="${esc(s.font_size)}" /></label>
|
|
574
|
+
</div>
|
|
575
|
+
<div class="field-row">
|
|
576
|
+
<label class="field"><span>Pane layout</span><select name="layout_mode">
|
|
577
|
+
<option value="tiled" ${s.layout_mode === "tiled" ? "selected" : ""}>Tiled: panes fill the window</option>
|
|
578
|
+
<option value="free" ${s.layout_mode === "free" ? "selected" : ""}>Free: drag and resize windows</option>
|
|
579
|
+
</select></label>
|
|
580
|
+
<label class="field"><span>Project colors</span><select name="project_tint">
|
|
581
|
+
<option value="on" ${s.project_tint !== "off" ? "selected" : ""}>Tint terminal background</option>
|
|
582
|
+
<option value="off" ${s.project_tint === "off" ? "selected" : ""}>Off</option>
|
|
583
|
+
</select></label>
|
|
584
|
+
</div>
|
|
585
|
+
<div class="field-row">
|
|
586
|
+
<label class="field"><span>Terminal colors</span><select name="terminal_theme">${TERMINAL_THEMES.map(
|
|
587
|
+
(t) => `<option value="${t}" ${t === (s.terminal_theme || "default") ? "selected" : ""}>${t === "default" ? "Default (follows theme)" : t.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}</option>`,
|
|
588
|
+
).join("")}</select></label>
|
|
589
|
+
<label class="field"><span>Terminal font</span><input name="font_family" list="font-list" placeholder="Default (Cascadia / Nerd Font)" value="${esc(s.font_family || "")}" />
|
|
590
|
+
<datalist id="font-list">${["Cascadia Code", "Cascadia Mono", "CaskaydiaCove Nerd Font", "JetBrains Mono", "Fira Code", "Consolas", "Source Code Pro", "Hack", "Ubuntu Mono", "DejaVu Sans Mono", "Menlo"].map((f) => `<option value="${f}"></option>`).join("")}</datalist></label>
|
|
591
|
+
</div>
|
|
592
|
+
<div class="field-row">
|
|
593
|
+
<label class="field"><span>Open file paths with</span><select name="editor">
|
|
594
|
+
<option value="vscode" ${s.editor !== "system" ? "selected" : ""}>VS Code (at the line)</option>
|
|
595
|
+
<option value="system" ${s.editor === "system" ? "selected" : ""}>System default app</option>
|
|
596
|
+
</select></label>
|
|
597
|
+
<span></span>
|
|
598
|
+
</div>
|
|
599
|
+
<p class="faint" style="margin:0 0 14px">New terminals use the default shell. Free layout scrolls when windows don't fit; use Tile all to arrange them.</p>
|
|
600
|
+
<hr style="border:0;border-top:1px solid var(--border);margin:4px 0 14px" />
|
|
601
|
+
<div style="margin-bottom:8px"><b>Change password</b> <span class="faint">· each browser locks after ${Math.round((auth.idle_timeout || 1800) / 60)} min idle; changing it signs out every other browser</span></div>
|
|
602
|
+
<div class="field-row">
|
|
603
|
+
<label class="field"><span>Current password</span><input type="password" name="current" autocomplete="current-password" /></label>
|
|
604
|
+
<span></span>
|
|
605
|
+
</div>
|
|
606
|
+
<div class="field-row">
|
|
607
|
+
<label class="field"><span>New password (8+ characters)</span><input type="password" name="password" autocomplete="new-password" /></label>
|
|
608
|
+
<label class="field"><span>Confirm new password</span><input type="password" name="confirm" autocomplete="new-password" /></label>
|
|
609
|
+
</div>
|
|
610
|
+
<div class="row"><button type="button" class="btn sm" data-a="pw">Change password</button></div>
|
|
611
|
+
<div class="error"></div>
|
|
612
|
+
</form>`,
|
|
613
|
+
foot: `<button class="btn" data-close>Close</button><button class="btn primary" data-a="save">Save</button>`,
|
|
614
|
+
});
|
|
615
|
+
const form = $("form", d.el);
|
|
616
|
+
const save = async (e) => {
|
|
617
|
+
e?.preventDefault();
|
|
618
|
+
const ok = await saveSettings({
|
|
619
|
+
default_shell: form.default_shell.value,
|
|
620
|
+
wsl_distro: form.wsl_distro.value,
|
|
621
|
+
theme: form.theme.value,
|
|
622
|
+
font_size: form.font_size.value,
|
|
623
|
+
layout_mode: form.layout_mode.value,
|
|
624
|
+
project_tint: form.project_tint.value,
|
|
625
|
+
terminal_theme: form.terminal_theme.value,
|
|
626
|
+
editor: form.editor.value,
|
|
627
|
+
font_family: form.font_family.value,
|
|
628
|
+
});
|
|
629
|
+
if (ok) {
|
|
630
|
+
d.close();
|
|
631
|
+
toast({ title: "Settings saved" });
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
form.onsubmit = save;
|
|
635
|
+
d.el.addEventListener("click", async (e) => {
|
|
636
|
+
const a = e.target.closest("[data-a]")?.dataset.a;
|
|
637
|
+
if (a === "save") save(e);
|
|
638
|
+
if (a === "pw") {
|
|
639
|
+
try {
|
|
640
|
+
const btn = e.target.closest("[data-a]");
|
|
641
|
+
btn.disabled = true;
|
|
642
|
+
btn.textContent = "Changing…"; // password hashing takes about a second on purpose
|
|
643
|
+
try {
|
|
644
|
+
await api("/api/auth/password", { method: "PUT", body: { current: form.current.value, password: form.password.value, confirm: form.confirm.value } });
|
|
645
|
+
} finally {
|
|
646
|
+
btn.disabled = false;
|
|
647
|
+
btn.textContent = "Change password";
|
|
648
|
+
}
|
|
649
|
+
toast({ title: "Password changed", body: "Other browsers were signed out." });
|
|
650
|
+
d.close();
|
|
651
|
+
} catch (err) {
|
|
652
|
+
$(".error", d.el).textContent = authError(err);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
hydrateIcons(d.el);
|
|
657
|
+
withEyes(d.el);
|
|
658
|
+
}
|