bitpull 0.1.24 → 0.1.26

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.
package/ui/ide.js CHANGED
@@ -1,12 +1,15 @@
1
- // BitPull projects: an editor and an AI agent on a folder of this computer.
1
+ // BitPull projects: an editor and an AI agent on a folder of this computer, laid out like a modern code editor.
2
2
  // Rule of this file: nothing from a file, a model or the network is ever turned into HTML. Text goes in
3
- // with textContent or into the editor's models; there is no innerHTML here (a test checks it).
3
+ // with textContent or into the editor's models; icons are built element by element (a test checks it).
4
4
  "use strict";
5
5
  (function () {
6
6
  const BP = window.__BITPULL__ || {};
7
7
  const token = BP.token || "";
8
8
  const $ = (id) => document.getElementById(id);
9
+ // Where the page's files are: /ui/~<build>/, which the webview keeps without asking again.
10
+ const BASE = ((document.currentScript && document.currentScript.src) || "/ui/ide.js").replace(/ide\.js(\?.*)?$/, "");
9
11
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
12
+ const MOD = isMac ? "⌘" : "Ctrl+";
10
13
  const store = {
11
14
  get(k, d) { try { const v = localStorage.getItem(`bitpull.ide.${k}`); return v === null ? d : JSON.parse(v); } catch { return d; } },
12
15
  set(k, v) { try { localStorage.setItem(`bitpull.ide.${k}`, JSON.stringify(v)); } catch { /* private mode */ } },
@@ -27,6 +30,66 @@
27
30
  }
28
31
  const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
29
32
 
33
+ // ---- icons: BitPull's own, drawn as lines ------------------------------------------------------------
34
+ const NS = "http://www.w3.org/2000/svg";
35
+ const ICONS = {
36
+ back: ["M10 4 6 8l4 4"], folder: ["M2 4.5h4.5l1.5 1.5h6v7H2z"], "chevron-down": ["M4 6l4 4 4-4"], "chevron-right": ["M6 4l4 4-4 4"],
37
+ search: ["M7 3a4 4 0 1 1 0 8a4 4 0 1 1 0-8z", "M10 10l3.5 3.5"], "layout-left": ["M2 3h12v10H2z", "M6 3v10", "M2.5 3.5h3v9h-3z"],
38
+ "layout-bottom": ["M2 3h12v10H2z", "M2 9.5h12", "M2.5 10h11v2.5h-11z"], "layout-right": ["M2 3h12v10H2z", "M10 3v10", "M10.5 3.5h3v9h-3z"],
39
+ gear: ["M8 5.5a2.5 2.5 0 1 1 0 5a2.5 2.5 0 1 1 0-5z", "M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.4 3.4l1.4 1.4M11.2 11.2l1.4 1.4M3.4 12.6l1.4-1.4M11.2 4.8l1.4-1.4"],
40
+ files: ["M4 2h5l3 3v9H4z", "M9 2v3h3", "M2 4v11h8"], git: ["M5 3a1.5 1.5 0 1 1 0 3a1.5 1.5 0 1 1 0-3z", "M5 10a1.5 1.5 0 1 1 0 3a1.5 1.5 0 1 1 0-3z", "M11 5a1.5 1.5 0 1 1 0 3a1.5 1.5 0 1 1 0-3z", "M5 6v4M11 8c0 2-6 1-6 2"],
41
+ "new-file": ["M4 2h5l3 3v9H4z", "M9 2v3h3", "M8 8v4M6 10h4"], "new-folder": ["M2 4.5h4.5l1.5 1.5h6v7H2z", "M8 8v3.5M6.25 9.75h3.5"],
42
+ refresh: ["M13 8a5 5 0 1 1-1.5-3.5", "M13 3v2.5h-2.5"], collapse: ["M3 3h10v10H3z", "M5.5 8h5"], split: ["M2 3h12v10H2z", "M8 3v10"],
43
+ more: ["M3.5 8h.01M8 8h.01M12.5 8h.01"], problems: ["M8 2a6 6 0 1 1 0 12a6 6 0 1 1 0-12z", "M8 5v3.5M8 11h.01"], output: ["M3 4h10M3 7h10M3 10h7M3 13h5"],
44
+ terminal: ["M2 3h12v10H2z", "M4.5 6.5 6.5 8l-2 1.5M8 10h3"], ports: ["M8 2a6 6 0 1 1 0 12a6 6 0 1 1 0-12z", "M2 8h12M8 2c2 2 2 10 0 12M8 2c-2 2-2 10 0 12"],
45
+ play: ["M5 3.5v9l7-4.5z"], maximize: ["M3 10l5-5 5 5"], restore: ["M3 6l5 5 5-5"], close: ["M4 4l8 8M12 4l-8 8"], plus: ["M8 3v10M3 8h10"],
46
+ undo: ["M5 4 2.5 6.5 5 9", "M3 6.5h6.5a3.5 3.5 0 0 1 0 7H6"], stop: ["M4 4h8v8H4z"], send: ["M8 13V3M4 7l4-4 4 4"],
47
+ error: ["M8 2a6 6 0 1 1 0 12a6 6 0 1 1 0-12z", "M6 6l4 4M10 6l-4 4"], warning: ["M8 2.5 14 13H2z", "M8 6.5v3M8 11.3h.01"],
48
+ rename: ["M3 13l1-3 7-7 2 2-7 7z"], trash: ["M3 4.5h10M6 4.5V3h4v1.5M4.5 4.5l.7 9h5.6l.7-9"], check: ["M3.5 8.5l3 3 6-7"],
49
+ logo: ["M8 1.5 14 5v6l-6 3.5L2 11V5z", "M8 8v6.5M8 8l6-3M8 8 2 5"],
50
+ };
51
+ function icon(name) {
52
+ const svg = document.createElementNS(NS, "svg");
53
+ svg.setAttribute("viewBox", "0 0 16 16");
54
+ svg.setAttribute("class", "i");
55
+ svg.setAttribute("aria-hidden", "true");
56
+ for (const d of ICONS[name] || []) { const p = document.createElementNS(NS, "path"); p.setAttribute("d", d); svg.appendChild(p); }
57
+ return svg;
58
+ }
59
+ const drawIcons = (root) => { for (const n of root.querySelectorAll("[data-icon]")) if (!n.querySelector("svg")) n.prepend(icon(n.dataset.icon)); };
60
+
61
+ /** File icons: a coloured mark per kind of file, BitPull's own. */
62
+ const FILE_KINDS = [
63
+ [/\.(ts|tsx|mts|cts)$/i, "#3178c6", "TS"], [/\.(js|mjs|cjs|jsx)$/i, "#d8bc2c", "JS"], [/\.json$/i, "#cbcb41", "{}"], [/\.(md|markdown)$/i, "#519aba", "M"],
64
+ [/\.(html?|vue|svelte)$/i, "#e44d26", "<>"], [/\.(css|scss|sass|less)$/i, "#42a5f5", "#"], [/\.py$/i, "#3572a5", "Py"], [/\.go$/i, "#00add8", "Go"],
65
+ [/\.rs$/i, "#dea584", "Rs"], [/\.(java|kt|kts)$/i, "#b07219", "J"], [/\.(rb)$/i, "#cc342d", "Rb"], [/\.(php)$/i, "#777bb4", "P"], [/\.(c|h|cpp|hpp|cc)$/i, "#659ad2", "C"],
66
+ [/\.(cs)$/i, "#68217a", "C#"], [/\.(sh|bash|zsh|ps1|bat|cmd)$/i, "#89e051", "$"], [/\.(ya?ml|toml|ini|conf|cfg)$/i, "#cb171e", "≡"], [/\.(sql|db|sqlite)$/i, "#e38c00", "Db"],
67
+ [/\.(png|jpe?g|gif|webp|svg|ico|bmp)$/i, "#a074c4", "▣"], [/\.(lock)$/i, "#8c8c8c", "🔒"], [/\.(txt|log)$/i, "#8c8c8c", "≡"], [/\.(xml|plist)$/i, "#e37933", "<>"],
68
+ ];
69
+ const SPECIAL = { dockerfile: ["#2496ed", "D"], "docker-compose.yml": ["#2496ed", "D"], "compose.yaml": ["#2496ed", "D"], ".gitignore": ["#f14e32", "G"], ".gitattributes": ["#f14e32", "G"], ".env": ["#e5c07b", "$"], ".env.example": ["#e5c07b", "$"], license: ["#d0bf41", "©"], makefile: ["#6d8086", "M"], "package.json": ["#cb3837", "N"], "package-lock.json": ["#cb3837", "N"] };
70
+ function fileIcon(name, dir, open) {
71
+ const svg = document.createElementNS(NS, "svg");
72
+ svg.setAttribute("viewBox", "0 0 16 16");
73
+ svg.setAttribute("class", "f");
74
+ svg.setAttribute("aria-hidden", "true");
75
+ const add = (tag, attrs) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); svg.appendChild(n); return n; };
76
+ if (dir) {
77
+ const muted = /^(node_modules|\.git|dist|build|\.next|\.cache|coverage|target|out)$/.test(name);
78
+ const color = muted ? "#7a7a7a" : name === "src" ? "#42a5f5" : /^(test|tests|__tests__|spec)$/.test(name) ? "#4caf50" : /^\./.test(name) ? "#8a8a8a" : "#dcb67a";
79
+ add("path", { d: open ? "M1.5 4h4l1.3 1.3H14v1.2H4.2L2.5 12.5h-1z M4.2 6.5H15l-2 6H2.5z" : "M1.5 3.5h4.3l1.4 1.4h7.3v8.6h-13z", fill: color });
80
+ return svg;
81
+ }
82
+ const low = name.toLowerCase();
83
+ const k = SPECIAL[low] || (low.startsWith(".env") ? SPECIAL[".env"] : null) || (FILE_KINDS.find(([re]) => re.test(name)) || []).slice(1);
84
+ const [color, mark] = k && k.length ? k : ["#8c8c8c", "≡"];
85
+ if (mark.length <= 2 && /[A-Za-z#${<©▣≡]/.test(mark)) {
86
+ add("rect", { x: "1.5", y: "2.5", width: "13", height: "11", rx: "2", fill: color, opacity: ".18" });
87
+ const tx = add("text", { x: "8", y: "11.2", "text-anchor": "middle", "font-size": mark.length > 1 ? "6.6" : "8.5", "font-weight": "700", "font-family": "system-ui, sans-serif", fill: color });
88
+ tx.textContent = mark;
89
+ } else add("path", { d: "M4 1.5h5l3 3v10H4z", fill: color, opacity: ".7" });
90
+ return svg;
91
+ }
92
+
30
93
  // ---- words ------------------------------------------------------------------------------------
31
94
  let dict = {}, fallback = {};
32
95
  const t = (key, vars) => String(dict[key] ?? fallback[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => (vars && vars[k] !== undefined ? vars[k] : `{${k}}`));
@@ -76,6 +139,30 @@
76
139
  document.addEventListener("mouseout", () => { clearTimeout(tipTimer); $("tip").hidden = true; });
77
140
  document.addEventListener("mousedown", () => { clearTimeout(tipTimer); $("tip").hidden = true; });
78
141
 
142
+ // ---- menus -----------------------------------------------------------------------------------------
143
+ /** items: { label, keys, check, run, sep, head, sub } */
144
+ function menu(anchor, items, at) {
145
+ const box = clear($("menu"));
146
+ const ul = el("ul");
147
+ for (const it of items) {
148
+ if (it.sep) { ul.append(el("li", { class: "sep" })); continue; }
149
+ if (it.head) { ul.append(el("li", { class: "head", text: it.head })); continue; }
150
+ ul.append(el("li", { role: "menuitem", onclick: (e) => { e.stopPropagation(); if (it.sub) return menu(e.currentTarget, it.sub, "side"); closeMenu(); it.run && it.run(); } },
151
+ el("span", { class: "check" }, it.check ? icon("check") : null), el("span", { text: it.label }), it.keys ? el("span", { class: "keys", text: it.keys }) : it.sub ? el("span", { class: "keys" }, icon("chevron-right")) : null));
152
+ }
153
+ box.append(ul);
154
+ box.hidden = false;
155
+ const r = anchor.getBoundingClientRect();
156
+ const w = box.offsetWidth, h = box.offsetHeight;
157
+ let x = at === "side" ? r.right + 2 : r.left, y = at === "side" ? r.top : r.bottom + 2;
158
+ if (document.documentElement.dir === "rtl" && at !== "side") x = r.right - w;
159
+ box.style.left = `${Math.max(4, Math.min(x, innerWidth - w - 4))}px`;
160
+ box.style.top = `${Math.max(4, y + h > innerHeight ? r.top - h - 2 : y)}px`;
161
+ }
162
+ const closeMenu = () => { $("menu").hidden = true; };
163
+ document.addEventListener("click", (e) => { if (!e.target.closest || !e.target.closest("#menu")) closeMenu(); }, true);
164
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); });
165
+
79
166
  // ---- a modal of our own (the app's window has no native dialogs everywhere) -------------------------
