@orangeworks/orangetree 0.4.0

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.
@@ -0,0 +1,650 @@
1
+ import { Canvas } from "./canvas.js";
2
+ import { Chat } from "./chat.js";
3
+ import { Explorer } from "./explorer.js";
4
+ import { api, toast } from "./api.js";
5
+ import { formModal, confirmModal, popover, reportModal } from "./ui.js";
6
+ import { openProjectSettings as openSettingsModal } from "./projectSettings.js";
7
+ import { showGuidelines as showGuidelinesModal } from "./guidelines.js";
8
+ import { applyDataI18n, setLocale, onLocaleChange, currentLocale, t } from "./i18n.js";
9
+ import { openPairingPanel } from "./pairing.js";
10
+ import { openConnectionSettings } from "./connection.js";
11
+ import { relatedDocs, baseName } from "./insight.js";
12
+ const $ = (sel) => document.querySelector(sel);
13
+ const EMPTY_TREE = {
14
+ version: 0,
15
+ currentProjectId: null,
16
+ projects: {},
17
+ nodes: {},
18
+ settings: { model: "", effort: "", permission: "bypass" },
19
+ language: null,
20
+ locks: {}
21
+ };
22
+ const state = { tree: EMPTY_TREE, selectedId: null, busy: /* @__PURE__ */ new Set(), activeRootId: null, showArchived: false };
23
+ const canvas = new Canvas($("#canvas"), {
24
+ onMove(id, pos) {
25
+ void api("PATCH", `/nodes/${id}`, { pos });
26
+ },
27
+ onSelect(id) {
28
+ state.selectedId = state.selectedId === id ? null : id;
29
+ refresh();
30
+ void chat.setNode(state.selectedId ? state.tree.nodes[state.selectedId] ?? null : null);
31
+ if (state.selectedId) void api("PATCH", `/nodes/${rootOf(id)}`, { viewedAt: Date.now() });
32
+ },
33
+ onContextMenu(id, clientX, clientY) {
34
+ if (state.selectedId !== id) {
35
+ state.selectedId = id;
36
+ refresh();
37
+ void chat.setNode(state.tree.nodes[id] ?? null);
38
+ }
39
+ void api("PATCH", `/nodes/${rootOf(id)}`, { viewedAt: Date.now() });
40
+ const archived = !!state.tree.nodes[id]?.archived;
41
+ popover([
42
+ { label: t("app.menu.fork"), onClick: () => void forkNode(id) },
43
+ { label: t("app.menu.reparent"), onClick: () => void reparentNode(id) },
44
+ { label: t("app.menu.relatedDocs"), onClick: () => showRelatedDocs(id, clientX, clientY) },
45
+ { label: archived ? t("app.menu.unarchive") : t("app.menu.archive"), onClick: () => void archiveNode(id) },
46
+ "separator",
47
+ { label: t("app.menu.delete"), danger: true, onClick: () => void deleteNode(id) }
48
+ ], { x: clientX, y: clientY });
49
+ }
50
+ });
51
+ function showRelatedDocs(id, x, y) {
52
+ const node = state.tree.nodes[id];
53
+ if (!node) return;
54
+ const docs = relatedDocs(node, Object.values(state.tree.nodes ?? {}));
55
+ if (!docs.length) {
56
+ popover([{ label: t("app.relatedDocs.none"), onClick: () => {
57
+ }, disabled: true }], { x, y });
58
+ return;
59
+ }
60
+ popover(docs.map((d) => ({ label: `\u{1F4C4} ${baseName(d)}`, title: d, onClick: () => openViewer(d) })), { x, y });
61
+ }
62
+ const chat = new Chat($("#chat"), {
63
+ api,
64
+ // Empty-state first message -> create a projectless node (title = first line).
65
+ async onNewConversation(text) {
66
+ const title = (text.split("\n")[0] || "").trim().slice(0, 40) || t("chat.newConversation");
67
+ const node = await api("POST", "/nodes", { title });
68
+ state.selectedId = node.id;
69
+ return node;
70
+ },
71
+ // Hover ⑂ on an answer segment -> immediate seeded fork; seedless -> the modal fork flow.
72
+ async onFork(nodeId, opts) {
73
+ const seed = opts?.seedText?.trim();
74
+ if (!seed) {
75
+ await forkNode(nodeId);
76
+ return;
77
+ }
78
+ const parent = state.tree.nodes[nodeId];
79
+ const title = t("app.fork.titleSuffix", { name: parent?.title ?? nodeId }).slice(0, 40);
80
+ const child = await api("POST", `/nodes/${nodeId}/fork`, { title, summary: seed });
81
+ state.selectedId = child.id;
82
+ void chat.setNode(child);
83
+ },
84
+ // done is a manual, meaningful transition — confirm first (and no-op if already done), per the reference.
85
+ async onDone(nodeId) {
86
+ const n = state.tree.nodes[nodeId];
87
+ if (!n || n.status === "done") return;
88
+ const ok = await confirmModal({
89
+ title: t("app.done.title"),
90
+ message: t("app.done.msg", { id: n.id, title: n.title }),
91
+ okLabel: t("app.done.ok")
92
+ });
93
+ if (!ok) return;
94
+ void api("PATCH", `/nodes/${nodeId}`, { status: "done" });
95
+ },
96
+ onGoto: gotoNode,
97
+ onOpenDoc(path) {
98
+ openViewer(path);
99
+ }
100
+ });
101
+ function gotoNode(nodeId) {
102
+ const n = state.tree.nodes[nodeId];
103
+ if (!n) return;
104
+ if (ancestorsArchived(n)) {
105
+ state.showArchived = true;
106
+ $("#btn-archive").classList.add("on");
107
+ }
108
+ state.activeRootId = null;
109
+ state.selectedId = nodeId;
110
+ refresh();
111
+ void chat.setNode(state.tree.nodes[nodeId] ?? null);
112
+ }
113
+ const explorer = new Explorer($("#explorer"), {
114
+ api,
115
+ onOpen: openViewer,
116
+ // current-context nodes for search (current project, or projectless when none is active) — SCR-INSIGHT-006
117
+ nodes: () => Object.values(state.tree.nodes ?? {}).filter((n) => (n.projectId ?? null) === (state.tree.currentProjectId ?? null)),
118
+ onGoto: gotoNode,
119
+ hasSelection: () => !!state.selectedId,
120
+ // 📎 on a file -> attach it as the selected node's artifact (docPath = prefixed "folderId:relpath" key).
121
+ async onLink(path) {
122
+ if (!state.selectedId) return;
123
+ await api("PATCH", `/nodes/${state.selectedId}`, { docPath: path });
124
+ toast(t("app.toast.linked"), "success");
125
+ },
126
+ async onSwitchWs(id) {
127
+ const target = id === "none" ? null : id;
128
+ if (target === (state.tree.currentProjectId ?? null)) return;
129
+ try {
130
+ await api("POST", `/projects/${id || "none"}/activate`);
131
+ } catch {
132
+ explorer.setProjects(Object.values(state.tree.projects ?? {}), state.tree.currentProjectId);
133
+ }
134
+ },
135
+ // Create a project name-only — folders are added later in ⚙ settings (talking works without one).
136
+ async onAddWs() {
137
+ const v = await formModal({
138
+ title: t("app.newProject.title"),
139
+ submitLabel: t("app.newProject.submit"),
140
+ fields: [{ name: "name", label: t("app.newProject.name"), type: "text", placeholder: t("app.newProject.ph") }]
141
+ });
142
+ if (v === null) return;
143
+ const name = String(v.name ?? "").trim();
144
+ if (!name) return;
145
+ await api("POST", "/projects", { name });
146
+ },
147
+ async onRemoveWs(id) {
148
+ const pj = state.tree.projects?.[id];
149
+ if (!pj) return;
150
+ const ok = await confirmModal({
151
+ title: t("app.removeProject.title"),
152
+ message: t("app.removeProject.msg", { name: pj.name }),
153
+ okLabel: t("common.remove"),
154
+ danger: true
155
+ });
156
+ if (!ok) return;
157
+ await api("DELETE", `/projects/${id}`);
158
+ },
159
+ onSettings: openProjectSettings
160
+ });
161
+ function openProjectSettings() {
162
+ if (!state.tree.currentProjectId) {
163
+ toast(t("app.toast.selectProjectFirst"), "info");
164
+ return;
165
+ }
166
+ void openSettingsModal({
167
+ api,
168
+ nodes: () => Object.values(state.tree.nodes ?? {}),
169
+ onOpenDoc: openViewer,
170
+ onShowGuidelines: showGuidelines
171
+ });
172
+ }
173
+ function showGuidelines() {
174
+ void showGuidelinesModal(api);
175
+ }
176
+ function doneCandidates(tree) {
177
+ const nodes = Object.values(tree.nodes ?? {});
178
+ const out = /* @__PURE__ */ new Set();
179
+ for (const n of nodes) {
180
+ if (n.status === "done") continue;
181
+ const kids = nodes.filter((c) => c.parentId === n.id && !c.archived);
182
+ if (kids.length && kids.every((c) => c.status === "done")) out.add(n.id);
183
+ }
184
+ return out;
185
+ }
186
+ function projectView() {
187
+ const cur = state.tree.currentProjectId;
188
+ const inProject = (n) => cur ? n.projectId === cur : !n.projectId;
189
+ const nodes = {};
190
+ for (const [id, n] of Object.entries(state.tree.nodes ?? {})) {
191
+ if (inProject(n)) nodes[id] = n;
192
+ }
193
+ return { ...state.tree, nodes };
194
+ }
195
+ function refresh() {
196
+ const view = projectView();
197
+ let sel = view.nodes[state.selectedId ?? ""] ?? null;
198
+ if (!sel && state.selectedId) {
199
+ state.selectedId = null;
200
+ void chat.setNode(null);
201
+ }
202
+ sel = state.selectedId ? view.nodes[state.selectedId] ?? null : null;
203
+ $("#sel-lang").value = state.tree.language ?? "";
204
+ const navLoc = (navigator.language || "ko").slice(0, 2);
205
+ const lang = state.tree.language;
206
+ const uiLoc = lang === "ko" || lang === "en" || lang === "ja" ? lang : navLoc === "en" ? "en" : navLoc === "ja" ? "ja" : "ko";
207
+ if (uiLoc !== currentLocale()) setLocale(uiLoc);
208
+ if (document.activeElement !== $("#sel-style")) {
209
+ $("#sel-style").value = state.tree.settings?.style ?? "";
210
+ }
211
+ const settings = state.tree.settings;
212
+ const reflectSel = (sel2, val) => {
213
+ const el = document.querySelector(sel2);
214
+ if (el && document.activeElement !== el) el.value = val;
215
+ };
216
+ reflectSel("#chat-model", settings?.model ?? "");
217
+ reflectSel("#chat-effort", settings?.effort ?? "");
218
+ reflectSel("#chat-perm", settings?.permission ?? "bypass");
219
+ explorer.setProjects(Object.values(state.tree.projects ?? {}), state.tree.currentProjectId);
220
+ renderRootTabs(view);
221
+ const visible = visibleIds(view);
222
+ if (state.selectedId && !visible.has(state.selectedId)) {
223
+ state.selectedId = null;
224
+ void chat.setNode(null);
225
+ sel = null;
226
+ }
227
+ const dimmed = state.showArchived ? new Set([...visible].filter((id) => ancestorsArchived(view.nodes[id]))) : /* @__PURE__ */ new Set();
228
+ canvas.render(view, state.selectedId, doneCandidates(view), state.busy, visible, dimmed);
229
+ $("#btn-del").disabled = !sel;
230
+ $("#sel-status").disabled = !sel;
231
+ $("#sel-status").value = sel?.status ?? "";
232
+ $("#sel-info").textContent = sel ? `${sel.id} \xB7 ${sel.title}${sel.sessionId ? ` \xB7 ${t("app.sessionLinked")}` : ""}` : "";
233
+ const docBtn = $("#sel-doc");
234
+ docBtn.hidden = !sel?.docPath;
235
+ docBtn.onclick = sel?.docPath ? () => openViewer(sel.docPath) : null;
236
+ chat.updateNode(sel);
237
+ }
238
+ function descendantIds(id) {
239
+ const all = Object.values(state.tree.nodes ?? {});
240
+ const out = [];
241
+ const walk = (pid) => {
242
+ for (const n of all) if (n.parentId === pid) {
243
+ out.push(n.id);
244
+ walk(n.id);
245
+ }
246
+ };
247
+ walk(id);
248
+ return out;
249
+ }
250
+ async function forkNode(parentId) {
251
+ const parent = state.tree.nodes[parentId];
252
+ if (!parent) return;
253
+ const v = await formModal({
254
+ title: t("app.fork.title"),
255
+ submitLabel: t("app.fork.submit"),
256
+ fields: [
257
+ { name: "title", label: t("app.fork.field.title"), value: t("app.fork.titleSuffix", { name: parent.title }) },
258
+ { name: "summary", type: "textarea", label: t("app.fork.field.summary"), rows: 3, placeholder: t("app.fork.field.summaryPh") },
259
+ { name: "returns", type: "checkbox", label: t("app.fork.field.returns"), checkLabel: t("app.fork.field.returnsCheck"), value: false },
260
+ { name: "worktree", type: "checkbox", label: t("app.fork.field.worktree"), checkLabel: t("app.fork.field.worktreeCheck"), value: false }
261
+ ]
262
+ });
263
+ if (v === null) return;
264
+ const title = String(v.title ?? "").trim();
265
+ if (!title) return;
266
+ const summary = String(v.summary ?? "").trim();
267
+ const child = await api("POST", `/nodes/${parentId}/fork`, { title, summary, returns: v.returns === true, worktree: v.worktree === true });
268
+ state.selectedId = child.id;
269
+ void chat.setNode(child);
270
+ }
271
+ async function deleteNode(id) {
272
+ const node = state.tree.nodes[id];
273
+ if (!node) return;
274
+ const kids = descendantIds(id);
275
+ const ok = await confirmModal({
276
+ title: t("app.delete.title"),
277
+ message: kids.length ? t("app.delete.msgCascade", { id: node.id, title: node.title, count: kids.length }) : t("app.delete.msg", { id: node.id, title: node.title }),
278
+ okLabel: t("common.delete"),
279
+ danger: true
280
+ });
281
+ if (!ok) return;
282
+ await api("DELETE", `/nodes/${id}?cascade=1`);
283
+ if (state.selectedId === id || kids.includes(state.selectedId ?? "")) {
284
+ state.selectedId = null;
285
+ void chat.setNode(null);
286
+ }
287
+ }
288
+ function rootOf(id) {
289
+ let cur = state.tree.nodes[id];
290
+ while (cur?.parentId && state.tree.nodes[cur.parentId]) cur = state.tree.nodes[cur.parentId];
291
+ return cur?.id ?? id;
292
+ }
293
+ async function reparentNode(id) {
294
+ const node = state.tree.nodes[id];
295
+ if (!node) return;
296
+ const blocked = /* @__PURE__ */ new Set([id, ...descendantIds(id)]);
297
+ const candidates = Object.values(state.tree.nodes ?? {}).filter(
298
+ (n) => !blocked.has(n.id) && !n.archived && (n.projectId ?? null) === (node.projectId ?? null)
299
+ );
300
+ const options = [
301
+ { value: "", label: t("app.reparent.detach") },
302
+ ...candidates.map((n) => ({ value: n.id, label: `${n.id} \xB7 ${n.title}` }))
303
+ ];
304
+ const v = await formModal({
305
+ title: t("app.reparent.title"),
306
+ submitLabel: t("app.reparent.submit"),
307
+ fields: [{ name: "parent", type: "select", label: t("app.reparent.field"), value: node.parentId ?? "", options }]
308
+ });
309
+ if (v === null) return;
310
+ const parent = String(v.parent ?? "") || null;
311
+ if (parent === (node.parentId ?? null)) return;
312
+ await api("PATCH", `/nodes/${id}`, { parentId: parent });
313
+ }
314
+ async function archiveNode(id) {
315
+ const node = state.tree.nodes[id];
316
+ if (!node) return;
317
+ await api("PATCH", `/nodes/${id}`, { archived: !node.archived });
318
+ }
319
+ const MAX_ROOT_TABS = 5;
320
+ function projectRoots(view) {
321
+ return Object.values(view.nodes ?? {}).filter((n) => !n.parentId && !n.archived).sort((a, b) => (b.viewedAt ?? 0) - (a.viewedAt ?? 0) || a.createdAt - b.createdAt);
322
+ }
323
+ function ancestorsArchived(n) {
324
+ let cur = n;
325
+ while (cur) {
326
+ if (cur.archived) return true;
327
+ cur = cur.parentId ? state.tree.nodes[cur.parentId] : void 0;
328
+ }
329
+ return false;
330
+ }
331
+ function visibleIds(view) {
332
+ const inView = (id) => id in view.nodes;
333
+ const base = state.activeRootId && view.nodes[state.activeRootId] ? new Set([state.activeRootId, ...descendantIds(state.activeRootId)].filter(inView)) : new Set(Object.keys(view.nodes));
334
+ if (state.showArchived) return base;
335
+ return new Set([...base].filter((id) => !ancestorsArchived(view.nodes[id])));
336
+ }
337
+ function pickRoot(id) {
338
+ state.activeRootId = id;
339
+ if (id) void api("PATCH", `/nodes/${id}`, { viewedAt: Date.now() });
340
+ refresh();
341
+ }
342
+ function renderRootTabs(view) {
343
+ const bar = $("#root-tabs");
344
+ bar.replaceChildren();
345
+ const roots = projectRoots(view);
346
+ if (state.activeRootId && !roots.some((r) => r.id === state.activeRootId)) state.activeRootId = null;
347
+ if (roots.length <= 1) {
348
+ bar.hidden = true;
349
+ return;
350
+ }
351
+ bar.hidden = false;
352
+ const mkTab = (label, active, onClick, title) => {
353
+ const b = document.createElement("button");
354
+ b.className = "root-tab" + (active ? " active" : "");
355
+ b.textContent = label.length > 14 ? `${label.slice(0, 13)}\u2026` : label;
356
+ if (title) b.title = title;
357
+ b.onclick = onClick;
358
+ return b;
359
+ };
360
+ bar.append(mkTab(t("rootTabs.all"), state.activeRootId == null, () => pickRoot(null), t("rootTabs.all.title")));
361
+ for (const r of roots.slice(0, MAX_ROOT_TABS)) {
362
+ bar.append(mkTab(r.title, state.activeRootId === r.id, () => pickRoot(r.id), `${r.id} \xB7 ${r.title}`));
363
+ }
364
+ const overflow = roots.slice(MAX_ROOT_TABS);
365
+ if (overflow.length) {
366
+ const more = document.createElement("button");
367
+ more.className = "root-tab more";
368
+ more.textContent = `\u2026 ${overflow.length}`;
369
+ more.onclick = (e) => {
370
+ popover(
371
+ overflow.map((r) => ({ label: `${r.id} \xB7 ${r.title}`, onClick: () => pickRoot(r.id) })),
372
+ e.currentTarget.getBoundingClientRect()
373
+ );
374
+ };
375
+ bar.append(more);
376
+ }
377
+ }
378
+ $("#btn-root").onclick = async () => {
379
+ const title = window.prompt(t("app.rootPrompt"));
380
+ if (!title?.trim()) return;
381
+ const node = await api("POST", "/nodes", { title: title.trim() });
382
+ state.selectedId = node.id;
383
+ };
384
+ function applyTheme(t2) {
385
+ if (t2 === "light" || t2 === "dark") document.documentElement.setAttribute("data-theme", t2);
386
+ else document.documentElement.removeAttribute("data-theme");
387
+ }
388
+ const themeSel = $("#sel-theme");
389
+ themeSel.value = (() => {
390
+ try {
391
+ const t2 = localStorage.getItem("otree-theme");
392
+ return t2 === "light" || t2 === "dark" ? t2 : "system";
393
+ } catch {
394
+ return "system";
395
+ }
396
+ })();
397
+ themeSel.onchange = () => {
398
+ const t2 = themeSel.value;
399
+ try {
400
+ if (t2 === "system") localStorage.removeItem("otree-theme");
401
+ else localStorage.setItem("otree-theme", t2);
402
+ } catch {
403
+ }
404
+ applyTheme(t2);
405
+ };
406
+ $("#sel-lang").onchange = (e) => {
407
+ const v = e.target.value;
408
+ void api("POST", "/language", { language: v || null });
409
+ const nav = (navigator.language || "ko").slice(0, 2);
410
+ const uiLoc = v === "ko" || v === "en" || v === "ja" ? v : nav === "en" ? "en" : nav === "ja" ? "ja" : "ko";
411
+ setLocale(uiLoc);
412
+ };
413
+ document.documentElement.lang = currentLocale();
414
+ applyDataI18n();
415
+ onLocaleChange(() => {
416
+ refresh();
417
+ void explorer.refresh();
418
+ void chat.setNode(state.selectedId ? state.tree.nodes[state.selectedId] ?? null : null);
419
+ });
420
+ $("#sel-style").onchange = (e) => {
421
+ void api("PATCH", "/settings", { style: e.target.value });
422
+ };
423
+ $("#sel-status").onchange = async (e) => {
424
+ const value = e.target.value;
425
+ const id = state.selectedId;
426
+ if (!id || !value) return;
427
+ if (value === "waiting_handoff") {
428
+ const v = await formModal({
429
+ title: t("app.handoff.title"),
430
+ submitLabel: t("app.handoff.submit"),
431
+ fields: [{ name: "handoffPath", label: t("app.handoff.path"), value: state.tree.nodes[id]?.handoffPath ?? "", placeholder: "docs/handoff.md" }]
432
+ });
433
+ if (v === null) {
434
+ refresh();
435
+ return;
436
+ }
437
+ await api("PATCH", `/nodes/${id}`, { status: value, handoffPath: String(v.handoffPath ?? "").trim() || null });
438
+ return;
439
+ }
440
+ void api("PATCH", `/nodes/${id}`, { status: value });
441
+ };
442
+ $("#btn-del").onclick = () => {
443
+ if (state.selectedId) void deleteNode(state.selectedId);
444
+ };
445
+ $("#btn-archive").onclick = () => {
446
+ state.showArchived = !state.showArchived;
447
+ $("#btn-archive").classList.toggle("on", state.showArchived);
448
+ refresh();
449
+ };
450
+ $("#btn-quit").onclick = async () => {
451
+ if (!window.confirm(t("app.quit.confirm"))) return;
452
+ try {
453
+ await fetch("/api/shutdown", { method: "POST" });
454
+ } catch {
455
+ }
456
+ document.body.innerHTML = `<div style="padding:40px;font:16px system-ui;color:var(--text-3)">${t("app.quit.done")}</div>`;
457
+ };
458
+ const reportBtn = $("#btn-report");
459
+ async function openReport() {
460
+ const v = await reportModal({ body: chat.lastAssistantText() });
461
+ if (v === null) return;
462
+ const ctx = { appVersion: appReportVersion, locale: currentLocale() };
463
+ if (state.selectedId) ctx.nodeId = state.selectedId;
464
+ const res = await api("POST", "/report", {
465
+ title: v.title,
466
+ body: v.body,
467
+ context: ctx,
468
+ ...v.images.length ? { images: v.images } : {}
469
+ });
470
+ if (res?.ok) toast(t("report.toast.success"), "success");
471
+ }
472
+ reportBtn.onclick = () => {
473
+ void openReport();
474
+ };
475
+ const pairBtn = $("#btn-pair");
476
+ pairBtn.onclick = () => openPairingPanel();
477
+ function applyPairGate(enabled) {
478
+ pairBtn.hidden = !enabled;
479
+ pairBtn.disabled = !enabled;
480
+ }
481
+ const settingsBtn = $("#btn-settings");
482
+ settingsBtn.onclick = () => void openConnectionSettings();
483
+ function applySettingsGate(enabled) {
484
+ settingsBtn.hidden = !enabled;
485
+ settingsBtn.disabled = !enabled;
486
+ }
487
+ let appReportVersion = "";
488
+ function applyReportGate(enabled) {
489
+ reportBtn.hidden = !enabled;
490
+ reportBtn.disabled = !enabled;
491
+ }
492
+ fetch("/api/config").then((r) => r.json()).then((c) => {
493
+ appReportVersion = c.version ?? "";
494
+ applyReportGate(c.reportEnabled === true);
495
+ applyPairGate(c.pairEnabled === true);
496
+ applySettingsGate(c.setupEditable === true);
497
+ if (c.claudeMissing) toast(t("claude.missing"), "error", 8e3);
498
+ }).catch(() => {
499
+ applyReportGate(false);
500
+ applyPairGate(false);
501
+ applySettingsGate(false);
502
+ });
503
+ $("#zoom-out").onclick = () => canvas.zoomBy(1.2);
504
+ $("#zoom-in").onclick = () => canvas.zoomBy(1 / 1.2);
505
+ $("#zoom-fit").onclick = () => canvas.fitView();
506
+ $("#zoom-arrange").onclick = async () => {
507
+ await api("POST", "/arrange");
508
+ canvas.fitView();
509
+ };
510
+ let viewerWin = null;
511
+ const viewerChannel = new BroadcastChannel("otree-viewer");
512
+ function openViewer(path) {
513
+ if (!viewerWin || viewerWin.closed) {
514
+ const h = Math.min(screen.availHeight - 60, 1100);
515
+ const w = Math.round(h * 3 / 4);
516
+ viewerWin = window.open(
517
+ `/view?path=${encodeURIComponent(path)}`,
518
+ "otree-viewer",
519
+ `width=${w},height=${h},menubar=no,toolbar=no`
520
+ );
521
+ } else {
522
+ viewerChannel.postMessage({ path });
523
+ viewerWin.focus();
524
+ }
525
+ }
526
+ function makeResizer(handleId, cssVar, storeKey, side) {
527
+ const root = document.documentElement;
528
+ const saved = parseInt(localStorage.getItem(storeKey) ?? "", 10);
529
+ if (saved) root.style.setProperty(cssVar, `${saved}px`);
530
+ const handle = document.querySelector(`#${handleId}`);
531
+ if (!handle) return;
532
+ const MIN = 200, GAP = 360;
533
+ let dragging = false;
534
+ const clamp = (w) => Math.max(MIN, Math.min(w, window.innerWidth - GAP));
535
+ handle.addEventListener("pointerdown", (e) => {
536
+ dragging = true;
537
+ handle.setPointerCapture(e.pointerId);
538
+ handle.classList.add("dragging");
539
+ document.body.classList.add("resizing");
540
+ });
541
+ handle.addEventListener("pointermove", (e) => {
542
+ if (!dragging) return;
543
+ const w = clamp(side === "left" ? e.clientX : window.innerWidth - e.clientX);
544
+ root.style.setProperty(cssVar, `${w}px`);
545
+ });
546
+ const stop = (e) => {
547
+ if (!dragging) return;
548
+ dragging = false;
549
+ handle.releasePointerCapture(e.pointerId);
550
+ handle.classList.remove("dragging");
551
+ document.body.classList.remove("resizing");
552
+ const w = parseInt(getComputedStyle(root).getPropertyValue(cssVar), 10);
553
+ if (w) localStorage.setItem(storeKey, String(w));
554
+ };
555
+ handle.addEventListener("pointerup", stop);
556
+ handle.addEventListener("pointercancel", stop);
557
+ }
558
+ makeResizer("exp-resizer", "--exp-w", "otree-exp-w", "left");
559
+ makeResizer("chat-resizer", "--chat-w", "otree-chat-w", "right");
560
+ let lastUsage = null;
561
+ function fmtReset(iso) {
562
+ if (!iso) return "";
563
+ const ts = new Date(iso).getTime();
564
+ if (isNaN(ts)) return "";
565
+ const diff = ts - Date.now();
566
+ if (diff <= 0) return t("app.usage.resetSoon");
567
+ const min = Math.round(diff / 6e4);
568
+ if (min < 60) return t("app.usage.resetMin", { n: min });
569
+ const h = Math.floor(min / 60), m = min % 60;
570
+ if (h < 24) return t(m ? "app.usage.resetHourMin" : "app.usage.resetHour", { h, m });
571
+ return t("app.usage.resetDay", { n: Math.floor(h / 24) });
572
+ }
573
+ function usageLevel(used) {
574
+ return used >= 90 ? "crit" : used >= 70 ? "warn" : "ok";
575
+ }
576
+ function usageItem(label, w) {
577
+ const used = Math.max(0, Math.min(100, w.used));
578
+ const remain = Math.max(0, 100 - used);
579
+ const reset = fmtReset(w.resetsAt);
580
+ const tip = `${t("app.usage.tip", { label, used, remain })}${reset ? ` \xB7 ${reset}` : ""}`;
581
+ return `<span class="usage-item ${usageLevel(used)}" title="${tip}">
582
+ <span class="ulabel">${label}</span>
583
+ <span class="ubar"><i style="width:${used}%"></i></span>
584
+ <span class="upct">${used}%</span>
585
+ </span>`;
586
+ }
587
+ function renderUsage(u) {
588
+ const el = $("#usage");
589
+ if (u && "available" in u && u.available) lastUsage = u;
590
+ const view = u && "available" in u && u.available ? u : lastUsage;
591
+ if (!view || !view.available) {
592
+ el.hidden = true;
593
+ el.innerHTML = "";
594
+ return;
595
+ }
596
+ const parts = [];
597
+ if (view.fiveHour) parts.push(usageItem(t("app.usage.fiveHour"), view.fiveHour));
598
+ if (view.sevenDay) parts.push(usageItem(t("app.usage.weekly"), view.sevenDay));
599
+ if (!parts.length) {
600
+ el.hidden = true;
601
+ el.innerHTML = "";
602
+ return;
603
+ }
604
+ el.innerHTML = `<span class="usage-cap" title="${t("app.usage.cap")}">\u{1F4CA}</span>` + parts.join("");
605
+ el.hidden = false;
606
+ }
607
+ function connect() {
608
+ const es = new EventSource("/api/events");
609
+ es.addEventListener("tree", (e) => {
610
+ state.tree = JSON.parse(e.data);
611
+ refresh();
612
+ explorer.setLocks(state.tree.locks ?? {});
613
+ void explorer.refresh();
614
+ });
615
+ es.addEventListener("node-stream", (e) => chat.onStream(JSON.parse(e.data)));
616
+ es.addEventListener("busy", (e) => {
617
+ const ids = JSON.parse(e.data);
618
+ state.busy = new Set(ids);
619
+ chat.setBusy(ids);
620
+ refresh();
621
+ });
622
+ es.addEventListener("files", () => void explorer.refresh());
623
+ let newtDownloadToasted = false;
624
+ es.addEventListener("newt-acquire", (e) => {
625
+ const p = JSON.parse(e.data);
626
+ if (p.phase === "download" && !newtDownloadToasted) {
627
+ newtDownloadToasted = true;
628
+ toast(t("newt.downloading"), "info", 6e3);
629
+ } else if (p.phase === "done") {
630
+ toast(t("newt.ready"), "success");
631
+ } else if (p.phase === "failed") {
632
+ toast(t("newt.failed"), "error", 8e3);
633
+ }
634
+ });
635
+ es.addEventListener("usage", (e) => renderUsage(JSON.parse(e.data)));
636
+ es.addEventListener("commands", (e) => chat.setCommands(JSON.parse(e.data)));
637
+ es.addEventListener("fork-proposal", (e) => chat.onForkProposal(JSON.parse(e.data)));
638
+ es.addEventListener("node-notice", (e) => chat.onNotice(JSON.parse(e.data)));
639
+ es.addEventListener("permission-request", (e) => chat.onPermissionRequest(JSON.parse(e.data)));
640
+ es.addEventListener("permission-closed", (e) => chat.onPermissionClosed(JSON.parse(e.data)));
641
+ es.onopen = () => $("#conn").classList.remove("off");
642
+ es.onerror = () => $("#conn").classList.add("off");
643
+ }
644
+ connect();
645
+ refresh();
646
+ fetch("/api/usage").then((r) => r.json()).then((u) => renderUsage(u)).catch(() => {
647
+ });
648
+ fetch("/api/commands").then((r) => r.json()).then((c) => chat.setCommands(c)).catch(() => {
649
+ });
650
+ setInterval(() => renderUsage(), 6e4);