80
167
  function modal(title, body, buttons) {
81
168
  return new Promise((resolve) => {
@@ -85,7 +172,7 @@
85
172
  const close = (v) => { $("modal").hidden = true; document.removeEventListener("keydown", onKey, true); resolve(v); };
86
173
  const onKey = (e) => { if (e.key === "Escape") { e.preventDefault(); close(null); } };
87
174
  document.addEventListener("keydown", onKey, true);
88
- for (const b of buttons) bar.append(el("button", { class: b.ghost ? "ghost" : b.danger ? "danger" : "", text: b.label, onclick: () => close(b.value === undefined ? true : typeof b.value === "function" ? b.value() : b.value) }));
175
+ for (const b of buttons) bar.append(el("button", { class: b.ghost ? "ghost" : b.danger ? "danger" : "primary", text: b.label, onclick: () => close(b.value === undefined ? true : typeof b.value === "function" ? b.value() : b.value) }));
89
176
  $("modal").hidden = false;
90
177
  const first = $("modal-body").querySelector("input,textarea");
91
178
  (first || bar.lastChild).focus();
@@ -99,24 +186,27 @@
99
186
  const confirmBox = (title, text, yes, danger) => modal(title, el("p", { text }), [{ label: t("pj.no"), ghost: true, value: false }, { label: yes, danger, value: true }]);
100
187
 
101
188
  // ---- state ------------------------------------------------------------------------------------
102
- let monaco = null, editor = null, diffEditor = null;
189
+ let editorLoaded = () => {};
190
+ const editorReady = new Promise((r) => { editorLoaded = r; });
191
+ let monaco = null, editor = null, editor2 = null, diffEditor = null, focused = null;
103
192
  let folder = "";
104
- const tabs = []; // { path, model, mtime, saved, view, binary, secret }
193
+ const tabs = []; // { path, model, mtime, saved, view, secret }
105
194
  let active = null;
106
195
  let models = [];
107
196
  let session = null; // the agent shown on the right
108
197
  let cursor = 0;
109
- let review = null; // { kind, path, onAccept, onReject }
198
+ let review = null;
110
199
  let terminal = null;
200
+ let gitInfo = { branch: "", status: new Map(), ignored: [] };
201
+ let problemCounts = { errors: 0, warnings: 0 };
111
202
 
112
203
  // ---- the terminal is another part of the app, which plugs in here -----------------------------
113
204
  /**
114
205
  * Terminal hook. The terminal registers itself with BitPullIDE.terminal.register(impl), where impl has:
115
- * mount(container, { token, folder, lang, t }) draws itself into the bottom panel, once;
206
+ * mount(container, { token, folder, lang, t }) draws itself into the panel, once;
116
207
  * open() shows it and focuses it; close() hides it; newTab() opens another shell in the folder;
117
- * runVisible(command) types a command in a visible tab, for the person to see (it still runs only on
118
- * this computer, in this page; models never reach it).
119
- * folderChanged(folder) optional: the project's folder changed.
208
+ * runVisible(command) shows a command the agent runs, read-only (it runs only on this computer, through
209
+ * the Projects path with its approvals; models never reach it); folderChanged(folder) optional.
120
210
  */
121
211
  window.BitPullIDE = {
122
212
  terminal: {
@@ -124,8 +214,20 @@
124
214
  terminal = impl;
125
215
  clear($("terminal-host"));
126
216
  impl.mount($("terminal-host"), { token, folder, lang, t });
127
- // The terminal tab may already be the one showing: then it starts at once, as if it had been opened.
128
- if (sizes.bottomOpen && document.querySelector('[data-bottom="terminal"]').classList.contains("on")) impl.open();
217
+ // Its buttons become icons, like the rest of the panel; the words stay as their name.
218
+ const TERM_ICONS = { "term.new": "plus", "term.split": "split", "term.close": "trash" };
219
+ const iconize = () => {
220
+ for (const b of $("terminal-host").querySelectorAll(".bp-term-bar button[data-action]")) {
221
+ const name = TERM_ICONS[b.dataset.action];
222
+ if (!name || b.classList.contains("ib")) continue;
223
+ b.setAttribute("aria-label", b.textContent);
224
+ b.textContent = "";
225
+ b.classList.add("ib");
226
+ b.append(icon(name));
227
+ }
228
+ };
229
+ new MutationObserver(iconize).observe($("terminal-host"), { childList: true, subtree: true });
230
+ if (layoutState.panelOpen && document.querySelector('[data-bottom="terminal"]').classList.contains("on")) impl.open();
129
231
  },
130
232
  },
131
233
  get folder() { return folder; },
@@ -133,72 +235,195 @@
133
235
  function showTerminal() {
134
236
  showBottom("terminal");
135
237
  if (terminal) terminal.open();
136
- else clear($("terminal-host")).append(el("p", { class: "muted", text: t("ide.terminal.missing") }));
238
+ else clear($("terminal-host")).append(el("p", { class: "muted pad", text: t("ide.terminal.missing") }));
137
239
  }
138
240
 
139
- // ---- layout: panels that can be resized, and are remembered --------------------------------------
140
- const sizes = Object.assign({ side: 260, agent: 360, bottom: 200, sideOpen: true, bottomOpen: true }, store.get("sizes", {}));
141
- function applySizes() {
142
- const root = document.documentElement.style;
143
- root.setProperty("--side", `${sizes.sideOpen ? sizes.side : 0}px`);
144
- root.setProperty("--agent", `${sizes.agent}px`);
145
- root.setProperty("--bottom", `${sizes.bottom}px`);
146
- $("side").hidden = !sizes.sideOpen;
147
- $("bottom").hidden = !sizes.bottomOpen;
148
- store.set("sizes", sizes);
149
- if (editor) editor.layout();
150
- if (diffEditor) diffEditor.layout();
151
- }
152
- for (const s of document.querySelectorAll(".split")) {
241
+ // ---- layout: a grid whose parts can be shown, hidden, moved and resized, and are remembered --------------
242
+ const layoutState = Object.assign(
243
+ { side: 260, agent: 380, panel: 240, panelWide: 420, sideOpen: true, agentOpen: true, panelOpen: true, panelPos: "bottom", panelAlign: "center", panelMax: false, icons: false, sideView: "files", panes: { tree: true, outline: false, history: false } },
244
+ store.get("layout", {}),
245
+ );
246
+ const narrow = () => innerWidth < 900;
247
+ function layout() {
248
+ const L = layoutState;
249
+ const ws = $("workbench");
250
+ $("ide").classList.toggle("narrow", narrow());
251
+ $("side").hidden = !L.sideOpen;
252
+ $("agent").hidden = !L.agentOpen;
253
+ $("panel").hidden = !L.panelOpen;
254
+ for (const [id, on] of [["toggle-side", L.sideOpen], ["toggle-panel", L.panelOpen], ["toggle-agent", L.agentOpen]]) $(id).classList.toggle("on", on);
255
+ $("panel").className = `at-${L.panelPos}${L.icons ? " icons" : ""}`;
256
+ const sashes = { side: ws.querySelector('[data-split="side"]'), panel: ws.querySelector('[data-split="panel"]'), agent: ws.querySelector('[data-split="agent"]') };
257
+ sashes.side.hidden = !L.sideOpen;
258
+ sashes.agent.hidden = !L.agentOpen;
259
+ sashes.panel.hidden = !L.panelOpen || L.panelMax;
260
+ sashes.side.className = "sash v";
261
+ sashes.agent.className = "sash v";
262
+ sashes.panel.className = `sash ${L.panelPos === "left" || L.panelPos === "right" ? "v" : "h"}`;
263
+ $("editor-area").hidden = L.panelOpen && L.panelMax;
264
+ $("panel-max").replaceChildren(icon(L.panelMax ? "restore" : "maximize"));
265
+ if (narrow()) {
266
+ ws.style.gridTemplateColumns = "minmax(0, 1fr)";
267
+ const rows = ["act"];
268
+ const heights = ["auto"];
269
+ if (L.sideOpen) { rows.push("side"); heights.push("34vh"); }
270
+ if (!(L.panelOpen && L.panelMax)) { rows.push("edit"); heights.push("62vh"); }
271
+ if (L.panelOpen) { rows.push("panel"); heights.push(L.panelMax ? "70vh" : "34vh"); }
272
+ if (L.agentOpen) { rows.push("agent"); heights.push("80vh"); }
273
+ ws.style.gridTemplateAreas = rows.map((r) => `"${r}"`).join(" ");
274
+ ws.style.gridTemplateRows = heights.join(" ");
275
+ } else {
276
+ const side = L.sideOpen ? `${L.side}px` : "0px";
277
+ const agent = L.agentOpen ? `${L.agent}px` : "0px";
278
+ const s1 = L.sideOpen ? "4px" : "0px", s3 = L.agentOpen ? "4px" : "0px";
279
+ const panelOn = L.panelOpen;
280
+ const max = panelOn && L.panelMax;
281
+ if (!panelOn || L.panelPos === "bottom" || L.panelPos === "top") {
282
+ const ph = !panelOn ? "0px" : max ? "minmax(0, 1fr)" : `${L.panel}px`;
283
+ const eh = max ? "0px" : "minmax(0, 1fr)";
284
+ const s2 = panelOn && !max ? "4px" : "0px";
285
+ const justify = L.panelAlign === "justify";
286
+ const top = L.panelPos === "top";
287
+ const editRow = `act side s1 edit s3 agent`;
288
+ const panelRow = justify ? `act panel panel panel panel panel` : `act side s1 panel s3 agent`;
289
+ const sashRow = justify ? `act s2 s2 s2 s2 s2` : `act side s1 s2 s3 agent`;
290
+ const areas = top ? [panelRow, sashRow, editRow] : [editRow, sashRow, panelRow];
291
+ ws.style.gridTemplateAreas = areas.map((r) => `"${r}"`).join(" ");
292
+ ws.style.gridTemplateColumns = `48px ${side} ${s1} minmax(0, 1fr) ${s3} ${agent}`;
293
+ ws.style.gridTemplateRows = top ? `${ph} ${s2} ${eh}` : `${eh} ${s2} ${ph}`;
294
+ } else {
295
+ const pw = max ? "minmax(0, 1fr)" : `${L.panelWide}px`;
296
+ const ew = max ? "0px" : "minmax(0, 1fr)";
297
+ const s2 = max ? "0px" : "4px";
298
+ const left = L.panelPos === "left";
299
+ ws.style.gridTemplateAreas = left ? `"act side s1 panel s2 edit s3 agent"` : `"act side s1 edit s2 panel s3 agent"`;
300
+ ws.style.gridTemplateColumns = left ? `48px ${side} ${s1} ${pw} ${s2} ${ew} ${s3} ${agent}` : `48px ${side} ${s1} ${ew} ${s2} ${pw} ${s3} ${agent}`;
301
+ ws.style.gridTemplateRows = "minmax(0, 1fr)";
302
+ }
303
+ }
304
+ for (const p of document.querySelectorAll(".pane")) p.classList.toggle("open", !!L.panes[p.dataset.pane]);
305
+ store.set("layout", L);
306
+ relayout();
307
+ }
308
+ /** Monaco and the terminal measure themselves after the grid has settled. */
309
+ function relayout() {
310
+ requestAnimationFrame(() => {
311
+ for (const ed of [editor, editor2, diffEditor]) if (ed) ed.layout();
312
+ if (terminal && terminal.fit) terminal.fit();
313
+ });
314
+ }
315
+ addEventListener("resize", () => layout());
316
+ for (const s of document.querySelectorAll(".sash")) {
153
317
  s.addEventListener("mousedown", (e) => {
154
318
  e.preventDefault();
155
319
  const which = s.dataset.split;
156
- const start = { x: e.clientX, y: e.clientY, v: sizes[which] };
320
+ const L = layoutState;
157
321
  const rtl = document.documentElement.dir === "rtl";
322
+ const vertical = s.classList.contains("v");
323
+ const start = { x: e.clientX, y: e.clientY, side: L.side, agent: L.agent, panel: L.panel, panelWide: L.panelWide };
324
+ s.classList.add("drag");
158
325
  const move = (m) => {
159
- const dx = (m.clientX - start.x) * (rtl ? -1 : 1);
160
- if (which === "side") sizes.side = Math.min(600, Math.max(160, start.v + dx));
161
- if (which === "agent") sizes.agent = Math.min(800, Math.max(260, start.v - dx));
162
- if (which === "bottom") sizes.bottom = Math.min(innerHeight - 160, Math.max(80, start.v - (m.clientY - start.y)));
163
- applySizes();
326
+ const dx = (m.clientX - start.x) * (rtl ? -1 : 1), dy = m.clientY - start.y;
327
+ if (which === "side") L.side = Math.min(700, Math.max(170, start.side + dx));
328
+ if (which === "agent") L.agent = Math.min(900, Math.max(280, start.agent - dx));
329
+ if (which === "panel" && !vertical) L.panel = Math.min(innerHeight - 140, Math.max(90, start.panel + (L.panelPos === "top" ? dy : -dy)));
330
+ if (which === "panel" && vertical) L.panelWide = Math.min(innerWidth - 400, Math.max(220, start.panelWide + (L.panelPos === "left" ? dx : -dx)));
331
+ layout();
164
332
  };
165
- const up = () => { removeEventListener("mousemove", move); removeEventListener("mouseup", up); };
333
+ const up = () => { s.classList.remove("drag"); removeEventListener("mousemove", move); removeEventListener("mouseup", up); };
166
334
  addEventListener("mousemove", move);
167
335
  addEventListener("mouseup", up);
168
336
  });
337
+ s.addEventListener("dblclick", () => {
338
+ const which = s.dataset.split;
339
+ Object.assign(layoutState, which === "side" ? { side: 260 } : which === "agent" ? { agent: 380 } : { panel: 240, panelWide: 420 });
340
+ layout();
341
+ });
169
342
  }
343
+ const toggle = (k) => { layoutState[k] = !layoutState[k]; if (k === "panelOpen" && !layoutState.panelOpen) layoutState.panelMax = false; layout(); };
344
+ $("toggle-side").addEventListener("click", () => toggle("sideOpen"));
345
+ $("toggle-panel").addEventListener("click", () => { if (!layoutState.panelOpen) showBottom(currentBottom()); else toggle("panelOpen"); });
346
+ $("toggle-agent").addEventListener("click", () => toggle("agentOpen"));
347
+ $("panel-max").addEventListener("click", () => toggle("panelMax"));
348
+
170
349
  function showSide(name) {
171
- sizes.sideOpen = true;
172
- for (const b of document.querySelectorAll("#activity button")) b.classList.toggle("on", b.dataset.side === name);
173
- for (const p of document.querySelectorAll("#side section")) p.hidden = p.dataset.panel !== name;
174
- applySizes();
350
+ layoutState.sideOpen = true;
351
+ layoutState.sideView = name;
352
+ for (const b of document.querySelectorAll("#activity button[data-side]")) b.classList.toggle("on", b.dataset.side === name);
353
+ for (const p of document.querySelectorAll("#side > section")) p.hidden = p.dataset.panel !== name;
354
+ layout();
175
355
  if (name === "git") gitRefresh();
176
- if (name === "outline") renderOutline();
177
- if (name === "history") renderHistory();
356
+ if (name === "outline" || name === "files") renderOutline();
357
+ if (name === "history" || name === "files") renderHistory();
178
358
  if (name === "search") $("search-q").focus();
179
359
  }
180
- for (const b of document.querySelectorAll("#activity button")) b.addEventListener("click", () => {
181
- if (b.classList.contains("on") && sizes.sideOpen) { sizes.sideOpen = false; applySizes(); } else showSide(b.dataset.side);
360
+ for (const b of document.querySelectorAll("#activity button[data-side]")) b.addEventListener("click", () => {
361
+ if (b.classList.contains("on") && layoutState.sideOpen) { layoutState.sideOpen = false; layout(); } else showSide(b.dataset.side);
362
+ });
363
+ $("views-more").addEventListener("click", (e) => menu(e.currentTarget, [
364
+ { label: t("ide.explorer"), keys: `${MOD}⇧E`, run: () => showSide("files") },
365
+ { label: t("ide.search"), keys: `${MOD}⇧F`, run: () => showSide("search") },
366
+ { label: t("ide.git"), keys: isMac ? "⌃⇧G" : "Ctrl+Shift+G", run: () => showSide("git") },
367
+ { label: t("ide.outline"), run: () => showSide("outline") },
368
+ { label: t("ide.history"), run: () => showSide("history") },
369
+ ], "side"));
370
+ for (const p of document.querySelectorAll(".pane")) p.querySelector(".pane-head").addEventListener("click", (e) => {
371
+ if (e.target.closest(".pane-acts")) return;
372
+ layoutState.panes[p.dataset.pane] = !layoutState.panes[p.dataset.pane];
373
+ layout();
374
+ if (p.dataset.pane === "outline") renderOutline();
375
+ if (p.dataset.pane === "history") renderHistory();
182
376
  });
377
+
378
+ const currentBottom = () => (document.querySelector("[data-bottom].on") || {}).dataset?.bottom || "problems";
183
379
  function showBottom(name) {
184
- sizes.bottomOpen = true;
380
+ layoutState.panelOpen = true;
185
381
  for (const b of document.querySelectorAll("[data-bottom]")) b.classList.toggle("on", b.dataset.bottom === name);
186
382
  for (const p of document.querySelectorAll("[data-bpanel]")) p.hidden = p.dataset.bpanel !== name;
187
- applySizes();
383
+ if (name === "ports") { document.querySelector('[data-bottom="ports"]').hidden = false; refreshPorts(); }
384
+ $("problems-run").hidden = name !== "problems";
385
+ layout();
188
386
  }
189
387
  for (const b of document.querySelectorAll("[data-bottom]")) b.addEventListener("click", () => (b.dataset.bottom === "terminal" ? showTerminal() : showBottom(b.dataset.bottom)));
190
- $("bottom-close").addEventListener("click", () => { sizes.bottomOpen = false; applySizes(); if (terminal) terminal.close(); });
388
+ $("bottom-close").addEventListener("click", () => { layoutState.panelOpen = false; layoutState.panelMax = false; layout(); if (terminal) terminal.close(); });
389
+ $("panel-more").addEventListener("click", (e) => {
390
+ const L = layoutState;
391
+ const pos = (p) => ({ label: t(`ide.panel.pos.${p}`), check: L.panelPos === p, run: () => { L.panelPos = p; layout(); } });
392
+ menu(e.currentTarget, [
393
+ { label: t("ide.ports"), run: () => showBottom("ports") },
394
+ { sep: true },
395
+ { label: t("ide.panel.position"), sub: ["bottom", "top", "left", "right"].map(pos) },
396
+ { label: t("ide.panel.align"), sub: ["center", "justify"].map((a) => ({ label: t(`ide.panel.align.${a}`), check: L.panelAlign === a, run: () => { L.panelAlign = a; layout(); } })) },
397
+ { label: t("ide.panel.icons"), check: L.icons, run: () => { L.icons = !L.icons; layout(); } },
398
+ { label: t("ide.panel.hide"), keys: `${MOD}J`, run: () => $("bottom-close").click() },
399
+ ]);
400
+ });
191
401
  const output = (line) => { const o = $("output"); o.textContent += `${line}\n`; if (o.textContent.length > 400_000) o.textContent = o.textContent.slice(-300_000); o.scrollTop = o.scrollHeight; };
192
402
 
403
+ // ---- ports: servers started from the app --------------------------------------------------------
404
+ async function refreshPorts() {
405
+ const body = $("ports").tBodies[0];
406
+ try {
407
+ const r = await api("/api/ws/ports");
408
+ clear(body);
409
+ for (const p of r.ports) body.append(el("tr", {}, el("td", { text: String(p.port) }), el("td", { text: p.address }), el("td", { text: `${p.command} (${p.pid})` }),
410
+ el("td", {}, el("button", { class: "ghost", text: t("ide.ports.open"), "data-tip": "tip.ide.ports.open", onclick: () => api("/api/ws/preview", { url: p.url }).catch((err) => output(explain(err))) }))));
411
+ $("ports-empty").hidden = r.ports.length > 0;
412
+ $("ports-count").hidden = !r.ports.length;
413
+ $("ports-count").textContent = String(r.ports.length);
414
+ if (r.ports.length) document.querySelector('[data-bottom="ports"]').hidden = false;
415
+ } catch (err) { $("ports-empty").hidden = false; $("ports-empty").textContent = explain(err); }
416
+ }
417
+
193
418
  // ---- Monaco, served by the app itself -------------------------------------------------------------
194
419
  const MONACO_LANG = { de: "de", es: "es", fr: "fr", it: "it", ru: "ru", zh: "zh-cn", pt: "pt-br" };
195
420
  function loadMonaco() {
196
421
  return new Promise((resolve, reject) => {
197
422
  const s = document.createElement("script");
198
- s.src = "/ui/monaco/vs/loader.js";
423
+ s.src = `${BASE}monaco/vs/loader.js`;
199
424
  s.onerror = () => reject(new Error("editor_missing"));
200
425
  s.onload = () => {
201
- window.require.config({ paths: { vs: "/ui/monaco/vs" }, "vs/nls": { availableLanguages: { "*": MONACO_LANG[lang] || "en" } } });
426
+ window.require.config({ paths: { vs: `${BASE}monaco/vs` }, "vs/nls": { availableLanguages: { "*": MONACO_LANG[lang] || "en" } } });
202
427
  // This version of the loader does not always call back. The global appears early, as soon as the
203
428
  // core is loaded; the editor is complete (stylesheet, languages, all its services) only when the
204
429
  // main module has run, which registers the JSON language: creating an editor before fails.
@@ -211,54 +436,81 @@
211
436
  const m = window.monaco;
212
437
  const langs = m && m.editor && m.languages && m.languages.getLanguages ? m.languages.getLanguages().length : 0;
213
438
  if (langs > 50 && !langsSince) langsSince = Date.now();
214
- if (m && m.editor && m.languages && (m.languages.json || (langsSince && Date.now() - langsSince > 1500))) { clearInterval(wait); resolve(m); }
439
+ if (m && m.editor && m.languages && (m.languages.json || (langsSince && Date.now() - langsSince > 400))) { clearInterval(wait); resolve(m); }
215
440
  else if (Date.now() - started > 30000) { clearInterval(wait); reject(new Error("editor_missing")); }
216
- }, 50);
441
+ }, 25);
217
442
  };
218
443
  document.body.appendChild(s);
219
444
  });
220
445
  }
221
446
  const dark = () => matchMedia("(prefers-color-scheme: dark)").matches;
447
+ function defineThemes() {
448
+ monaco.editor.defineTheme("bitpull-dark", { base: "vs-dark", inherit: true, rules: [], colors: { "editor.background": "#1f1f1f", "editorGutter.background": "#1f1f1f", "minimap.background": "#1f1f1f", "editorLineNumber.foreground": "#6e7681", "editorLineNumber.activeForeground": "#cccccc", "editor.lineHighlightBorder": "#282828", "editorIndentGuide.background1": "#404040", "editorWidget.background": "#202020" } });
449
+ monaco.editor.defineTheme("bitpull-light", { base: "vs", inherit: true, rules: [], colors: { "editor.background": "#ffffff", "editorLineNumber.foreground": "#6e7681" } });
450
+ }
451
+ const theme = () => (dark() ? "bitpull-dark" : "bitpull-light");
452
+ const EDITOR_OPTS = () => ({ automaticLayout: false, theme: theme(), minimap: { enabled: true, renderCharacters: false, scale: 1 }, fontSize: 13, lineHeight: 20, fontFamily: 'Menlo, Monaco, "Cascadia Code", Consolas, "Courier New", monospace', tabSize: 2, renderWhitespace: "selection", smoothScrolling: true, scrollBeyondLastLine: false, fixedOverflowWidgets: true, padding: { top: 4 }, stickyScroll: { enabled: true }, bracketPairColorization: { enabled: true }, guides: { bracketPairs: "active", indentation: true } });
453
+ function wireEditor(ed) {
454
+ ed.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveActive());
455
+ ed.addAction({ id: "bitpull.inline", label: t("ide.inline"), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], contextMenuGroupId: "1_modification", run: () => inlineEdit() });
456
+ ed.onDidFocusEditorText(() => { focused = ed; const m = ed.getModel(); const tab = m && tabs.find((x) => x.model === m); if (tab && tab !== active) { active = tab; renderTabs(); renderCrumbs(); } });
457
+ ed.onDidChangeModelContent(() => { renderTabs(); clearTimeout(outlineTimer); outlineTimer = setTimeout(renderOutline, 600); });
458
+ ed.onDidChangeCursorPosition(() => { markOutline(); renderPos(); });
459
+ }
222
460
  function setupEditor() {
223
- const opts = { automaticLayout: true, theme: dark() ? "vs-dark" : "vs", minimap: { enabled: true }, fontSize: 13, tabSize: 2, renderWhitespace: "selection", smoothScrolling: true, scrollBeyondLastLine: false };
224
- editor = monaco.editor.create($("editor"), { ...opts, model: null });
225
- diffEditor = monaco.editor.createDiffEditor($("diff"), { ...opts, renderSideBySide: true, originalEditable: false });
226
- matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => monaco.editor.setTheme(dark() ? "vs-dark" : "vs"));
227
- editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveActive());
228
- editor.addAction({ id: "bitpull.inline", label: t("ide.inline"), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], contextMenuGroupId: "1_modification", run: () => inlineEdit() });
229
- editor.onDidChangeModelContent(() => { renderTabs(); clearTimeout(outlineTimer); outlineTimer = setTimeout(renderOutline, 600); });
230
- editor.onDidChangeCursorPosition(() => markOutline());
461
+ defineThemes();
462
+ editor = monaco.editor.create($("editor"), { ...EDITOR_OPTS(), model: null });
463
+ diffEditor = monaco.editor.createDiffEditor($("diff"), { ...EDITOR_OPTS(), renderSideBySide: true, originalEditable: false });
464
+ focused = editor;
465
+ wireEditor(editor);
466
+ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => monaco.editor.setTheme(theme()));
467
+ // The editors follow their boxes: one observer, not a timer per editor.
468
+ new ResizeObserver(() => relayout()).observe($("editor-wrap"));
231
469
  }
232
470
  let outlineTimer = null;
233
471
 
234
472
  // ---- files --------------------------------------------------------------------------------------
235
473
  const opened = new Set(["."]);
236
- async function loadTree(path, into) {
237
- const r = await api("/api/ws/tree", { folder, path });
474
+ const treeCache = new Map(); // path → entries, so that reopening a folder draws at once
475
+ const gitLetter = (path) => gitInfo.status.get(path) || null;
476
+ const isIgnored = (path, dir) => gitInfo.ignored.some((p) => (p.endsWith("/") ? `${path}/`.startsWith(p) || path === p.slice(0, -1) : path === p));
477
+ const dirChanged = (path) => { for (const [p, l] of gitInfo.status) if (p.startsWith(`${path}/`) && l !== "D") return l === "U" || l === "A" ? "U" : "M"; return null; };
478
+ async function loadTree(path, into, depth) {
479
+ let entries = treeCache.get(path);
480
+ const fresh = api("/api/ws/tree", { folder, path }).then((r) => { treeCache.set(path, r.entries); return r.entries; });
481
+ if (!entries) entries = await fresh; else fresh.then((e) => { if (JSON.stringify(e) !== JSON.stringify(entries)) drawTree(path, into, depth, e); }).catch(() => {});
482
+ drawTree(path, into, depth, entries);
483
+ }
484
+ function drawTree(path, into, depth, entries) {
238
485
  clear(into);
239
- for (const e of r.entries) {
486
+ for (const e of entries) {
240
487
  const li = el("li", { role: "treeitem" });
241
- const item = el("div", { class: "item", title: e.path },
242
- el("span", { text: e.dir ? (opened.has(e.path) ? "▾" : "▸") : e.secret ? "🔒" : "·", class: "muted" }),
488
+ const open = e.dir && opened.has(e.path);
489
+ const letter = e.dir ? dirChanged(e.path) : gitLetter(e.path);
490
+ const cls = ["item", open ? "open" : "", e.name.startsWith(".") ? "dot" : "", isIgnored(e.path, e.dir) ? "ignored" : "", letter ? `git-${letter}` : "", active && active.path === e.path ? "on" : ""].filter(Boolean).join(" ");
491
+ const item = el("div", { class: cls, title: e.path, tabindex: "-1", style: `padding-inline-start:${8 + depth * 8}px` },
492
+ el("span", { class: "twist" }, e.dir ? icon("chevron-right") : null),
493
+ e.secret ? icon("warning") : fileIcon(e.name, e.dir, open),
243
494
  el("span", { class: "name", text: e.name }),
495
+ letter && !e.dir ? el("span", { class: "git", text: letter }) : letter ? el("span", { class: "git", text: "•" }) : null,
244
496
  el("span", { class: "acts" },
245
- el("button", { class: "icon", "data-tip": "tip.ide.rename", text: "✎", onclick: (ev) => { ev.stopPropagation(); renamePath(e.path); } }),
246
- el("button", { class: "icon", "data-tip": "tip.ide.delete", text: "🗑", onclick: (ev) => { ev.stopPropagation(); deletePath(e.path, e.dir); } })));
247
- if (active && active.path === e.path) item.classList.add("on");
497
+ el("button", { class: "ib", "data-tip": "tip.ide.rename", onclick: (ev) => { ev.stopPropagation(); renamePath(e.path); } }, icon("rename")),
498
+ el("button", { class: "ib", "data-tip": "tip.ide.delete", onclick: (ev) => { ev.stopPropagation(); deletePath(e.path, e.dir); } }, icon("trash"))));
499
+ for (let d = 0; d < depth; d++) item.append(el("span", { class: "guide", style: `inset-inline-start:${16 + d * 8}px` }));
248
500
  li.append(item);
249
501
  if (e.dir) {
250
502
  const sub = el("ul", { role: "group" });
251
503
  li.append(sub);
252
- if (opened.has(e.path)) loadTree(e.path, sub).catch(() => {});
504
+ if (open) loadTree(e.path, sub, depth + 1).catch(() => {});
253
505
  item.addEventListener("click", () => {
254
- if (opened.has(e.path)) { opened.delete(e.path); clear(sub); } else { opened.add(e.path); loadTree(e.path, sub).catch((err) => output(explain(err))); }
255
- item.firstChild.textContent = opened.has(e.path) ? "▾" : "▸";
506
+ if (opened.has(e.path)) { opened.delete(e.path); clear(sub); item.classList.remove("open"); item.replaceChild(fileIcon(e.name, true, false), item.children[1]); }
507
+ else { opened.add(e.path); item.classList.add("open"); item.replaceChild(fileIcon(e.name, true, true), item.children[1]); loadTree(e.path, sub, depth + 1).catch((err) => output(explain(err))); }
256
508
  });
257
509
  } else item.addEventListener("click", () => openFile(e.path));
258
510
  into.append(li);
259
511
  }
260
512
  }
261
- const refreshTree = () => folder && loadTree(".", $("tree")).catch((err) => output(explain(err)));
513
+ const refreshTree = () => { if (!folder) return; treeCache.clear(); loadTree(".", $("tree"), 0).catch((err) => output(explain(err))); };
262
514
  async function newPath(dir) {
263
515
  const name = await askText(t(dir ? "ide.new_dir" : "ide.new_file"), t("ide.new.hint"), active && !dir ? active.path.replace(/[^/]*$/, "") : "");
264
516
  if (!name) return;
@@ -280,27 +532,56 @@
280
532
  }
281
533
  $("new-file").addEventListener("click", () => newPath(false));
282
534
  $("new-dir").addEventListener("click", () => newPath(true));
283
- $("refresh").addEventListener("click", refreshTree);
535
+ $("refresh").addEventListener("click", () => { refreshTree(); refreshGit(); });
536
+ $("collapse-all").addEventListener("click", () => { opened.clear(); opened.add("."); refreshTree(); });
284
537
 
285
- // ---- tabs and the editor --------------------------------------------------------------------------
538
+ // ---- tabs, breadcrumbs, the editor ------------------------------------------------------------------
286
539
  const isDirty = (tab) => tab.model && tab.model.getAlternativeVersionId() !== tab.saved;
287
540
  function renderTabs() {
288
541
  const bar = clear($("tabs"));
289
542
  for (const tab of tabs) {
290
543
  const name = tab.path.split("/").pop();
291
- bar.append(el("div", { class: `tab${tab === active ? " on" : ""}`, role: "tab", title: tab.path, onclick: () => activate(tab) },
292
- el("span", { text: name }), isDirty(tab) ? el("span", { class: "dirty", text: "●" }) : null,
293
- el("button", { "data-tip": "tip.ide.close", text: "×", onclick: (e) => { e.stopPropagation(); closeTab(tab); } })));
544
+ bar.append(el("div", { class: `tab${tab === active ? " on" : ""}${isDirty(tab) ? " dirty" : ""}`, role: "tab", title: tab.path, onclick: () => activate(tab), onauxclick: (e) => { if (e.button === 1) closeTab(tab); } },
545
+ fileIcon(name, false), el("span", { text: name }),
546
+ el("button", { class: "x", "data-tip": "tip.ide.close", onclick: (e) => { e.stopPropagation(); closeTab(tab); } }, icon("close"))));
294
547
  }
548
+ const on = bar.querySelector(".tab.on");
549
+ if (on) on.scrollIntoView({ block: "nearest", inline: "nearest" });
295
550
  $("welcome").hidden = !!active || !!review;
296
551
  $("editor").hidden = !active || !!review;
552
+ $("editor2").hidden = !splitOn || !active || !!review;
553
+ $("crumbs").hidden = !active || !!review;
554
+ }
555
+ function renderCrumbs() {
556
+ const c = clear($("crumbs"));
557
+ if (!active) return;
558
+ const parts = active.path.split("/");
559
+ parts.forEach((p, i) => {
560
+ if (i) c.append(el("span", { class: "sep" }, icon("chevron-right")));
561
+ c.append(el("span", { class: "c" }, i === parts.length - 1 ? fileIcon(p, false) : null, el("span", { text: p })));
562
+ });
563
+ renderPos();
564
+ }
565
+ function renderPos() {
566
+ const ed = focused || editor;
567
+ const pos = ed && ed.getPosition();
568
+ $("sb-pos").hidden = !active || !pos;
569
+ if (pos) $("sb-pos").textContent = t("ide.status.pos", { line: pos.lineNumber, col: pos.column });
570
+ const m = active && active.model;
571
+ $("sb-lang").hidden = !m;
572
+ $("sb-enc").hidden = !m;
573
+ if (m) { const id = m.getLanguageId(); const l = monaco.languages.getLanguages().find((x) => x.id === id); $("sb-lang").textContent = (l && l.aliases && l.aliases[0]) || id; }
297
574
  }
298
575
  async function openFile(path, line) {
299
- if (!monaco) return;
300
576
  let tab = tabs.find((x) => x.path === path);
577
+ let r;
578
+ if (!tab) {
579
+ // The file is read while the editor may still be loading: a click right after opening the page counts.
580
+ try { [r] = await Promise.all([api("/api/ws/read", { folder, path }), editorReady]); } catch (err) { output(explain(err)); return; }
581
+ if (!monaco) return;
582
+ tab = tabs.find((x) => x.path === path);
583
+ }
301
584
  if (!tab) {
302
- let r;
303
- try { r = await api("/api/ws/read", { folder, path }); } catch (err) { output(explain(err)); return; }
304
585
  if (r.binary) { output(t("ide.binary", { path })); return; }
305
586
  const uri = monaco.Uri.from({ scheme: "file", path: `/${path}` });
306
587
  const model = monaco.editor.getModel(uri) || monaco.editor.createModel(r.text, undefined, uri);
@@ -309,26 +590,32 @@
309
590
  if (r.secret) output(t("ide.secret_open", { path }));
310
591
  }
311
592
  activate(tab);
312
- if (line) { editor.revealLineInCenter(line); editor.setPosition({ lineNumber: line, column: 1 }); editor.focus(); }
593
+ const ed = focused || editor;
594
+ if (line) { ed.revealLineInCenter(line); ed.setPosition({ lineNumber: line, column: 1 }); ed.focus(); }
313
595
  }
314
596
  function activate(tab) {
315
- if (active && active.model) active.view = editor.saveViewState();
597
+ const ed = focused || editor;
598
+ if (active && active.model && ed.getModel() === active.model) active.view = ed.saveViewState();
316
599
  active = tab;
317
- closeReview();
318
- editor.setModel(tab.model);
319
- if (tab.view) editor.restoreViewState(tab.view);
600
+ if (review) closeReview();
601
+ if (ed.getModel() !== tab.model) ed.setModel(tab.model);
602
+ if (tab.view) ed.restoreViewState(tab.view);
320
603
  renderTabs();
321
- renderOutline();
322
- if (!$("side").hidden && !document.querySelector('#side section[data-panel="history"]').hidden) renderHistory();
604
+ renderCrumbs();
605
+ relayout();
606
+ clearTimeout(outlineTimer);
607
+ outlineTimer = setTimeout(() => { renderOutline(); if (layoutState.panes.history || layoutState.sideView === "history") renderHistory(); }, 120);
323
608
  for (const n of document.querySelectorAll("#tree .item")) n.classList.toggle("on", n.title === tab.path);
324
- editor.focus();
609
+ ed.focus();
325
610
  }
326
611
  async function closeTab(tab, force) {
327
612
  if (!force && isDirty(tab) && !(await confirmBox(t("ide.unsaved"), t("ide.unsaved.text", { path: tab.path }), t("ide.discard"), true))) return;
328
613
  tabs.splice(tabs.indexOf(tab), 1);
329
- if (active === tab) { active = null; editor.setModel(null); if (tabs.length) activate(tabs[tabs.length - 1]); }
614
+ for (const ed of [editor, editor2]) if (ed && ed.getModel() === tab.model) ed.setModel(null);
615
+ if (active === tab) { active = null; if (tabs.length) activate(tabs[tabs.length - 1]); }
330
616
  tab.model.dispose();
331
617
  renderTabs();
618
+ renderCrumbs();
332
619
  }
333
620
  async function saveActive(force) {
334
621
  const tab = active;
@@ -338,6 +625,7 @@
338
625
  tab.mtime = r.mtime;
339
626
  tab.saved = tab.model.getAlternativeVersionId();
340
627
  renderTabs();
628
+ refreshGit();
341
629
  } catch (err) {
342
630
  if (String(err.message) !== "file_changed_on_disk") return output(explain(err));
343
631
  const choice = await modal(t("ide.conflict"), el("p", { text: t("ide.conflict.text", { path: tab.path }) }), [{ label: t("pj.no"), ghost: true, value: null }, { label: t("ide.conflict.reload"), ghost: true, value: "reload" }, { label: t("ide.conflict.overwrite"), danger: true, value: "overwrite" }]);
@@ -353,6 +641,22 @@
353
641
  tab.saved = tab.model.getAlternativeVersionId();
354
642
  renderTabs();
355
643
  }
644
+ // Split editor: a second editor beside the first, on the same file; each keeps its own position.
645
+ let splitOn = false;
646
+ function toggleSplit() {
647
+ splitOn = !splitOn;
648
+ if (splitOn && !editor2) { editor2 = monaco.editor.create($("editor2"), { ...EDITOR_OPTS(), model: null }); wireEditor(editor2); }
649
+ if (editor2) editor2.setModel(splitOn && active ? active.model : null);
650
+ if (!splitOn) focused = editor;
651
+ renderTabs();
652
+ relayout();
653
+ }
654
+ $("split-editor").addEventListener("click", () => { if (active) toggleSplit(); });
655
+ $("editor-more").addEventListener("click", (e) => menu(e.currentTarget, [
656
+ { label: t("ide.save"), keys: `${MOD}S`, run: () => saveActive() },
657
+ { label: t("ide.split"), check: splitOn, run: () => { if (active) toggleSplit(); } },
658
+ { label: t("ide.close.all"), run: async () => { for (const tab of [...tabs]) await closeTab(tab); } },
659
+ ]));
356
660
 
357
661
  // ---- reviewing a change in the editor, piece by piece ---------------------------------------------
358
662
  function closeReview() {
@@ -369,10 +673,10 @@
369
673
  * one by one (it is changed in the right-hand side, which the person then keeps or saves).
370
674
  */
371
675
  function showReview({ title, before, after, path, pieces, accept, reject, acceptLabel, rejectLabel }) {
372
- const lang = monaco.Uri.from({ scheme: "file", path: `/${path || "x.txt"}` });
676
+ const langUri = monaco.Uri.from({ scheme: "file", path: `/${path || "x.txt"}` });
373
677
  const original = monaco.editor.createModel(before ?? "", undefined, monaco.Uri.from({ scheme: "bp-before", path: `/${Date.now()}/${path || "x"}` }));
374
678
  const modified = monaco.editor.createModel(after ?? "", undefined, monaco.Uri.from({ scheme: "bp-after", path: `/${Date.now()}/${path || "x"}` }));
375
- const language = monaco.editor.getModel(lang) ? monaco.editor.getModel(lang).getLanguageId() : null;
679
+ const language = monaco.editor.getModel(langUri) ? monaco.editor.getModel(langUri).getLanguageId() : null;
376
680
  if (language) { monaco.editor.setModelLanguage(original, language); monaco.editor.setModelLanguage(modified, language); }
377
681
  if (review) { diffEditor.setModel(null); review.original.dispose(); review.modified.dispose(); }
378
682
  diffEditor.setModel({ original, modified });
@@ -380,7 +684,9 @@
380
684
  $("review").hidden = false;
381
685
  $("diff").hidden = false;
382
686
  $("editor").hidden = true;
687
+ $("editor2").hidden = true;
383
688
  $("welcome").hidden = true;
689
+ $("crumbs").hidden = true;
384
690
  $("review-title").textContent = title;
385
691
  $("review-accept").textContent = acceptLabel || t("ide.review.keep");
386
692
  $("review-reject").textContent = rejectLabel || t("ide.review.revert_all");
@@ -391,10 +697,11 @@
391
697
  const draw = () => {
392
698
  clear(hunks);
393
699
  const changes = diffEditor.getLineChanges() || [];
394
- changes.forEach((c, i) => hunks.append(el("button", { class: "ghost small", "data-tip": "tip.ide.review.piece", text: t("ide.review.piece", { n: i + 1 }), onclick: () => revertPiece(c, original, modified) })));
700
+ changes.forEach((c, i) => hunks.append(el("button", { class: "ghost", "data-tip": "tip.ide.review.piece", text: t("ide.review.piece", { n: i + 1 }), onclick: () => revertPiece(c, original, modified) })));
395
701
  };
396
702
  diffEditor.onDidUpdateDiff(draw);
397
703
  }
704
+ relayout();
398
705
  }
399
706
  /** Puts one changed piece back as it was. */
400
707
  function revertPiece(c, original, modified) {
@@ -435,24 +742,56 @@
435
742
  } catch (err) { output(explain(err)); }
436
743
  }
437
744
 
438
- // ---- quick open, search -----------------------------------------------------------------------
439
- async function quickOpen() {
440
- if (!folder) return;
441
- const input = el("input", { type: "text", placeholder: t("ide.quick.placeholder") });
745
+ // ---- quick open, the command palette, search -------------------------------------------------------
746
+ function picker(title, placeholder, source, onPick) {
747
+ const input = el("input", { type: "text", placeholder });
442
748
  const list = el("ul", { class: "list" });
443
749
  let results = [], sel = 0;
444
- const draw = () => { clear(list); results.forEach((r, i) => list.append(el("li", { class: i === sel ? "on" : "", text: r.path, onclick: () => pick(r) }))); };
445
- const pick = (r) => { $("modal").hidden = true; openFile(r.path); };
750
+ const draw = () => { clear(list); results.forEach((r, i) => list.append(el("li", { class: i === sel ? "on" : "", onclick: () => pick(r) }, r.icon || null, el("span", { class: "name", text: r.label }), r.keys ? el("span", { class: "keys", text: r.keys }) : null))); const on = list.querySelector("li.on"); if (on) on.scrollIntoView({ block: "nearest" }); };
751
+ const pick = (r) => { $("modal").hidden = true; onPick(r); };
446
752
  let timer = null;
447
- input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(async () => { const q = input.value.trim(); if (!q) { results = []; draw(); return; } try { results = (await api("/api/ws/search", { folder, query: q, names: true })).results.slice(0, 50); sel = 0; draw(); } catch {} }, 150); });
753
+ const update = () => { clearTimeout(timer); timer = setTimeout(async () => { try { results = await source(input.value.trim()); sel = 0; draw(); } catch { results = []; draw(); } }, 80); };
754
+ input.addEventListener("input", update);
448
755
  input.addEventListener("keydown", (e) => {
449
756
  if (e.key === "ArrowDown") { sel = Math.min(results.length - 1, sel + 1); draw(); e.preventDefault(); }
450
757
  if (e.key === "ArrowUp") { sel = Math.max(0, sel - 1); draw(); e.preventDefault(); }
451
758
  if (e.key === "Enter" && results[sel]) pick(results[sel]);
452
759
  });
453
- modal(t("ide.quick"), [input, list], [{ label: t("ide.close"), ghost: true, value: null }]);
760
+ update();
761
+ modal(title, [input, list], [{ label: t("ide.close"), ghost: true, value: null }]);
762
+ }
763
+ function quickOpen() {
764
+ if (!folder) return;
765
+ picker(t("ide.quick"), t("ide.quick.placeholder"), async (q) => {
766
+ if (!q) return tabs.map((x) => ({ label: x.path, path: x.path, icon: fileIcon(x.path.split("/").pop(), false) }));
767
+ return (await api("/api/ws/search", { folder, query: q, names: true })).results.slice(0, 60).map((r) => ({ label: r.path, path: r.path, icon: fileIcon(r.path.split("/").pop(), false) }));
768
+ }, (r) => openFile(r.path));
769
+ }
770
+ const COMMANDS = () => [
771
+ { label: t("ide.quick"), keys: `${MOD}P`, run: quickOpen },
772
+ { label: t("ide.save"), keys: `${MOD}S`, run: () => saveActive() },
773
+ { label: t("ide.agent.new"), keys: `${MOD}L`, run: () => newAgent() },
774
+ { label: t("ide.inline"), keys: `${MOD}K`, run: () => inlineEdit() },
775
+ { label: t("ide.cmd.mode"), keys: "⇧Tab", run: () => cycleMode() },
776
+ { label: t("ide.cmd.toggle.side"), keys: `${MOD}B`, run: () => toggle("sideOpen") },
777
+ { label: t("ide.cmd.toggle.panel"), keys: `${MOD}J`, run: () => $("toggle-panel").click() },
778
+ { label: t("ide.cmd.toggle.agent"), keys: `${MOD}⌥B`, run: () => toggle("agentOpen") },
779
+ { label: t("ide.cmd.terminal"), keys: isMac ? "⌃`" : "Ctrl+`", run: showTerminal },
780
+ { label: t("ide.explorer"), keys: `${MOD}⇧E`, run: () => showSide("files") },
781
+ { label: t("ide.search"), keys: `${MOD}⇧F`, run: () => showSide("search") },
782
+ { label: t("ide.git"), run: () => showSide("git") },
783
+ { label: t("ide.problems.run"), run: () => $("problems-run").click() },
784
+ { label: t("ide.ports"), run: () => showBottom("ports") },
785
+ { label: t("ide.split"), run: () => { if (active) toggleSplit(); } },
786
+ { label: t("ide.preview"), run: () => openPreview() },
787
+ { label: t("pj.limits"), run: () => editLimits() },
788
+ { label: t("pj.folder.add"), run: () => addFolder() },
789
+ ...["bottom", "top", "left", "right"].map((p) => ({ label: `${t("ide.panel.position")}: ${t(`ide.panel.pos.${p}`)}`, run: () => { layoutState.panelPos = p; layout(); } })),
790
+ ];
791
+ function palette() {
792
+ picker(t("ide.palette"), t("ide.palette.placeholder"), async (q) => COMMANDS().filter((c) => !q || c.label.toLowerCase().includes(q.toLowerCase())), (c) => c.run());
454
793
  }
455
- $("quick-open").addEventListener("click", quickOpen);
794
+ $("palette-btn").addEventListener("click", palette);
456
795
  let searchTimer = null;
457
796
  async function runSearch() {
458
797
  const q = $("search-q").value.trim();
@@ -462,12 +801,25 @@
462
801
  try {
463
802
  const r = await api("/api/ws/search", { folder, query: q, caseSensitive: $("search-case").checked, regex: $("search-regex").checked });
464
803
  $("search-info").textContent = t(r.truncated ? "ide.search.many" : "ide.search.count", { n: r.results.length });
465
- for (const hit of r.results) out.append(el("li", { title: `${hit.path}:${hit.line}`, onclick: () => openFile(hit.path, hit.line) }, el("span", { class: "badge", text: `${hit.path.split("/").pop()}:${hit.line}` }), el("span", { class: "name", text: hit.text.trim() })));
804
+ for (const hit of r.results) out.append(el("li", { title: `${hit.path}:${hit.line}`, onclick: () => openFile(hit.path, hit.line) }, fileIcon(hit.path.split("/").pop(), false), el("span", { class: "name", text: hit.text.trim() }), el("span", { class: "muted small", text: `${hit.path.split("/").pop()}:${hit.line}` })));
466
805
  } catch (err) { $("search-info").textContent = explain(err); }
467
806
  }
468
807
  for (const id of ["search-q", "search-case", "search-regex"]) $(id).addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(runSearch, 250); });
469
808
 
470
809
  // ---- git: the person's own actions -------------------------------------------------------------------
810
+ /** Branch and colours in the tree, quietly: a folder without git simply shows none. */
811
+ async function refreshGit() {
812
+ if (!folder) return;
813
+ try {
814
+ const r = await api("/api/ws/git/status", { folder });
815
+ const status = new Map();
816
+ for (const f of r.files) status.set(f.path.replace(/\/$/, ""), f.unstaged === "?" ? "U" : f.staged === "A" ? "A" : f.unstaged === "D" || f.staged === "D" ? "D" : "M");
817
+ gitInfo = { branch: (r.branch || "").split("...")[0], status, ignored: r.ignored || [] };
818
+ } catch { gitInfo = { branch: "", status: new Map(), ignored: [] }; }
819
+ $("sb-branch").hidden = !gitInfo.branch;
820
+ $("sb-branch").lastChild.textContent = gitInfo.branch;
821
+ if (treeCache.size) loadTree(".", $("tree"), 0).catch(() => {});
822
+ }
471
823
  async function gitRefresh() {
472
824
  if (!folder) return;
473
825
  const staged = clear($("git-staged")), changes = clear($("git-changes"));
@@ -476,14 +828,16 @@
476
828
  const r = await api("/api/ws/git/status", { folder });
477
829
  $("git-branch").textContent = r.branch ? t("ide.git.branch", { branch: r.branch }) : "";
478
830
  for (const f of r.files) {
479
- const row = (where, letter, stage) => el("li", { title: f.path, onclick: () => gitDiff(f.path, stage === false) },
480
- el("span", { class: "badge", text: letter }), el("span", { class: "name", text: f.path }),
481
- el("button", { class: "icon", "data-tip": stage ? "tip.ide.git.stage" : "tip.ide.git.unstage", text: stage ? "+" : "−", onclick: async (e) => { e.stopPropagation(); try { await api(stage ? "/api/ws/git/stage" : "/api/ws/git/unstage", { folder, paths: [f.path] }); gitRefresh(); } catch (err) { $("git-msg").textContent = explain(err); } } }));
482
- if (f.staged) staged.append(row("staged", f.staged, false));
483
- if (f.unstaged) changes.append(row("changes", f.unstaged === "?" ? "U" : f.unstaged, true));
831
+ const row = (letter, stage) => el("li", { title: f.path, onclick: () => gitDiff(f.path, stage === false) },
832
+ fileIcon(f.path.split("/").pop(), false), el("span", { class: "name", text: f.path.split("/").pop() }), el("span", { class: "muted small name", text: f.path.includes("/") ? f.path.replace(/\/[^/]*$/, "") : "" }), el("span", { class: "grow" }),
833
+ el("button", { class: "ib", "data-tip": stage ? "tip.ide.git.stage" : "tip.ide.git.unstage", onclick: async (e) => { e.stopPropagation(); try { await api(stage ? "/api/ws/git/stage" : "/api/ws/git/unstage", { folder, paths: [f.path] }); gitRefresh(); } catch (err) { $("git-msg").textContent = explain(err); } } }, icon(stage ? "plus" : "undo")),
834
+ el("span", { class: `badge ${letter === "U" || letter === "A" ? "good" : letter === "D" ? "bad" : "warn"}`, text: letter }));
835
+ if (f.staged) staged.append(row(f.staged, false));
836
+ if (f.unstaged) changes.append(row(f.unstaged === "?" ? "U" : f.unstaged, true));
484
837
  }
485
838
  if (!r.files.length) $("git-msg").textContent = t("ide.git.clean");
486
839
  } catch (err) { $("git-branch").textContent = ""; $("git-msg").textContent = explain(err); }
840
+ refreshGit();
487
841
  }
488
842
  async function gitDiff(path, staged) {
489
843
  try {
@@ -493,6 +847,7 @@
493
847
  } catch (err) { $("git-msg").textContent = explain(err); }
494
848
  }
495
849
  $("git-refresh").addEventListener("click", gitRefresh);
850
+ $("sb-branch").addEventListener("click", () => showSide("git"));
496
851
  $("git-commit").addEventListener("click", async () => {
497
852
  try { const r = await api("/api/ws/git/commit", { folder, message: $("git-message").value }); $("git-message").value = ""; output(r.output); gitRefresh(); } catch (err) { $("git-msg").textContent = explain(err); }
498
853
  });
@@ -502,6 +857,14 @@
502
857
  });
503
858
 
504
859
  // ---- problems, outline, history, preview --------------------------------------------------------------
860
+ function renderProblemCounts() {
861
+ $("sb-errors").textContent = String(problemCounts.errors);
862
+ $("sb-warnings").textContent = String(problemCounts.warnings);
863
+ const n = problemCounts.errors + problemCounts.warnings;
864
+ $("problems-count").hidden = !n;
865
+ $("problems-count").textContent = String(n);
866
+ }
867
+ $("sb-problems").addEventListener("click", () => showBottom("problems"));
505
868
  $("problems-run").addEventListener("click", async () => {
506
869
  if (!folder) return;
507
870
  const list = clear($("problems"));
@@ -513,10 +876,13 @@
513
876
  if (!r.ran.length) list.append(el("li", { class: "muted", text: t("ide.problems.none_setup") }));
514
877
  else if (!r.problems.length) list.append(el("li", { class: "muted", text: t("ide.problems.clean", { tools: r.ran.join(", ") }) }));
515
878
  const byFile = {};
879
+ problemCounts = { errors: 0, warnings: 0 };
516
880
  for (const p of r.problems) {
517
881
  (byFile[p.file] = byFile[p.file] || []).push(p);
518
- list.append(el("li", { title: p.message, onclick: () => openFile(p.file, p.line) }, el("span", { class: `badge ${p.severity === "error" ? "bad" : "warn"}`, text: p.source }), el("span", { class: "name", text: `${p.file}:${p.line} ${p.message}` })));
882
+ problemCounts[p.severity === "error" ? "errors" : "warnings"]++;
883
+ list.append(el("li", { title: p.message, onclick: () => openFile(p.file, p.line) }, icon(p.severity === "error" ? "error" : "warning"), el("span", { class: "name", text: p.message }), el("span", { class: "muted small", text: `${p.file}:${p.line} · ${p.source}` })));
519
884
  }
885
+ renderProblemCounts();
520
886
  for (const m of monaco.editor.getModels()) {
521
887
  if (m.uri.scheme !== "file") continue;
522
888
  const ps = byFile[m.uri.path.slice(1)] || [];
@@ -534,37 +900,48 @@
534
900
  /^\s*(?:public|private|protected|static|\s)+[\w<>[\],\s]+\s+(\w+)\s*\([^;]*$/,
535
901
  /^(#{1,6})\s+(.+)$/,
536
902
  ];
903
+ const outlineLists = () => [$("outline"), $("outline-view")];
537
904
  function renderOutline() {
538
- const list = clear($("outline"));
539
- if (!active || !active.model) return;
905
+ const want = layoutState.panes.outline || layoutState.sideView === "outline";
906
+ for (const list of outlineLists()) clear(list);
907
+ if (!want || !active || !active.model) return;
540
908
  const lines = active.model.getValue().split("\n");
541
909
  if (lines.length > 20000) return;
910
+ const items = [];
542
911
  lines.forEach((text, i) => {
543
912
  for (const re of OUTLINE) {
544
913
  const m = re.exec(text);
545
914
  if (!m) continue;
546
915
  const name = re === OUTLINE[5] ? `${" ".repeat(m[1].length - 1)}${m[2]}` : m.slice(1).find(Boolean);
547
- if (name) list.append(el("li", { "data-line": i + 1, onclick: () => openFile(active.path, i + 1) }, el("span", { class: "muted small", text: String(i + 1) }), el("span", { class: "name", text: name })));
916
+ if (name) items.push([i + 1, name]);
548
917
  break;
549
918
  }
550
919
  });
920
+ for (const list of outlineLists()) for (const [line, name] of items) list.append(el("li", { "data-line": line, onclick: () => openFile(active.path, line) }, el("span", { class: "name", text: name }), el("span", { class: "muted small", text: String(line) })));
551
921
  markOutline();
552
922
  }
553
923
  function markOutline() {
554
- if (!editor || !editor.getPosition()) return;
555
- const line = editor.getPosition().lineNumber;
556
- let best = null;
557
- for (const li of $("outline").children) if (Number(li.dataset.line) <= line) best = li;
558
- for (const li of $("outline").children) li.classList.toggle("on", li === best);
924
+ const ed = focused || editor;
925
+ if (!ed || !ed.getPosition()) return;
926
+ const line = ed.getPosition().lineNumber;
927
+ for (const list of outlineLists()) {
928
+ let best = null;
929
+ for (const li of list.children) if (Number(li.dataset.line) <= line) best = li;
930
+ for (const li of list.children) li.classList.toggle("on", li === best);
931
+ }
559
932
  }
560
933
  async function renderHistory() {
561
- const list = clear($("history"));
562
- if (!active) return list.append(el("li", { class: "muted", text: t("ide.history.none") }));
934
+ const lists = [$("history"), $("history-view")];
935
+ for (const l of lists) clear(l);
936
+ if (!(layoutState.panes.history || layoutState.sideView === "history")) return;
937
+ if (!active) { for (const l of lists) l.append(el("li", { class: "muted", text: t("ide.history.none") })); return; }
563
938
  try {
564
939
  const r = await api("/api/ws/history", { folder, path: active.path });
565
- if (!r.versions.length) list.append(el("li", { class: "muted", text: t("ide.history.none") }));
566
- for (const v of r.versions) list.append(el("li", { onclick: () => showVersion(active.path, v.id) }, el("span", { class: "badge", text: t(`ide.history.${v.why}`) }), el("span", { class: "name", text: new Date(v.at).toLocaleString(lang) })));
567
- } catch (err) { list.append(el("li", { class: "muted", text: explain(err) })); }
940
+ for (const l of lists) {
941
+ if (!r.versions.length) l.append(el("li", { class: "muted", text: t("ide.history.none") }));
942
+ for (const v of r.versions) l.append(el("li", { onclick: () => showVersion(active.path, v.id) }, el("span", { class: "badge", text: t(`ide.history.${v.why}`) }), el("span", { class: "name", text: new Date(v.at).toLocaleString(lang) })));
943
+ }
944
+ } catch (err) { for (const l of lists) l.append(el("li", { class: "muted", text: explain(err) })); }
568
945
  }
569
946
  async function showVersion(path, id) {
570
947
  try {
@@ -575,12 +952,42 @@
575
952
  reject: async () => { if (tab) { tab.model.setValue(v.text); activate(tab); } else await writeFrom(path, v.text); } });
576
953
  } catch (err) { output(explain(err)); }
577
954
  }
578
- $("preview").addEventListener("click", async () => {
955
+ async function openPreview() {
579
956
  const url = await askText(t("ide.preview"), t("ide.preview.hint"), store.get("preview", "http://localhost:3000"));
580
957
  if (!url) return;
581
958
  store.set("preview", url);
582
959
  try { await api("/api/ws/preview", { url }); } catch (err) { output(explain(err)); }
960
+ }
961
+
962
+ // ---- settings, folder ---------------------------------------------------------------------------------
963
+ $("settings-btn").addEventListener("click", (e) => menu(e.currentTarget, [
964
+ { label: t("ide.palette"), keys: `${MOD}⇧P`, run: palette },
965
+ { label: t("pj.limits"), run: () => editLimits() },
966
+ { label: t("ide.preview"), run: openPreview },
967
+ { label: t("ide.terminal.settings"), run: terminalSettings },
968
+ { sep: true },
969
+ { label: t("ide.panel.position"), sub: ["bottom", "top", "left", "right"].map((p) => ({ label: t(`ide.panel.pos.${p}`), check: layoutState.panelPos === p, run: () => { layoutState.panelPos = p; layout(); } })) },
970
+ { label: t("ide.layout.reset"), run: () => { Object.assign(layoutState, { side: 260, agent: 380, panel: 240, panelWide: 420, sideOpen: true, agentOpen: true, panelOpen: true, panelPos: "bottom", panelAlign: "center", panelMax: false }); layout(); } },
971
+ ]));
972
+ async function terminalSettings() {
973
+ const box = el("div");
974
+ try { const mod = await import(`${BASE}terminal.js`); mod.mountTerminalSetting(box, { token, t }); } catch { box.append(el("p", { class: "muted", text: t("ide.terminal.missing") })); }
975
+ await modal(t("ide.terminal.settings"), box, [{ label: t("ide.ok"), value: null }]);
976
+ }
977
+ const folderLabel = (f) => f.split(/[\\/]/).filter(Boolean).pop() || f;
978
+ $("folder-btn").addEventListener("click", (e) => {
979
+ const sel = $("folder");
980
+ menu(e.currentTarget, [
981
+ ...[...sel.options].filter((o) => o.value).map((o) => ({ label: folderLabel(o.value), keys: o.value.length > 40 ? `…${o.value.slice(-38)}` : o.value, check: o.value === folder, run: () => { sel.value = o.value; setFolder(o.value); } })),
982
+ { sep: true },
983
+ { label: t("pj.folder.add"), run: addFolder },
984
+ ]);
583
985
  });
986
+ async function addFolder() {
987
+ const path = await askText(t("pj.folder.add"), t("pj.folder.confirm"), "");
988
+ if (!path) return;
989
+ try { await api("/api/folders", { path }); await loadFolders(); const last = (await api("/api/projects")).folders.slice(-1)[0]; $("folder").value = last; setFolder(last); } catch (err) { output(explain(err)); }
990
+ }
584
991
 
585
992
  // ---- models, folder, mode, limits ---------------------------------------------------------------------
586
993
  async function loadModels() {
@@ -595,16 +1002,19 @@
595
1002
  for (const [label, list] of groups) {
596
1003
  if (!list.length) continue;
597
1004
  const g = el("optgroup", { label: t(label) });
598
- for (const m of list) g.append(el("option", { value: m.key, disabled: !m.available, text: m.available ? m.label : `${m.label} · ${t("ide.model.unavailable")}` }));
1005
+ for (const m of list) g.append(el("option", { value: m.key, disabled: m.available === false, text: m.available === false ? `${m.label} · ${t("ide.model.unavailable")}` : m.label }));
599
1006
  sel.append(g);
600
1007
  }
601
1008
  if (!models.length) sel.append(el("option", { value: "", text: t("ide.model.none") }));
602
1009
  const keep = store.get("model", "");
603
- const first = models.find((m) => m.available && m.key === keep) || models.find((m) => m.available && m.own) || models.find((m) => m.available);
1010
+ const first = models.find((m) => m.available !== false && m.key === keep) || models.find((m) => m.available !== false && m.own) || models.find((m) => m.available !== false);
604
1011
  if (first) sel.value = first.key;
1012
+ renderStatusModel();
605
1013
  }
606
- const chosenModel = () => models.find((m) => m.key === $("model").value && m.available) || null;
607
- $("model").addEventListener("change", () => store.set("model", $("model").value));
1014
+ const chosenModel = () => models.find((m) => m.key === $("model").value && m.available !== false) || null;
1015
+ function renderStatusModel() { const m = chosenModel(); $("sb-model").textContent = m ? m.label : t("ide.model.none"); }
1016
+ $("model").addEventListener("change", () => { store.set("model", $("model").value); renderStatusModel(); });
1017
+ $("sb-model").addEventListener("click", () => { layoutState.agentOpen = true; layout(); $("model").focus(); });
608
1018
  async function loadFolders() {
609
1019
  const r = await api("/api/projects");
610
1020
  const sel = clear($("folder"));
@@ -619,23 +1029,29 @@
619
1029
  for (const tab of [...tabs]) closeTab(tab, true);
620
1030
  folder = f;
621
1031
  store.set("folder", f);
1032
+ $("folder-name").textContent = f ? folderLabel(f) : t("ide.folder.none");
1033
+ $("tree-title").textContent = f ? folderLabel(f) : "";
1034
+ $("palette-label").textContent = f ? `${folderLabel(f)} — ${t("ide.palette.hint", { keys: `${MOD}⇧P` })}` : t("ide.palette");
622
1035
  session = null;
623
1036
  cursor = 0;
624
1037
  clear($("agent-log"));
625
1038
  opened.clear();
626
1039
  opened.add(".");
1040
+ treeCache.clear();
1041
+ await refreshGit();
627
1042
  refreshTree();
628
1043
  if (terminal && terminal.folderChanged) terminal.folderChanged(f);
629
1044
  await refreshAgents();
630
1045
  }
631
1046
  $("folder").addEventListener("change", () => setFolder($("folder").value));
632
- $("folder-add").addEventListener("click", async () => {
633
- const path = await askText(t("pj.folder.add"), t("pj.folder.confirm"), "");
634
- if (!path) return;
635
- try { await api("/api/folders", { path }); await loadFolders(); $("folder").value = (await api("/api/projects")).folders.slice(-1)[0]; setFolder($("folder").value); } catch (err) { output(explain(err)); }
636
- });
637
1047
  const MODES = ["ask", "edits", "auto"];
638
- function renderModeHint() { $("mode-hint").textContent = t(`pj.mode.${$("mode").value}.hint`); }
1048
+ function renderModeHint() {
1049
+ const mode = $("mode").value;
1050
+ $("mode-hint").textContent = mode === "auto" ? t("ide.mode.auto.on", { keys: "⇧Tab" }) : t(`pj.mode.${mode}.hint`);
1051
+ $("mode-hint").classList.toggle("auto", mode === "auto");
1052
+ $("sb-mode").textContent = t(`pj.mode.${mode}`);
1053
+ $("sb-mode").classList.toggle("auto", mode === "auto");
1054
+ }
639
1055
  async function setMode(mode) {
640
1056
  const prev = $("mode").value;
641
1057
  $("mode").value = mode;
@@ -644,7 +1060,9 @@
644
1060
  try { await api("/api/projects/mode", { id: session.id, mode, remoteConfirmed: mode === "auto" && session.leaves ? await remoteOk(true) : undefined }); }
645
1061
  catch (err) { $("mode").value = prev; renderModeHint(); output(explain(err)); }
646
1062
  }
1063
+ const cycleMode = () => setMode(MODES[(MODES.indexOf($("mode").value) + 1) % MODES.length]);
647
1064
  $("mode").addEventListener("change", () => setMode($("mode").value));
1065
+ $("sb-mode").addEventListener("click", cycleMode);
648
1066
  /** Content that leaves this computer: the person says yes once, knowing it. */
649
1067
  async function remoteOk(auto) {
650
1068
  return !!(await confirmBox(t("ide.remote.title"), t(auto ? "pj.auto.confirm" : "pj.remote"), t("pj.yes")));
@@ -661,10 +1079,7 @@
661
1079
  return { k, input, block: el("div", {}, el("label", { class: "row" }, el("strong", { text: t(`pj.limit.${k}`) }), input, el("span", { class: "muted small", text: t("ide.limits.auto_value", { n: session.autoLimits[k] }) })), el("p", { class: "muted small", text: t(`ide.limits.${k}.what`) })) };
662
1080
  };
663
1081
  const fields = ["steps", "minutes", "credits"].map(field);
664
- const body = [
665
- el("p", { text: t("ide.limits.intro") }), ...fields.map((f) => f.block),
666
- limitsExplained(),
667
- ];
1082
+ const body = [el("p", { text: t("ide.limits.intro") }), ...fields.map((f) => f.block), limitsExplained()];
668
1083
  const choice = await modal(t("pj.limits"), body, [{ label: t("pj.no"), ghost: true, value: null }, { label: t("ide.limits.reset"), ghost: true, value: "auto" }, { label: t("pj.limits.save"), value: "save" }]);
669
1084
  try {
670
1085
  if (choice === "auto") session = await api("/api/projects/limits", { id: session.id, auto: true });
@@ -672,7 +1087,6 @@
672
1087
  renderAgent(session);
673
1088
  } catch (err) { output(explain(err)); }
674
1089
  }
675
- $("limits-open").addEventListener("click", editLimits);
676
1090
  $("limit-edit").addEventListener("click", editLimits);
677
1091
 
678
1092
  // ---- agents ---------------------------------------------------------------------------------------
@@ -682,18 +1096,16 @@
682
1096
  agentList = (r.sessions || []).filter((s) => s.folder === folder).sort((x, y) => y.updated - x.updated);
683
1097
  drawAgents();
684
1098
  if (!session && agentList.length) selectAgent(agentList[0].id);
685
- $("agents-toggle").textContent = session ? session.title || t("ide.agent.untitled") : t("ide.agents");
1099
+ $("agents-toggle").textContent = session ? session.title || t("ide.agent.untitled") : "";
686
1100
  }
687
1101
  function drawAgents() {
688
1102
  const q = $("agents-q").value.trim().toLowerCase();
689
1103
  const list = clear($("agents-list"));
690
1104
  for (const a of agentList.filter((x) => !q || (x.title || "").toLowerCase().includes(q))) {
691
- list.append(el("li", { class: session && a.id === session.id ? "on" : "", onclick: () => { selectAgent(a.id); $("agents").hidden = true; } },
692
- el("span", { class: "badge", text: t(`pj.status.${a.status}`).replace(/[.…]$/, "") }), el("span", { class: "name", text: a.title || t("ide.agent.untitled") })));
1105
+ list.append(el("li", { class: session && a.id === session.id ? "on" : "", title: a.title || "", onclick: () => selectAgent(a.id) },
1106
+ el("span", { class: "name grow", text: a.title || t("ide.agent.untitled") }), el("span", { class: "muted small", text: t(`pj.status.${a.status}`).replace(/[.…]$/, "") })));
693
1107
  }
694
- if (!agentList.length) list.append(el("li", { class: "muted", text: t("ide.agent.none") }));
695
1108
  }
696
- $("agents-toggle").addEventListener("click", () => { $("agents").hidden = !$("agents").hidden; drawAgents(); });
697
1109
  $("agents-q").addEventListener("input", drawAgents);
698
1110
  async function selectAgent(id) {
699
1111
  session = await api("/api/projects/state", { id, after: 0 });
@@ -702,6 +1114,7 @@
702
1114
  $("mode").value = session.mode;
703
1115
  renderModeHint();
704
1116
  renderAgent(session);
1117
+ drawAgents();
705
1118
  }
706
1119
  async function newAgent() {
707
1120
  const m = chosenModel();
@@ -716,6 +1129,8 @@
716
1129
  clear($("agent-log"));
717
1130
  renderAgent(session);
718
1131
  await refreshAgents();
1132
+ layoutState.agentOpen = true;
1133
+ layout();
719
1134
  $("prompt").focus();
720
1135
  return session;
721
1136
  } catch (err) { output(explain(err)); return null; }
@@ -730,9 +1145,12 @@
730
1145
  for (const e of v.events || []) {
731
1146
  if (e.n < cursor) continue;
732
1147
  cursor = e.n + 1;
733
- log.append(eventNode(e));
1148
+ // Pieces of output of the same command go into one block: thousands of small blocks slow the page.
1149
+ const last = log.lastElementChild;
1150
+ if (e.kind === "output" && last && last.classList.contains("output") && last.textContent.length < 200000) last.textContent += e.text;
1151
+ else log.append(eventNode(e));
734
1152
  if (e.kind === "output") { output(e.text); if (terminal && terminal.agentOutput) terminal.agentOutput(e.text); }
735
- if (e.kind === "action" && e.action === "run") { output(`$ ${e.text}`); if (terminal && terminal.runVisible) terminal.runVisible(e.text); }
1153
+ if (e.kind === "action" && e.action === "run") { output(`$ ${e.text}`); if (terminal && terminal.runVisible) terminal.runVisible(e.text); setTimeout(refreshPorts, 2500); }
736
1154
  if (e.kind === "notice" && (e.code === "command_ended" || e.code === "command_timeout") && terminal && terminal.agentDone) terminal.agentDone(e.text);
737
1155
  }
738
1156
  log.scrollTop = log.scrollHeight;
@@ -745,8 +1163,8 @@
745
1163
  renderPending(v.pending);
746
1164
  const ch = clear($("agent-changes"));
747
1165
  if (v.changes && v.changes.length) {
748
- ch.append(el("div", { class: "panel-sub", text: t("pj.tab.changes") }));
749
- for (const c of v.changes) ch.append(el("div", {}, el("a", { href: "#", text: c.path, onclick: (e) => { e.preventDefault(); reviewAgentChange(c.path); } })));
1166
+ ch.append(el("div", { class: "sub-head", text: t("pj.tab.changes") }));
1167
+ for (const c of v.changes) ch.append(el("div", { class: "row" }, fileIcon(c.path.split("/").pop(), false), el("a", { href: "#", text: c.path, onclick: (e) => { e.preventDefault(); reviewAgentChange(c.path); } })));
750
1168
  }
751
1169
  $("agents-toggle").textContent = v.title || t("ide.agent.untitled");
752
1170
  for (const tab of tabs) if (!isDirty(tab) && v.changes && v.changes.some((c) => c.path === tab.path)) reloadTab(tab).catch(() => {});
@@ -755,7 +1173,7 @@
755
1173
  if (e.kind === "user") return el("div", { class: "ev user", text: e.text });
756
1174
  if (e.kind === "assistant") return el("div", { class: "ev", text: e.text });
757
1175
  if (e.kind === "output") return el("div", { class: "ev output", text: e.text });
758
- if (e.kind === "action") return el("div", { class: "ev action", text: `${t(`pj.act.${e.action}`)} ${e.path || e.text || ""}` });
1176
+ if (e.kind === "action") return el("div", { class: "ev action" }, icon(e.action === "run" ? "terminal" : e.action === "search" ? "search" : "files"), el("span", { text: `${t(`pj.act.${e.action}`)} ${e.path || e.text || ""}` }));
759
1177
  if (e.kind === "error") return el("div", { class: "ev error", text: `${explain(e.code)}${e.text ? `\n${e.text}` : ""}` });
760
1178
  const key = `pj.note.${e.code}`;
761
1179
  return el("div", { class: "ev notice", text: t(key, { code: e.text }) });
@@ -777,7 +1195,7 @@
777
1195
  box.append(el("strong", { text: t("pj.ask.command") }), el("code", { text: p.command }), el("p", { class: "muted small", text: `${t("pj.ask.cwd", { cwd: p.cwd })} · ${t("pj.where.computer")}` }));
778
1196
  if (p.risk) box.append(el("p", { class: "small", text: t(`pj.risk.${p.risk}`) }));
779
1197
  }
780
- box.append(el("div", { class: "row" }, el("button", { "data-tip": "tip.pj.approve", text: t("pj.approve"), onclick: () => answer(true) }), el("button", { class: "ghost", "data-tip": "tip.pj.refuse", text: t("pj.refuse"), onclick: () => answer(false) })));
1198
+ box.append(el("div", { class: "row" }, el("button", { class: "primary", "data-tip": "tip.pj.approve", text: t("pj.approve"), onclick: () => answer(true) }), el("button", { class: "ghost", "data-tip": "tip.pj.refuse", text: t("pj.refuse"), onclick: () => answer(false) })));
781
1199
  }
782
1200
 
783
1201
  /** @-mentions: typing @ suggests files of the folder. */
@@ -791,7 +1209,7 @@
791
1209
  function drawMentions() {
792
1210
  const box = clear($("mention"));
793
1211
  box.hidden = !mentionItems.length;
794
- mentionItems.forEach((p, i) => box.append(el("li", { class: i === mentionSel ? "on" : "", text: p, onmousedown: (e) => { e.preventDefault(); insertMention(p); } })));
1212
+ mentionItems.forEach((p, i) => box.append(el("li", { class: i === mentionSel ? "on" : "", onmousedown: (e) => { e.preventDefault(); insertMention(p); } }, fileIcon(p.split("/").pop(), false), el("span", { class: "name", text: p }))));
795
1213
  }
796
1214
  function insertMention(path) {
797
1215
  const ta = $("prompt");
@@ -839,15 +1257,28 @@
839
1257
  try { renderAgent(await api("/api/projects/undo", { id: session.id })); for (const tab of tabs) if (!isDirty(tab)) reloadTab(tab).catch(() => {}); refreshTree(); } catch (err) { output(explain(err)); }
840
1258
  });
841
1259
  // The app works only while a page asks for news: this is also how it knows someone is watching.
842
- setInterval(async () => {
843
- if (!session || document.hidden) return;
844
- try { renderAgent(await api("/api/projects/state", { id: session.id, after: cursor })); } catch (err) { if (/project_not_found/.test(err.message)) { session = null; refreshAgents(); } }
845
- }, 800);
1260
+ // Each request waits on the app until something happens, so a command's output shows while it is written.
1261
+ (async function follow() {
1262
+ for (;;) {
1263
+ if (!session || document.hidden) { await new Promise((r) => setTimeout(r, 400)); continue; }
1264
+ const id = session.id;
1265
+ try {
1266
+ const v = await api("/api/projects/state", { id, after: cursor, wait: 15000 });
1267
+ // The person may have switched agent meanwhile: news of another one are not mixed in.
1268
+ if (session && session.id === id && v.id === id) renderAgent(v);
1269
+ } catch (err) {
1270
+ if (/project_not_found/.test(err.message)) { if (session && session.id === id) { session = null; refreshAgents(); } }
1271
+ else await new Promise((r) => setTimeout(r, 1000));
1272
+ }
1273
+ }
1274
+ })();
1275
+ setInterval(() => { if (!document.hidden && folder) refreshGit(); }, 15000);
846
1276
 
847
1277
  /** Rewrites the selected text with the agent's model, shown as a change to accept. */
848
1278
  async function inlineEdit() {
849
1279
  if (!active) return;
850
- const sel = editor.getSelection();
1280
+ const ed = focused || editor;
1281
+ const sel = ed.getSelection();
851
1282
  const model = active.model;
852
1283
  const range = sel && !sel.isEmpty() ? sel : new monaco.Range(sel.startLineNumber, 1, sel.startLineNumber, model.getLineMaxColumn(sel.startLineNumber));
853
1284
  const instruction = await askText(t("ide.inline"), t("ide.inline.hint"), "");
@@ -868,43 +1299,52 @@
868
1299
  }
869
1300
 
870
1301
  // ---- keys ------------------------------------------------------------------------------------------
871
- const KEYS = [["P", "ide.key.quick"], ["⇧F", "ide.key.search"], ["B", "ide.key.side"], ["J", "ide.key.terminal"], ["L", "ide.key.agent"], ["K", "ide.key.inline"], ["S", "ide.key.save"]];
1302
+ const KEYS = [["⇧P", "ide.palette"], ["P", "ide.key.quick"], ["⇧F", "ide.key.search"], ["B", "ide.key.side"], ["J", "ide.key.terminal"], ["L", "ide.key.agent"], ["K", "ide.key.inline"], ["S", "ide.key.save"]];
872
1303
  function drawKeys() {
873
- const list = clear($("keys"));
874
- const mod = isMac ? "⌘" : "Ctrl+";
875
- for (const [k, label] of KEYS) list.append(el("li", { text: `${mod}${k} ${t(label)}` }));
876
- list.append(el("li", { text: `⇧Tab ${t("ide.key.mode")}` }));
1304
+ const table = clear($("keys"));
1305
+ const keys = (s) => el("td", {}, ...s.split(/(?=⇧)|(?<=⇧)/).map((k) => el("kbd", { text: k })));
1306
+ for (const [k, label] of KEYS) table.append(el("tr", {}, el("td", { text: t(label) }), keys(`${MOD}${k}`)));
1307
+ table.append(el("tr", {}, el("td", { text: t("ide.key.mode") }), keys("⇧Tab")));
877
1308
  }
878
1309
  document.addEventListener("keydown", (e) => {
879
1310
  const mod = isMac ? e.metaKey : e.ctrlKey;
880
1311
  const inEditor = e.target && e.target.closest && e.target.closest(".monaco-editor");
881
- if (e.key === "Tab" && e.shiftKey && !inEditor && !mentionItems.length) {
882
- e.preventDefault();
883
- setMode(MODES[(MODES.indexOf($("mode").value) + 1) % MODES.length]);
884
- return;
885
- }
1312
+ if (e.key === "Tab" && e.shiftKey && !inEditor && !mentionItems.length) { e.preventDefault(); cycleMode(); return; }
1313
+ if (e.ctrlKey && e.key === "`") { e.preventDefault(); showTerminal(); return; }
886
1314
  if (!mod) return;
887
1315
  const k = e.key.toLowerCase();
888
- if (k === "p" && !e.shiftKey) { e.preventDefault(); quickOpen(); }
1316
+ if (k === "p" && e.shiftKey) { e.preventDefault(); palette(); }
1317
+ else if (k === "p") { e.preventDefault(); quickOpen(); }
889
1318
  else if (k === "f" && e.shiftKey) { e.preventDefault(); showSide("search"); }
890
- else if (k === "b" && !e.shiftKey) { e.preventDefault(); sizes.sideOpen = !sizes.sideOpen; applySizes(); }
891
- else if (k === "j") { e.preventDefault(); if (sizes.bottomOpen && document.querySelector('[data-bottom="terminal"]').classList.contains("on") && $("terminal-host").querySelector(".bp-term")) { sizes.bottomOpen = false; applySizes(); if (terminal) terminal.close(); } else showTerminal(); }
1319
+ else if (k === "e" && e.shiftKey) { e.preventDefault(); showSide("files"); }
1320
+ else if (k === "b" && e.altKey) { e.preventDefault(); toggle("agentOpen"); }
1321
+ else if (k === "b" && !e.shiftKey) { e.preventDefault(); toggle("sideOpen"); }
1322
+ else if (k === "j") { e.preventDefault(); if (layoutState.panelOpen && currentBottom() === "terminal") { layoutState.panelOpen = false; layout(); if (terminal) terminal.close(); } else showTerminal(); }
892
1323
  else if (k === "l") { e.preventDefault(); newAgent(); }
893
1324
  else if (k === "s" && !inEditor) { e.preventDefault(); saveActive(); }
1325
+ else if (k === "\\") { e.preventDefault(); if (active) toggleSplit(); }
894
1326
  }, true);
895
1327
  addEventListener("beforeunload", (e) => { if (tabs.some(isDirty)) { e.preventDefault(); e.returnValue = ""; } });
896
1328
 
897
1329
  // ---- start ------------------------------------------------------------------------------------------
898
1330
  (async () => {
1331
+ drawIcons(document);
899
1332
  await loadWords();
900
1333
  $("ide").hidden = false;
901
- $("back").href = `/#token=${encodeURIComponent(token)}&lang=${encodeURIComponent(lang)}`;
902
- applySizes();
1334
+ $("back").addEventListener("click", () => { location.href = `/#token=${encodeURIComponent(token)}&lang=${encodeURIComponent(lang)}`; });
1335
+ for (const b of document.querySelectorAll("#activity button[data-side]")) b.classList.toggle("on", b.dataset.side === layoutState.sideView && layoutState.sideOpen);
1336
+ for (const p of document.querySelectorAll("#side > section")) p.hidden = p.dataset.panel !== layoutState.sideView;
1337
+ layout();
903
1338
  drawKeys();
904
1339
  renderModeHint();
1340
+ renderProblemCounts();
1341
+ showBottom(store.get("bottom", "problems") === "terminal" ? "problems" : store.get("bottom", "problems"));
1342
+ // Folders and models while the editor loads: the tree shows before Monaco is ready.
1343
+ const lists = Promise.all([loadModels().catch((err) => output(explain(err))), loadFolders().catch((err) => output(explain(err)))]).then(() => ($("folder").value ? setFolder($("folder").value) : null));
905
1344
  try { monaco = await loadMonaco(); setupEditor(); } catch (err) { output(t("ide.err.editor_missing")); }
1345
+ editorLoaded();
906
1346
  renderTabs();
907
- await Promise.all([loadModels().catch((err) => output(explain(err))), loadFolders().catch((err) => output(explain(err)))]);
908
- if ($("folder").value) await setFolder($("folder").value);
1347
+ await lists;
1348
+ for (const b of document.querySelectorAll("[data-bottom]")) b.addEventListener("click", () => store.set("bottom", b.dataset.bottom));
909
1349
  })();
910
1350
  })();