bitpull 0.1.23 → 0.1.25
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/README.md +2 -1
- package/bitpull.cjs +11701 -10690
- package/package.json +3 -2
- package/ui/ide-terminal.js +57 -0
- package/ui/ide.css +271 -0
- package/ui/ide.html +178 -0
- package/ui/ide.js +1336 -0
- package/ui/monaco/vs/abap-D-t0cyap.js +1 -0
- package/ui/monaco/vs/apex-CcIm7xu6.js +1 -0
- package/ui/monaco/vs/assets/css.worker-BpD9FCKP.js +97 -0
- package/ui/monaco/vs/assets/editor.worker-_vAIFJDs.js +30 -0
- package/ui/monaco/vs/assets/editorWebWorkerMain-DEgFqX2K.js +30 -0
- package/ui/monaco/vs/assets/html.worker-CLuInTTy.js +474 -0
- package/ui/monaco/vs/assets/json.worker-Csf_TLih.js +62 -0
- package/ui/monaco/vs/assets/ts.worker-g3pjEjl4.js +67722 -0
- package/ui/monaco/vs/azcli-BA0tQDCg.js +1 -0
- package/ui/monaco/vs/basic-languages/monaco.contribution.js +1 -0
- package/ui/monaco/vs/bat-C397hTD6.js +1 -0
- package/ui/monaco/vs/bicep-DF5aW17k.js +2 -0
- package/ui/monaco/vs/cameligo-plsz8qhj.js +1 -0
- package/ui/monaco/vs/clojure-Y2auQMzK.js +1 -0
- package/ui/monaco/vs/coffee-Bu45yuWE.js +1 -0
- package/ui/monaco/vs/cpp-CkKPQIni.js +1 -0
- package/ui/monaco/vs/csharp-CX28MZyh.js +1 -0
- package/ui/monaco/vs/csp-D8uWnyxW.js +1 -0
- package/ui/monaco/vs/css-CaeNmE3S.js +3 -0
- package/ui/monaco/vs/css.worker-BInK4lsP.js +1 -0
- package/ui/monaco/vs/cssMode-eIUIN_ru.js +1 -0
- package/ui/monaco/vs/cypher-DVThT8BS.js +1 -0
- package/ui/monaco/vs/dart-CmGfCvrO.js +1 -0
- package/ui/monaco/vs/dockerfile-CZqqYdch.js +1 -0
- package/ui/monaco/vs/ecl-30fUercY.js +1 -0
- package/ui/monaco/vs/editor/editor.main.css +1 -0
- package/ui/monaco/vs/editor/editor.main.js +1 -0
- package/ui/monaco/vs/editor/editor.worker.js +1 -0
- package/ui/monaco/vs/editor-BdtEMBbM.js +892 -0
- package/ui/monaco/vs/editor.js +1 -0
- package/ui/monaco/vs/editorWorkerHost-B_VpI68A.js +35 -0
- package/ui/monaco/vs/elixir-xjPaIfzF.js +1 -0
- package/ui/monaco/vs/flow9-DqtmStfK.js +1 -0
- package/ui/monaco/vs/freemarker2-B-FhRNBn.js +3 -0
- package/ui/monaco/vs/fsharp-BOMdg4U1.js +1 -0
- package/ui/monaco/vs/go-D_hbi-Jt.js +1 -0
- package/ui/monaco/vs/graphql-CKUU4kLG.js +1 -0
- package/ui/monaco/vs/handlebars-BJBfNnbX.js +1 -0
- package/ui/monaco/vs/hcl-DTaboeZW.js +1 -0
- package/ui/monaco/vs/html-BTio3wpy.js +1 -0
- package/ui/monaco/vs/html.worker-C1TNvQ72.js +1 -0
- package/ui/monaco/vs/htmlMode-aw5QYZo7.js +1 -0
- package/ui/monaco/vs/index-049xrXrp.js +1 -0
- package/ui/monaco/vs/index-DZH_zysX.js +5 -0
- package/ui/monaco/vs/index.js +1 -0
- package/ui/monaco/vs/ini-CsNwO04R.js +1 -0
- package/ui/monaco/vs/initialize-CAIC7-3s.js +1 -0
- package/ui/monaco/vs/java-CI4ZMsH9.js +1 -0
- package/ui/monaco/vs/javascript-B_IYblwB.js +1 -0
- package/ui/monaco/vs/json.worker-Bp7U0-9w.js +1 -0
- package/ui/monaco/vs/jsonMode-w7Q_ZLTt.js +1 -0
- package/ui/monaco/vs/julia-BwzEvaQw.js +1 -0
- package/ui/monaco/vs/kotlin-IUYPiTV8.js +1 -0
- package/ui/monaco/vs/less-C0eDYdqa.js +2 -0
- package/ui/monaco/vs/lexon-iON-Kj97.js +1 -0
- package/ui/monaco/vs/liquid-CXh4HpyA.js +1 -0
- package/ui/monaco/vs/loader.js +1368 -0
- package/ui/monaco/vs/lspLanguageFeatures-Cog3rVVE.js +2 -0
- package/ui/monaco/vs/lua-DtygF91M.js +1 -0
- package/ui/monaco/vs/m3-CsR4AuFi.js +1 -0
- package/ui/monaco/vs/main-BEx-Fmlo.js +15 -0
- package/ui/monaco/vs/main-DsK8pnKg.js +3 -0
- package/ui/monaco/vs/markdown-C_rD0bIw.js +1 -0
- package/ui/monaco/vs/mdx-BcFcCtz9.js +1 -0
- package/ui/monaco/vs/mips-CiYP61RB.js +1 -0
- package/ui/monaco/vs/monaco.contribution-BuX2NvO2.js +1 -0
- package/ui/monaco/vs/monaco.contribution-C7tY_xnl.js +1 -0
- package/ui/monaco/vs/monaco.contribution-CY2lwFI8.js +1 -0
- package/ui/monaco/vs/monaco.contribution-D1-wBxcq.js +1 -0
- package/ui/monaco/vs/msdax-C38-sJlp.js +1 -0
- package/ui/monaco/vs/mysql-CdtbpvbG.js +1 -0
- package/ui/monaco/vs/nls/lang/de.js +9 -0
- package/ui/monaco/vs/nls/lang/es.js +9 -0
- package/ui/monaco/vs/nls/lang/fr.js +9 -0
- package/ui/monaco/vs/nls/lang/it.js +9 -0
- package/ui/monaco/vs/nls/lang/pt-br.js +9 -0
- package/ui/monaco/vs/nls/lang/ru.js +9 -0
- package/ui/monaco/vs/nls/lang/zh-cn.js +9 -0
- package/ui/monaco/vs/nls.messages-loader.js +1 -0
- package/ui/monaco/vs/objective-c-CntZFaHX.js +1 -0
- package/ui/monaco/vs/pascal-r6kuqfl_.js +1 -0
- package/ui/monaco/vs/pascaligo-BiXoTmXh.js +1 -0
- package/ui/monaco/vs/perl-DABw_TcH.js +1 -0
- package/ui/monaco/vs/pgsql-me_jFXeX.js +1 -0
- package/ui/monaco/vs/php-D_kh-9LK.js +1 -0
- package/ui/monaco/vs/pla-VfZjczW0.js +1 -0
- package/ui/monaco/vs/postiats-BBSzz8Pk.js +1 -0
- package/ui/monaco/vs/powerquery-Dt-g_2cc.js +1 -0
- package/ui/monaco/vs/powershell-B-7ap1zc.js +1 -0
- package/ui/monaco/vs/protobuf-BmtuEB1A.js +2 -0
- package/ui/monaco/vs/pug-BRpRNeEb.js +1 -0
- package/ui/monaco/vs/python-CasA8BlK.js +1 -0
- package/ui/monaco/vs/qsharp-BzsFaUU9.js +1 -0
- package/ui/monaco/vs/r-f8dDdrp4.js +1 -0
- package/ui/monaco/vs/razor-IGOwKAHu.js +1 -0
- package/ui/monaco/vs/redis-fvZQY4PI.js +1 -0
- package/ui/monaco/vs/redshift-45Et0LQi.js +1 -0
- package/ui/monaco/vs/restructuredtext-C7UUFKFD.js +1 -0
- package/ui/monaco/vs/ruby-CZO8zYTz.js +1 -0
- package/ui/monaco/vs/rust-Bfetafyc.js +1 -0
- package/ui/monaco/vs/sb-3GYllVck.js +1 -0
- package/ui/monaco/vs/scala-foMgrKo1.js +1 -0
- package/ui/monaco/vs/scheme-CHdMtr7p.js +1 -0
- package/ui/monaco/vs/scss-C1cmLt9V.js +3 -0
- package/ui/monaco/vs/shell-ClXCKCEW.js +1 -0
- package/ui/monaco/vs/solidity-MZ6ExpPy.js +1 -0
- package/ui/monaco/vs/sophia-DWkuSsPQ.js +1 -0
- package/ui/monaco/vs/sparql-AUGFYSyk.js +1 -0
- package/ui/monaco/vs/sql-32GpJSV2.js +1 -0
- package/ui/monaco/vs/st-CuDFIVZ_.js +1 -0
- package/ui/monaco/vs/swift-t-PfMj7W.js +1 -0
- package/ui/monaco/vs/systemverilog-Ch4vA8Yt.js +1 -0
- package/ui/monaco/vs/tcl-D74tq1nH.js +1 -0
- package/ui/monaco/vs/toggleHighContrast-DuqAmExZ.js +65 -0
- package/ui/monaco/vs/ts.worker-BLYAhMkX.js +1 -0
- package/ui/monaco/vs/tsMode-DB1GiNun.js +11 -0
- package/ui/monaco/vs/twig-C6taOxMV.js +1 -0
- package/ui/monaco/vs/typescript-BTLNeq8B.js +1 -0
- package/ui/monaco/vs/typespec-8OfoLt6R.js +1 -0
- package/ui/monaco/vs/vb-Dyb2648j.js +1 -0
- package/ui/monaco/vs/wgsl-BhLXMOR0.js +298 -0
- package/ui/monaco/vs/workers-BeMslOxK.js +1 -0
- package/ui/monaco/vs/xml-DHCggo_L.js +1 -0
- package/ui/monaco/vs/yaml-C-RIivnc.js +1 -0
- package/ui/terminal.js +328 -0
- package/ui/vendor/addon-fit.mjs +18 -0
- package/ui/vendor/xterm.css +285 -0
- package/ui/vendor/xterm.mjs +53 -0
package/ui/ide.js
ADDED
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
// BitPull projects: an editor and an AI agent on a folder of this computer, laid out like a modern code editor.
|
|
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; icons are built element by element (a test checks it).
|
|
4
|
+
"use strict";
|
|
5
|
+
(function () {
|
|
6
|
+
const BP = window.__BITPULL__ || {};
|
|
7
|
+
const token = BP.token || "";
|
|
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(\?.*)?$/, "");
|
|
11
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
12
|
+
const MOD = isMac ? "⌘" : "Ctrl+";
|
|
13
|
+
const store = {
|
|
14
|
+
get(k, d) { try { const v = localStorage.getItem(`bitpull.ide.${k}`); return v === null ? d : JSON.parse(v); } catch { return d; } },
|
|
15
|
+
set(k, v) { try { localStorage.setItem(`bitpull.ide.${k}`, JSON.stringify(v)); } catch { /* private mode */ } },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Builds an element: attributes, then children (strings become text). */
|
|
19
|
+
function el(tag, attrs, ...kids) {
|
|
20
|
+
const n = document.createElement(tag);
|
|
21
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
22
|
+
if (v === undefined || v === null || v === false) continue;
|
|
23
|
+
if (k === "class") n.className = v;
|
|
24
|
+
else if (k === "text") n.textContent = v;
|
|
25
|
+
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
|
|
26
|
+
else n.setAttribute(k, v === true ? "" : String(v));
|
|
27
|
+
}
|
|
28
|
+
for (const k of kids.flat()) if (k !== null && k !== undefined && k !== false) n.append(typeof k === "string" || typeof k === "number" ? String(k) : k);
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
31
|
+
const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
|
|
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
|
+
|
|
93
|
+
// ---- words ------------------------------------------------------------------------------------
|
|
94
|
+
let dict = {}, fallback = {};
|
|
95
|
+
const t = (key, vars) => String(dict[key] ?? fallback[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => (vars && vars[k] !== undefined ? vars[k] : `{${k}}`));
|
|
96
|
+
const LANGS = ["en", "zh", "hi", "es", "ar", "fr", "bn", "pt", "ru", "id", "de", "it"];
|
|
97
|
+
let lang = LANGS.includes(BP.lang) ? BP.lang : "en";
|
|
98
|
+
async function loadWords() {
|
|
99
|
+
const get = (l) => fetch(`/i18n/${l}.json`).then((r) => (r.ok ? r.json() : {})).catch(() => ({}));
|
|
100
|
+
[dict, fallback] = await Promise.all([get(lang), lang === "en" ? Promise.resolve({}) : get("en")]);
|
|
101
|
+
document.documentElement.lang = lang;
|
|
102
|
+
document.documentElement.dir = lang === "ar" ? "rtl" : "ltr";
|
|
103
|
+
document.title = `${t("ide.title")} · BitPull`;
|
|
104
|
+
for (const n of document.querySelectorAll("[data-i18n]")) n.textContent = t(n.dataset.i18n);
|
|
105
|
+
for (const n of document.querySelectorAll("[data-i18n-placeholder]")) n.placeholder = t(n.dataset.i18nPlaceholder);
|
|
106
|
+
for (const n of document.querySelectorAll("[data-tip]")) n.setAttribute("aria-label", t(n.dataset.tip));
|
|
107
|
+
}
|
|
108
|
+
/** A known error's words, or the error as it came. */
|
|
109
|
+
function explain(err) {
|
|
110
|
+
const m = String((err && err.message) || err).replace(/^Error:\s*/, "");
|
|
111
|
+
for (const p of ["ide.err.", "pj.err."]) if (dict[p + m] !== undefined || fallback[p + m] !== undefined) return t(p + m);
|
|
112
|
+
if (/Failed to fetch|NetworkError|Load failed/.test(m)) return t("ide.err.app_down");
|
|
113
|
+
return m;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- the app's local API ------------------------------------------------------------------------
|
|
117
|
+
async function api(path, body) {
|
|
118
|
+
const res = await fetch(path, { method: body ? "POST" : "GET", headers: { "content-type": "application/json", "x-bitpull-token": token }, body: body ? JSON.stringify(body) : undefined });
|
|
119
|
+
const data = await res.json().catch(() => ({}));
|
|
120
|
+
if (!res.ok) throw new Error(data.error || res.status);
|
|
121
|
+
return data;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- tooltips: after a short pause over anything with data-tip ------------------------------------
|
|
125
|
+
let tipTimer = null;
|
|
126
|
+
document.addEventListener("mouseover", (e) => {
|
|
127
|
+
const n = e.target.closest && e.target.closest("[data-tip]");
|
|
128
|
+
clearTimeout(tipTimer);
|
|
129
|
+
if (!n) return;
|
|
130
|
+
tipTimer = setTimeout(() => {
|
|
131
|
+
const box = $("tip");
|
|
132
|
+
box.textContent = t(n.dataset.tip);
|
|
133
|
+
box.hidden = false;
|
|
134
|
+
const r = n.getBoundingClientRect();
|
|
135
|
+
box.style.left = `${Math.max(8, Math.min(r.left, innerWidth - box.offsetWidth - 8))}px`;
|
|
136
|
+
box.style.top = `${r.bottom + 6 + box.offsetHeight > innerHeight ? r.top - box.offsetHeight - 6 : r.bottom + 6}px`;
|
|
137
|
+
}, 700);
|
|
138
|
+
});
|
|
139
|
+
document.addEventListener("mouseout", () => { clearTimeout(tipTimer); $("tip").hidden = true; });
|
|
140
|
+
document.addEventListener("mousedown", () => { clearTimeout(tipTimer); $("tip").hidden = true; });
|
|
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
|
+
|
|
166
|
+
// ---- a modal of our own (the app's window has no native dialogs everywhere) -------------------------
|
|
167
|
+
function modal(title, body, buttons) {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
$("modal-title").textContent = title;
|
|
170
|
+
clear($("modal-body")).append(...[].concat(body || []));
|
|
171
|
+
const bar = clear($("modal-buttons"));
|
|
172
|
+
const close = (v) => { $("modal").hidden = true; document.removeEventListener("keydown", onKey, true); resolve(v); };
|
|
173
|
+
const onKey = (e) => { if (e.key === "Escape") { e.preventDefault(); close(null); } };
|
|
174
|
+
document.addEventListener("keydown", onKey, true);
|
|
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) }));
|
|
176
|
+
$("modal").hidden = false;
|
|
177
|
+
const first = $("modal-body").querySelector("input,textarea");
|
|
178
|
+
(first || bar.lastChild).focus();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const askText = async (title, label, value) => {
|
|
182
|
+
const input = el("input", { type: "text", value: value || "" });
|
|
183
|
+
input.addEventListener("keydown", (e) => { if (e.key === "Enter") $("modal-buttons").lastChild.click(); });
|
|
184
|
+
return modal(title, [el("p", { class: "muted small", text: label }), input], [{ label: t("pj.no"), ghost: true, value: null }, { label: t("ide.ok"), value: () => input.value.trim() || null }]);
|
|
185
|
+
};
|
|
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 }]);
|
|
187
|
+
|
|
188
|
+
// ---- state ------------------------------------------------------------------------------------
|
|
189
|
+
let editorLoaded = () => {};
|
|
190
|
+
const editorReady = new Promise((r) => { editorLoaded = r; });
|
|
191
|
+
let monaco = null, editor = null, editor2 = null, diffEditor = null, focused = null;
|
|
192
|
+
let folder = "";
|
|
193
|
+
const tabs = []; // { path, model, mtime, saved, view, secret }
|
|
194
|
+
let active = null;
|
|
195
|
+
let models = [];
|
|
196
|
+
let session = null; // the agent shown on the right
|
|
197
|
+
let cursor = 0;
|
|
198
|
+
let review = null;
|
|
199
|
+
let terminal = null;
|
|
200
|
+
let gitInfo = { branch: "", status: new Map(), ignored: [] };
|
|
201
|
+
let problemCounts = { errors: 0, warnings: 0 };
|
|
202
|
+
|
|
203
|
+
// ---- the terminal is another part of the app, which plugs in here -----------------------------
|
|
204
|
+
/**
|
|
205
|
+
* Terminal hook. The terminal registers itself with BitPullIDE.terminal.register(impl), where impl has:
|
|
206
|
+
* mount(container, { token, folder, lang, t }) draws itself into the panel, once;
|
|
207
|
+
* open() shows it and focuses it; close() hides it; newTab() opens another shell in the folder;
|
|
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.
|
|
210
|
+
*/
|
|
211
|
+
window.BitPullIDE = {
|
|
212
|
+
terminal: {
|
|
213
|
+
register(impl) {
|
|
214
|
+
terminal = impl;
|
|
215
|
+
clear($("terminal-host"));
|
|
216
|
+
impl.mount($("terminal-host"), { token, folder, lang, t });
|
|
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();
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
get folder() { return folder; },
|
|
234
|
+
};
|
|
235
|
+
function showTerminal() {
|
|
236
|
+
showBottom("terminal");
|
|
237
|
+
if (terminal) terminal.open();
|
|
238
|
+
else clear($("terminal-host")).append(el("p", { class: "muted pad", text: t("ide.terminal.missing") }));
|
|
239
|
+
}
|
|
240
|
+
|
|
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")) {
|
|
317
|
+
s.addEventListener("mousedown", (e) => {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
const which = s.dataset.split;
|
|
320
|
+
const L = layoutState;
|
|
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");
|
|
325
|
+
const move = (m) => {
|
|
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();
|
|
332
|
+
};
|
|
333
|
+
const up = () => { s.classList.remove("drag"); removeEventListener("mousemove", move); removeEventListener("mouseup", up); };
|
|
334
|
+
addEventListener("mousemove", move);
|
|
335
|
+
addEventListener("mouseup", up);
|
|
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
|
+
});
|
|
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
|
+
|
|
349
|
+
function showSide(name) {
|
|
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();
|
|
355
|
+
if (name === "git") gitRefresh();
|
|
356
|
+
if (name === "outline" || name === "files") renderOutline();
|
|
357
|
+
if (name === "history" || name === "files") renderHistory();
|
|
358
|
+
if (name === "search") $("search-q").focus();
|
|
359
|
+
}
|
|
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();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const currentBottom = () => (document.querySelector("[data-bottom].on") || {}).dataset?.bottom || "problems";
|
|
379
|
+
function showBottom(name) {
|
|
380
|
+
layoutState.panelOpen = true;
|
|
381
|
+
for (const b of document.querySelectorAll("[data-bottom]")) b.classList.toggle("on", b.dataset.bottom === name);
|
|
382
|
+
for (const p of document.querySelectorAll("[data-bpanel]")) p.hidden = p.dataset.bpanel !== name;
|
|
383
|
+
if (name === "ports") { document.querySelector('[data-bottom="ports"]').hidden = false; refreshPorts(); }
|
|
384
|
+
$("problems-run").hidden = name !== "problems";
|
|
385
|
+
layout();
|
|
386
|
+
}
|
|
387
|
+
for (const b of document.querySelectorAll("[data-bottom]")) b.addEventListener("click", () => (b.dataset.bottom === "terminal" ? showTerminal() : showBottom(b.dataset.bottom)));
|
|
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
|
+
});
|
|
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; };
|
|
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
|
+
|
|
418
|
+
// ---- Monaco, served by the app itself -------------------------------------------------------------
|
|
419
|
+
const MONACO_LANG = { de: "de", es: "es", fr: "fr", it: "it", ru: "ru", zh: "zh-cn", pt: "pt-br" };
|
|
420
|
+
function loadMonaco() {
|
|
421
|
+
return new Promise((resolve, reject) => {
|
|
422
|
+
const s = document.createElement("script");
|
|
423
|
+
s.src = `${BASE}monaco/vs/loader.js`;
|
|
424
|
+
s.onerror = () => reject(new Error("editor_missing"));
|
|
425
|
+
s.onload = () => {
|
|
426
|
+
window.require.config({ paths: { vs: `${BASE}monaco/vs` }, "vs/nls": { availableLanguages: { "*": MONACO_LANG[lang] || "en" } } });
|
|
427
|
+
// This version of the loader does not always call back. The global appears early, as soon as the
|
|
428
|
+
// core is loaded; the editor is complete (stylesheet, languages, all its services) only when the
|
|
429
|
+
// main module has run, which registers the JSON language: creating an editor before fails.
|
|
430
|
+
window.require(["vs/editor/editor.main"], () => {}, reject);
|
|
431
|
+
// With the editor translated (Italian, German…) the JSON language never shows up as a global: there the
|
|
432
|
+
// sign is that the languages are registered and have stayed so for a moment.
|
|
433
|
+
const started = Date.now();
|
|
434
|
+
let langsSince = 0;
|
|
435
|
+
const wait = setInterval(() => {
|
|
436
|
+
const m = window.monaco;
|
|
437
|
+
const langs = m && m.editor && m.languages && m.languages.getLanguages ? m.languages.getLanguages().length : 0;
|
|
438
|
+
if (langs > 50 && !langsSince) langsSince = Date.now();
|
|
439
|
+
if (m && m.editor && m.languages && (m.languages.json || (langsSince && Date.now() - langsSince > 400))) { clearInterval(wait); resolve(m); }
|
|
440
|
+
else if (Date.now() - started > 30000) { clearInterval(wait); reject(new Error("editor_missing")); }
|
|
441
|
+
}, 25);
|
|
442
|
+
};
|
|
443
|
+
document.body.appendChild(s);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
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
|
+
}
|
|
460
|
+
function setupEditor() {
|
|
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"));
|
|
469
|
+
}
|
|
470
|
+
let outlineTimer = null;
|
|
471
|
+
|
|
472
|
+
// ---- files --------------------------------------------------------------------------------------
|
|
473
|
+
const opened = new Set(["."]);
|
|
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) {
|
|
485
|
+
clear(into);
|
|
486
|
+
for (const e of entries) {
|
|
487
|
+
const li = el("li", { role: "treeitem" });
|
|
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),
|
|
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,
|
|
496
|
+
el("span", { class: "acts" },
|
|
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` }));
|
|
500
|
+
li.append(item);
|
|
501
|
+
if (e.dir) {
|
|
502
|
+
const sub = el("ul", { role: "group" });
|
|
503
|
+
li.append(sub);
|
|
504
|
+
if (open) loadTree(e.path, sub, depth + 1).catch(() => {});
|
|
505
|
+
item.addEventListener("click", () => {
|
|
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))); }
|
|
508
|
+
});
|
|
509
|
+
} else item.addEventListener("click", () => openFile(e.path));
|
|
510
|
+
into.append(li);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const refreshTree = () => { if (!folder) return; treeCache.clear(); loadTree(".", $("tree"), 0).catch((err) => output(explain(err))); };
|
|
514
|
+
async function newPath(dir) {
|
|
515
|
+
const name = await askText(t(dir ? "ide.new_dir" : "ide.new_file"), t("ide.new.hint"), active && !dir ? active.path.replace(/[^/]*$/, "") : "");
|
|
516
|
+
if (!name) return;
|
|
517
|
+
try { await api("/api/ws/create", { folder, path: name, dir }); refreshTree(); if (!dir) openFile(name); } catch (err) { output(explain(err)); }
|
|
518
|
+
}
|
|
519
|
+
async function renamePath(path) {
|
|
520
|
+
const to = await askText(t("ide.rename"), path, path);
|
|
521
|
+
if (!to || to === path) return;
|
|
522
|
+
try {
|
|
523
|
+
await api("/api/ws/rename", { folder, from: path, to });
|
|
524
|
+
const tab = tabs.find((x) => x.path === path);
|
|
525
|
+
if (tab) closeTab(tab, true);
|
|
526
|
+
refreshTree();
|
|
527
|
+
} catch (err) { output(explain(err)); }
|
|
528
|
+
}
|
|
529
|
+
async function deletePath(path, dir) {
|
|
530
|
+
if (!(await confirmBox(t("ide.delete"), t(dir ? "ide.delete.dir" : "ide.delete.file", { path }), t("ide.delete"), true))) return;
|
|
531
|
+
try { await api("/api/ws/delete", { folder, path }); const tab = tabs.find((x) => x.path === path); if (tab) closeTab(tab, true); refreshTree(); } catch (err) { output(explain(err)); }
|
|
532
|
+
}
|
|
533
|
+
$("new-file").addEventListener("click", () => newPath(false));
|
|
534
|
+
$("new-dir").addEventListener("click", () => newPath(true));
|
|
535
|
+
$("refresh").addEventListener("click", () => { refreshTree(); refreshGit(); });
|
|
536
|
+
$("collapse-all").addEventListener("click", () => { opened.clear(); opened.add("."); refreshTree(); });
|
|
537
|
+
|
|
538
|
+
// ---- tabs, breadcrumbs, the editor ------------------------------------------------------------------
|
|
539
|
+
const isDirty = (tab) => tab.model && tab.model.getAlternativeVersionId() !== tab.saved;
|
|
540
|
+
function renderTabs() {
|
|
541
|
+
const bar = clear($("tabs"));
|
|
542
|
+
for (const tab of tabs) {
|
|
543
|
+
const name = tab.path.split("/").pop();
|
|
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"))));
|
|
547
|
+
}
|
|
548
|
+
const on = bar.querySelector(".tab.on");
|
|
549
|
+
if (on) on.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
550
|
+
$("welcome").hidden = !!active || !!review;
|
|
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; }
|
|
574
|
+
}
|
|
575
|
+
async function openFile(path, line) {
|
|
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
|
+
}
|
|
584
|
+
if (!tab) {
|
|
585
|
+
if (r.binary) { output(t("ide.binary", { path })); return; }
|
|
586
|
+
const uri = monaco.Uri.from({ scheme: "file", path: `/${path}` });
|
|
587
|
+
const model = monaco.editor.getModel(uri) || monaco.editor.createModel(r.text, undefined, uri);
|
|
588
|
+
tab = { path, model, mtime: r.mtime, saved: model.getAlternativeVersionId(), secret: r.secret };
|
|
589
|
+
tabs.push(tab);
|
|
590
|
+
if (r.secret) output(t("ide.secret_open", { path }));
|
|
591
|
+
}
|
|
592
|
+
activate(tab);
|
|
593
|
+
const ed = focused || editor;
|
|
594
|
+
if (line) { ed.revealLineInCenter(line); ed.setPosition({ lineNumber: line, column: 1 }); ed.focus(); }
|
|
595
|
+
}
|
|
596
|
+
function activate(tab) {
|
|
597
|
+
const ed = focused || editor;
|
|
598
|
+
if (active && active.model && ed.getModel() === active.model) active.view = ed.saveViewState();
|
|
599
|
+
active = tab;
|
|
600
|
+
if (review) closeReview();
|
|
601
|
+
if (ed.getModel() !== tab.model) ed.setModel(tab.model);
|
|
602
|
+
if (tab.view) ed.restoreViewState(tab.view);
|
|
603
|
+
renderTabs();
|
|
604
|
+
renderCrumbs();
|
|
605
|
+
relayout();
|
|
606
|
+
clearTimeout(outlineTimer);
|
|
607
|
+
outlineTimer = setTimeout(() => { renderOutline(); if (layoutState.panes.history || layoutState.sideView === "history") renderHistory(); }, 120);
|
|
608
|
+
for (const n of document.querySelectorAll("#tree .item")) n.classList.toggle("on", n.title === tab.path);
|
|
609
|
+
ed.focus();
|
|
610
|
+
}
|
|
611
|
+
async function closeTab(tab, force) {
|
|
612
|
+
if (!force && isDirty(tab) && !(await confirmBox(t("ide.unsaved"), t("ide.unsaved.text", { path: tab.path }), t("ide.discard"), true))) return;
|
|
613
|
+
tabs.splice(tabs.indexOf(tab), 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]); }
|
|
616
|
+
tab.model.dispose();
|
|
617
|
+
renderTabs();
|
|
618
|
+
renderCrumbs();
|
|
619
|
+
}
|
|
620
|
+
async function saveActive(force) {
|
|
621
|
+
const tab = active;
|
|
622
|
+
if (!tab) return;
|
|
623
|
+
try {
|
|
624
|
+
const r = await api("/api/ws/write", { folder, path: tab.path, content: tab.model.getValue(), mtime: tab.mtime, force: !!force });
|
|
625
|
+
tab.mtime = r.mtime;
|
|
626
|
+
tab.saved = tab.model.getAlternativeVersionId();
|
|
627
|
+
renderTabs();
|
|
628
|
+
refreshGit();
|
|
629
|
+
} catch (err) {
|
|
630
|
+
if (String(err.message) !== "file_changed_on_disk") return output(explain(err));
|
|
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" }]);
|
|
632
|
+
if (choice === "overwrite") return saveActive(true);
|
|
633
|
+
if (choice === "reload") reloadTab(tab);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function reloadTab(tab) {
|
|
637
|
+
const r = await api("/api/ws/read", { folder, path: tab.path });
|
|
638
|
+
if (r.binary) return;
|
|
639
|
+
tab.model.setValue(r.text);
|
|
640
|
+
tab.mtime = r.mtime;
|
|
641
|
+
tab.saved = tab.model.getAlternativeVersionId();
|
|
642
|
+
renderTabs();
|
|
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
|
+
]));
|
|
660
|
+
|
|
661
|
+
// ---- reviewing a change in the editor, piece by piece ---------------------------------------------
|
|
662
|
+
function closeReview() {
|
|
663
|
+
// The diff editor lets go of its models first, then they can go.
|
|
664
|
+
if (diffEditor) diffEditor.setModel(null);
|
|
665
|
+
if (review) { review.original.dispose(); review.modified.dispose(); }
|
|
666
|
+
review = null;
|
|
667
|
+
$("review").hidden = true;
|
|
668
|
+
$("diff").hidden = true;
|
|
669
|
+
renderTabs();
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Shows `before` against `after` side by side. With pieces = true each changed piece can be put back
|
|
673
|
+
* one by one (it is changed in the right-hand side, which the person then keeps or saves).
|
|
674
|
+
*/
|
|
675
|
+
function showReview({ title, before, after, path, pieces, accept, reject, acceptLabel, rejectLabel }) {
|
|
676
|
+
const langUri = monaco.Uri.from({ scheme: "file", path: `/${path || "x.txt"}` });
|
|
677
|
+
const original = monaco.editor.createModel(before ?? "", undefined, monaco.Uri.from({ scheme: "bp-before", path: `/${Date.now()}/${path || "x"}` }));
|
|
678
|
+
const modified = monaco.editor.createModel(after ?? "", undefined, monaco.Uri.from({ scheme: "bp-after", path: `/${Date.now()}/${path || "x"}` }));
|
|
679
|
+
const language = monaco.editor.getModel(langUri) ? monaco.editor.getModel(langUri).getLanguageId() : null;
|
|
680
|
+
if (language) { monaco.editor.setModelLanguage(original, language); monaco.editor.setModelLanguage(modified, language); }
|
|
681
|
+
if (review) { diffEditor.setModel(null); review.original.dispose(); review.modified.dispose(); }
|
|
682
|
+
diffEditor.setModel({ original, modified });
|
|
683
|
+
review = { path, original, modified };
|
|
684
|
+
$("review").hidden = false;
|
|
685
|
+
$("diff").hidden = false;
|
|
686
|
+
$("editor").hidden = true;
|
|
687
|
+
$("editor2").hidden = true;
|
|
688
|
+
$("welcome").hidden = true;
|
|
689
|
+
$("crumbs").hidden = true;
|
|
690
|
+
$("review-title").textContent = title;
|
|
691
|
+
$("review-accept").textContent = acceptLabel || t("ide.review.keep");
|
|
692
|
+
$("review-reject").textContent = rejectLabel || t("ide.review.revert_all");
|
|
693
|
+
$("review-accept").onclick = async () => { const keep = modified.getValue(); closeReview(); if (accept) await accept(keep); };
|
|
694
|
+
$("review-reject").onclick = async () => { closeReview(); if (reject) await reject(); };
|
|
695
|
+
const hunks = clear($("review-hunks"));
|
|
696
|
+
if (pieces) {
|
|
697
|
+
const draw = () => {
|
|
698
|
+
clear(hunks);
|
|
699
|
+
const changes = diffEditor.getLineChanges() || [];
|
|
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) })));
|
|
701
|
+
};
|
|
702
|
+
diffEditor.onDidUpdateDiff(draw);
|
|
703
|
+
}
|
|
704
|
+
relayout();
|
|
705
|
+
}
|
|
706
|
+
/** Puts one changed piece back as it was. */
|
|
707
|
+
function revertPiece(c, original, modified) {
|
|
708
|
+
const origLines = c.originalEndLineNumber === 0 ? [] : original.getValue().split("\n").slice(c.originalStartLineNumber - 1, c.originalEndLineNumber);
|
|
709
|
+
let range;
|
|
710
|
+
if (c.modifiedEndLineNumber === 0) range = new monaco.Range(c.modifiedStartLineNumber + 1, 1, c.modifiedStartLineNumber + 1, 1);
|
|
711
|
+
else range = new monaco.Range(c.modifiedStartLineNumber, 1, c.modifiedEndLineNumber, modified.getLineMaxColumn(c.modifiedEndLineNumber));
|
|
712
|
+
let text = origLines.join("\n");
|
|
713
|
+
if (c.modifiedEndLineNumber === 0) text += "\n";
|
|
714
|
+
else if (!origLines.length) {
|
|
715
|
+
// The piece only added lines: remove them, with their line ending.
|
|
716
|
+
const end = c.modifiedEndLineNumber < modified.getLineCount() ? new monaco.Range(c.modifiedStartLineNumber, 1, c.modifiedEndLineNumber + 1, 1) : new monaco.Range(Math.max(1, c.modifiedStartLineNumber - 1), modified.getLineMaxColumn(Math.max(1, c.modifiedStartLineNumber - 1)), c.modifiedEndLineNumber, modified.getLineMaxColumn(c.modifiedEndLineNumber));
|
|
717
|
+
modified.pushEditOperations([], [{ range: end, text: "" }], () => null);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
modified.pushEditOperations([], [{ range, text }], () => null);
|
|
721
|
+
}
|
|
722
|
+
$("review-close").addEventListener("click", closeReview);
|
|
723
|
+
|
|
724
|
+
/** The agent's change to a file: compared with the file as it was before the agent started. */
|
|
725
|
+
async function reviewAgentChange(path) {
|
|
726
|
+
if (!session) return;
|
|
727
|
+
try {
|
|
728
|
+
const r = await api("/api/projects/original", { id: session.id, path });
|
|
729
|
+
showReview({
|
|
730
|
+
title: t("ide.review.agent", { path }), before: r.before ?? "", after: r.now ?? "", path, pieces: true,
|
|
731
|
+
acceptLabel: t("ide.review.keep"), rejectLabel: t("ide.review.revert_all"),
|
|
732
|
+
accept: async (text) => { if (text !== r.now) await writeFrom(path, text); },
|
|
733
|
+
reject: async () => { await writeFrom(path, r.before ?? ""); },
|
|
734
|
+
});
|
|
735
|
+
} catch (err) { output(explain(err)); }
|
|
736
|
+
}
|
|
737
|
+
async function writeFrom(path, text) {
|
|
738
|
+
try {
|
|
739
|
+
await api("/api/ws/write", { folder, path, content: text, force: true });
|
|
740
|
+
const tab = tabs.find((x) => x.path === path);
|
|
741
|
+
if (tab) await reloadTab(tab);
|
|
742
|
+
} catch (err) { output(explain(err)); }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ---- quick open, the command palette, search -------------------------------------------------------
|
|
746
|
+
function picker(title, placeholder, source, onPick) {
|
|
747
|
+
const input = el("input", { type: "text", placeholder });
|
|
748
|
+
const list = el("ul", { class: "list" });
|
|
749
|
+
let results = [], sel = 0;
|
|
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); };
|
|
752
|
+
let timer = null;
|
|
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);
|
|
755
|
+
input.addEventListener("keydown", (e) => {
|
|
756
|
+
if (e.key === "ArrowDown") { sel = Math.min(results.length - 1, sel + 1); draw(); e.preventDefault(); }
|
|
757
|
+
if (e.key === "ArrowUp") { sel = Math.max(0, sel - 1); draw(); e.preventDefault(); }
|
|
758
|
+
if (e.key === "Enter" && results[sel]) pick(results[sel]);
|
|
759
|
+
});
|
|
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());
|
|
793
|
+
}
|
|
794
|
+
$("palette-btn").addEventListener("click", palette);
|
|
795
|
+
let searchTimer = null;
|
|
796
|
+
async function runSearch() {
|
|
797
|
+
const q = $("search-q").value.trim();
|
|
798
|
+
const out = clear($("search-results"));
|
|
799
|
+
$("search-info").textContent = "";
|
|
800
|
+
if (!q || !folder) return;
|
|
801
|
+
try {
|
|
802
|
+
const r = await api("/api/ws/search", { folder, query: q, caseSensitive: $("search-case").checked, regex: $("search-regex").checked });
|
|
803
|
+
$("search-info").textContent = t(r.truncated ? "ide.search.many" : "ide.search.count", { n: r.results.length });
|
|
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}` })));
|
|
805
|
+
} catch (err) { $("search-info").textContent = explain(err); }
|
|
806
|
+
}
|
|
807
|
+
for (const id of ["search-q", "search-case", "search-regex"]) $(id).addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(runSearch, 250); });
|
|
808
|
+
|
|
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
|
+
}
|
|
823
|
+
async function gitRefresh() {
|
|
824
|
+
if (!folder) return;
|
|
825
|
+
const staged = clear($("git-staged")), changes = clear($("git-changes"));
|
|
826
|
+
$("git-msg").textContent = "";
|
|
827
|
+
try {
|
|
828
|
+
const r = await api("/api/ws/git/status", { folder });
|
|
829
|
+
$("git-branch").textContent = r.branch ? t("ide.git.branch", { branch: r.branch }) : "";
|
|
830
|
+
for (const f of r.files) {
|
|
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));
|
|
837
|
+
}
|
|
838
|
+
if (!r.files.length) $("git-msg").textContent = t("ide.git.clean");
|
|
839
|
+
} catch (err) { $("git-branch").textContent = ""; $("git-msg").textContent = explain(err); }
|
|
840
|
+
refreshGit();
|
|
841
|
+
}
|
|
842
|
+
async function gitDiff(path, staged) {
|
|
843
|
+
try {
|
|
844
|
+
const [d, now] = await Promise.all([api("/api/ws/git/diff", { folder, path, staged }), api("/api/ws/read", { folder, path }).catch(() => ({ text: "" }))]);
|
|
845
|
+
showReview({ title: t("ide.git.diff", { path }), before: d.base, after: now.text || "", path, pieces: false, acceptLabel: t("ide.close"), rejectLabel: t("ide.git.discard"),
|
|
846
|
+
reject: async () => { if (await confirmBox(t("ide.git.discard"), t("ide.git.discard.text", { path }), t("ide.git.discard"), true)) await writeFrom(path, d.base); gitRefresh(); } });
|
|
847
|
+
} catch (err) { $("git-msg").textContent = explain(err); }
|
|
848
|
+
}
|
|
849
|
+
$("git-refresh").addEventListener("click", gitRefresh);
|
|
850
|
+
$("sb-branch").addEventListener("click", () => showSide("git"));
|
|
851
|
+
$("git-commit").addEventListener("click", async () => {
|
|
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); }
|
|
853
|
+
});
|
|
854
|
+
$("git-push").addEventListener("click", async () => {
|
|
855
|
+
if (!(await confirmBox(t("ide.git.push"), t("ide.git.push.confirm"), t("ide.git.push")))) return;
|
|
856
|
+
try { const r = await api("/api/ws/git/push", { folder, confirmed: true }); output(r.output || t("ide.git.pushed")); } catch (err) { $("git-msg").textContent = explain(err); }
|
|
857
|
+
});
|
|
858
|
+
|
|
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"));
|
|
868
|
+
$("problems-run").addEventListener("click", async () => {
|
|
869
|
+
if (!folder) return;
|
|
870
|
+
const list = clear($("problems"));
|
|
871
|
+
showBottom("problems");
|
|
872
|
+
list.append(el("li", { class: "muted", text: t("ide.problems.running") }));
|
|
873
|
+
try {
|
|
874
|
+
const r = await api("/api/ws/problems", { folder });
|
|
875
|
+
clear(list);
|
|
876
|
+
if (!r.ran.length) list.append(el("li", { class: "muted", text: t("ide.problems.none_setup") }));
|
|
877
|
+
else if (!r.problems.length) list.append(el("li", { class: "muted", text: t("ide.problems.clean", { tools: r.ran.join(", ") }) }));
|
|
878
|
+
const byFile = {};
|
|
879
|
+
problemCounts = { errors: 0, warnings: 0 };
|
|
880
|
+
for (const p of r.problems) {
|
|
881
|
+
(byFile[p.file] = byFile[p.file] || []).push(p);
|
|
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}` })));
|
|
884
|
+
}
|
|
885
|
+
renderProblemCounts();
|
|
886
|
+
for (const m of monaco.editor.getModels()) {
|
|
887
|
+
if (m.uri.scheme !== "file") continue;
|
|
888
|
+
const ps = byFile[m.uri.path.slice(1)] || [];
|
|
889
|
+
monaco.editor.setModelMarkers(m, "bitpull", ps.map((p) => ({ startLineNumber: p.line, startColumn: p.column, endLineNumber: p.line, endColumn: p.column + 1, message: `${p.source}: ${p.message}`, severity: p.severity === "error" ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning })));
|
|
890
|
+
}
|
|
891
|
+
} catch (err) { clear(list).append(el("li", { class: "muted", text: explain(err) })); }
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
/** A simple outline from the text: classes, functions and headings, for every language. */
|
|
895
|
+
const OUTLINE = [
|
|
896
|
+
/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/,
|
|
897
|
+
/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/,
|
|
898
|
+
/^\s*(?:async\s+)?def\s+(\w+)|^\s*class\s+(\w+)/,
|
|
899
|
+
/^\s*func\s+(?:\([^)]*\)\s*)?(\w+)|^\s*(?:pub\s+)?(?:fn|struct|enum|trait|impl)\s+(\w+)/,
|
|
900
|
+
/^\s*(?:public|private|protected|static|\s)+[\w<>[\],\s]+\s+(\w+)\s*\([^;]*$/,
|
|
901
|
+
/^(#{1,6})\s+(.+)$/,
|
|
902
|
+
];
|
|
903
|
+
const outlineLists = () => [$("outline"), $("outline-view")];
|
|
904
|
+
function renderOutline() {
|
|
905
|
+
const want = layoutState.panes.outline || layoutState.sideView === "outline";
|
|
906
|
+
for (const list of outlineLists()) clear(list);
|
|
907
|
+
if (!want || !active || !active.model) return;
|
|
908
|
+
const lines = active.model.getValue().split("\n");
|
|
909
|
+
if (lines.length > 20000) return;
|
|
910
|
+
const items = [];
|
|
911
|
+
lines.forEach((text, i) => {
|
|
912
|
+
for (const re of OUTLINE) {
|
|
913
|
+
const m = re.exec(text);
|
|
914
|
+
if (!m) continue;
|
|
915
|
+
const name = re === OUTLINE[5] ? `${" ".repeat(m[1].length - 1)}${m[2]}` : m.slice(1).find(Boolean);
|
|
916
|
+
if (name) items.push([i + 1, name]);
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
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) })));
|
|
921
|
+
markOutline();
|
|
922
|
+
}
|
|
923
|
+
function markOutline() {
|
|
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
|
+
}
|
|
932
|
+
}
|
|
933
|
+
async function renderHistory() {
|
|
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; }
|
|
938
|
+
try {
|
|
939
|
+
const r = await api("/api/ws/history", { folder, path: active.path });
|
|
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) })); }
|
|
945
|
+
}
|
|
946
|
+
async function showVersion(path, id) {
|
|
947
|
+
try {
|
|
948
|
+
const v = await api("/api/ws/version", { folder, path, id });
|
|
949
|
+
const tab = tabs.find((x) => x.path === path);
|
|
950
|
+
const now = tab ? tab.model.getValue() : (await api("/api/ws/read", { folder, path })).text;
|
|
951
|
+
showReview({ title: t("ide.history.compare", { path }), before: v.text, after: now, path, pieces: false, acceptLabel: t("ide.close"), rejectLabel: t("ide.history.restore"),
|
|
952
|
+
reject: async () => { if (tab) { tab.model.setValue(v.text); activate(tab); } else await writeFrom(path, v.text); } });
|
|
953
|
+
} catch (err) { output(explain(err)); }
|
|
954
|
+
}
|
|
955
|
+
async function openPreview() {
|
|
956
|
+
const url = await askText(t("ide.preview"), t("ide.preview.hint"), store.get("preview", "http://localhost:3000"));
|
|
957
|
+
if (!url) return;
|
|
958
|
+
store.set("preview", url);
|
|
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
|
+
]);
|
|
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
|
+
}
|
|
991
|
+
|
|
992
|
+
// ---- models, folder, mode, limits ---------------------------------------------------------------------
|
|
993
|
+
async function loadModels() {
|
|
994
|
+
const r = await api("/api/chat/models");
|
|
995
|
+
models = [
|
|
996
|
+
...r.own.map((m) => ({ ...m, key: `own:${m.model}`, label: m.name, own: true, paid: false })),
|
|
997
|
+
...r.network.map((m) => ({ ...m, key: `net:${m.model}`, label: m.name, own: false, paid: false })),
|
|
998
|
+
...r.paid.map((m) => ({ ...m, model: m.id, key: `paid:${m.id}`, label: m.name, own: false, paid: true })),
|
|
999
|
+
].filter((m) => (m.tasks || ["chat"]).includes("chat") || m.paid);
|
|
1000
|
+
const sel = clear($("model"));
|
|
1001
|
+
const groups = [["chat.group.own", models.filter((m) => m.own)], ["chat.group.network", models.filter((m) => !m.own && !m.paid)], ["chat.group.paid", models.filter((m) => m.paid)]];
|
|
1002
|
+
for (const [label, list] of groups) {
|
|
1003
|
+
if (!list.length) continue;
|
|
1004
|
+
const g = el("optgroup", { label: t(label) });
|
|
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 }));
|
|
1006
|
+
sel.append(g);
|
|
1007
|
+
}
|
|
1008
|
+
if (!models.length) sel.append(el("option", { value: "", text: t("ide.model.none") }));
|
|
1009
|
+
const keep = store.get("model", "");
|
|
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);
|
|
1011
|
+
if (first) sel.value = first.key;
|
|
1012
|
+
renderStatusModel();
|
|
1013
|
+
}
|
|
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(); });
|
|
1018
|
+
async function loadFolders() {
|
|
1019
|
+
const r = await api("/api/projects");
|
|
1020
|
+
const sel = clear($("folder"));
|
|
1021
|
+
for (const f of r.folders) sel.append(el("option", { value: f, text: f }));
|
|
1022
|
+
if (!r.folders.length) sel.append(el("option", { value: "", text: t("ide.folder.none") }));
|
|
1023
|
+
const want = BP.folder || store.get("folder", "");
|
|
1024
|
+
sel.value = r.folders.includes(want) ? want : r.folders[0] || "";
|
|
1025
|
+
return r;
|
|
1026
|
+
}
|
|
1027
|
+
async function setFolder(f) {
|
|
1028
|
+
if (tabs.some(isDirty) && !(await confirmBox(t("ide.unsaved"), t("ide.unsaved.all"), t("ide.discard"), true))) { $("folder").value = folder; return; }
|
|
1029
|
+
for (const tab of [...tabs]) closeTab(tab, true);
|
|
1030
|
+
folder = f;
|
|
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");
|
|
1035
|
+
session = null;
|
|
1036
|
+
cursor = 0;
|
|
1037
|
+
clear($("agent-log"));
|
|
1038
|
+
opened.clear();
|
|
1039
|
+
opened.add(".");
|
|
1040
|
+
treeCache.clear();
|
|
1041
|
+
await refreshGit();
|
|
1042
|
+
refreshTree();
|
|
1043
|
+
if (terminal && terminal.folderChanged) terminal.folderChanged(f);
|
|
1044
|
+
await refreshAgents();
|
|
1045
|
+
}
|
|
1046
|
+
$("folder").addEventListener("change", () => setFolder($("folder").value));
|
|
1047
|
+
const MODES = ["ask", "edits", "auto"];
|
|
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
|
+
}
|
|
1055
|
+
async function setMode(mode) {
|
|
1056
|
+
const prev = $("mode").value;
|
|
1057
|
+
$("mode").value = mode;
|
|
1058
|
+
renderModeHint();
|
|
1059
|
+
if (!session) return;
|
|
1060
|
+
try { await api("/api/projects/mode", { id: session.id, mode, remoteConfirmed: mode === "auto" && session.leaves ? await remoteOk(true) : undefined }); }
|
|
1061
|
+
catch (err) { $("mode").value = prev; renderModeHint(); output(explain(err)); }
|
|
1062
|
+
}
|
|
1063
|
+
const cycleMode = () => setMode(MODES[(MODES.indexOf($("mode").value) + 1) % MODES.length]);
|
|
1064
|
+
$("mode").addEventListener("change", () => setMode($("mode").value));
|
|
1065
|
+
$("sb-mode").addEventListener("click", cycleMode);
|
|
1066
|
+
/** Content that leaves this computer: the person says yes once, knowing it. */
|
|
1067
|
+
async function remoteOk(auto) {
|
|
1068
|
+
return !!(await confirmBox(t("ide.remote.title"), t(auto ? "pj.auto.confirm" : "pj.remote"), t("pj.yes")));
|
|
1069
|
+
}
|
|
1070
|
+
const limitsExplained = () => el("dl", {}, el("dt", { text: t("ide.limits.why.title") }), el("dd", { text: t("ide.limits.why") }), el("dt", { text: t("ide.limits.not.title") }), el("dd", { text: t("ide.limits.not") }), el("dt", { text: t("ide.limits.auto.title") }), el("dd", { text: t("ide.limits.auto") }));
|
|
1071
|
+
async function editLimits() {
|
|
1072
|
+
// Before an agent exists there is nothing to change yet, but the explanation is useful already.
|
|
1073
|
+
if (!session) {
|
|
1074
|
+
await modal(t("pj.limits"), [el("p", { text: t("ide.limits.intro") }), ...["steps", "minutes", "credits"].map((k) => el("p", { class: "muted small", text: t(`ide.limits.${k}.what`) })), limitsExplained(), el("p", { class: "muted small", text: t("ide.agent.none") })], [{ label: t("ide.ok"), value: null }]);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const field = (k) => {
|
|
1078
|
+
const input = el("input", { type: "number", min: k === "credits" ? 0 : 1, step: "1", value: session.limits[k] });
|
|
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`) })) };
|
|
1080
|
+
};
|
|
1081
|
+
const fields = ["steps", "minutes", "credits"].map(field);
|
|
1082
|
+
const body = [el("p", { text: t("ide.limits.intro") }), ...fields.map((f) => f.block), limitsExplained()];
|
|
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" }]);
|
|
1084
|
+
try {
|
|
1085
|
+
if (choice === "auto") session = await api("/api/projects/limits", { id: session.id, auto: true });
|
|
1086
|
+
if (choice === "save") session = await api("/api/projects/limits", { id: session.id, limits: Object.fromEntries(fields.map((f) => [f.k, Number(f.input.value)])) });
|
|
1087
|
+
renderAgent(session);
|
|
1088
|
+
} catch (err) { output(explain(err)); }
|
|
1089
|
+
}
|
|
1090
|
+
$("limit-edit").addEventListener("click", editLimits);
|
|
1091
|
+
|
|
1092
|
+
// ---- agents ---------------------------------------------------------------------------------------
|
|
1093
|
+
let agentList = [];
|
|
1094
|
+
async function refreshAgents() {
|
|
1095
|
+
const r = await api("/api/projects");
|
|
1096
|
+
agentList = (r.sessions || []).filter((s) => s.folder === folder).sort((x, y) => y.updated - x.updated);
|
|
1097
|
+
drawAgents();
|
|
1098
|
+
if (!session && agentList.length) selectAgent(agentList[0].id);
|
|
1099
|
+
$("agents-toggle").textContent = session ? session.title || t("ide.agent.untitled") : "";
|
|
1100
|
+
}
|
|
1101
|
+
function drawAgents() {
|
|
1102
|
+
const q = $("agents-q").value.trim().toLowerCase();
|
|
1103
|
+
const list = clear($("agents-list"));
|
|
1104
|
+
for (const a of agentList.filter((x) => !q || (x.title || "").toLowerCase().includes(q))) {
|
|
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(/[.…]$/, "") })));
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
$("agents-q").addEventListener("input", drawAgents);
|
|
1110
|
+
async function selectAgent(id) {
|
|
1111
|
+
session = await api("/api/projects/state", { id, after: 0 });
|
|
1112
|
+
cursor = 0;
|
|
1113
|
+
clear($("agent-log"));
|
|
1114
|
+
$("mode").value = session.mode;
|
|
1115
|
+
renderModeHint();
|
|
1116
|
+
renderAgent(session);
|
|
1117
|
+
drawAgents();
|
|
1118
|
+
}
|
|
1119
|
+
async function newAgent() {
|
|
1120
|
+
const m = chosenModel();
|
|
1121
|
+
if (!folder) return output(t("pj.err.project_folder_not_authorised"));
|
|
1122
|
+
if (!m) return output(t("pj.err.project_needs_model"));
|
|
1123
|
+
const leaves = !m.own;
|
|
1124
|
+
let remoteConfirmed;
|
|
1125
|
+
if (leaves) { remoteConfirmed = await remoteOk($("mode").value === "auto"); if (!remoteConfirmed) return null; }
|
|
1126
|
+
try {
|
|
1127
|
+
session = await api("/api/projects/open", { folder, model: m.model, modelName: m.label, paid: m.paid, placement: m.own ? "mine" : "auto", mode: $("mode").value, remoteConfirmed });
|
|
1128
|
+
cursor = 0;
|
|
1129
|
+
clear($("agent-log"));
|
|
1130
|
+
renderAgent(session);
|
|
1131
|
+
await refreshAgents();
|
|
1132
|
+
layoutState.agentOpen = true;
|
|
1133
|
+
layout();
|
|
1134
|
+
$("prompt").focus();
|
|
1135
|
+
return session;
|
|
1136
|
+
} catch (err) { output(explain(err)); return null; }
|
|
1137
|
+
}
|
|
1138
|
+
$("agent-new").addEventListener("click", newAgent);
|
|
1139
|
+
|
|
1140
|
+
const STATUS_BUSY = new Set(["running", "waiting", "paused", "limit"]);
|
|
1141
|
+
function renderAgent(v) {
|
|
1142
|
+
if (!v) return;
|
|
1143
|
+
session = { ...session, ...v };
|
|
1144
|
+
const log = $("agent-log");
|
|
1145
|
+
for (const e of v.events || []) {
|
|
1146
|
+
if (e.n < cursor) continue;
|
|
1147
|
+
cursor = e.n + 1;
|
|
1148
|
+
log.append(eventNode(e));
|
|
1149
|
+
if (e.kind === "output") { output(e.text); if (terminal && terminal.agentOutput) terminal.agentOutput(e.text); }
|
|
1150
|
+
if (e.kind === "action" && e.action === "run") { output(`$ ${e.text}`); if (terminal && terminal.runVisible) terminal.runVisible(e.text); setTimeout(refreshPorts, 2500); }
|
|
1151
|
+
if (e.kind === "notice" && (e.code === "command_ended" || e.code === "command_timeout") && terminal && terminal.agentDone) terminal.agentDone(e.text);
|
|
1152
|
+
}
|
|
1153
|
+
log.scrollTop = log.scrollHeight;
|
|
1154
|
+
$("agent-status").textContent = `${t(`pj.status.${v.status}`)} · ${t("pj.used", { steps: v.used.steps, minutes: v.used.minutes, credits: Math.round(v.used.credits * 100) / 100 })} · ${t("ide.limits.short", { steps: v.limits.steps, minutes: v.limits.minutes, credits: v.limits.credits || "—" })}`;
|
|
1155
|
+
$("agent-stop").hidden = !STATUS_BUSY.has(v.status);
|
|
1156
|
+
$("agent-send").hidden = STATUS_BUSY.has(v.status);
|
|
1157
|
+
$("agent-undo").hidden = !v.canUndo;
|
|
1158
|
+
$("agent-limit").hidden = v.status !== "limit";
|
|
1159
|
+
if (v.status === "limit") $("agent-limit-text").textContent = t("ide.limit.text", { steps: v.limits.steps, minutes: v.limits.minutes, credits: v.limits.credits || "—" });
|
|
1160
|
+
renderPending(v.pending);
|
|
1161
|
+
const ch = clear($("agent-changes"));
|
|
1162
|
+
if (v.changes && v.changes.length) {
|
|
1163
|
+
ch.append(el("div", { class: "sub-head", text: t("pj.tab.changes") }));
|
|
1164
|
+
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); } })));
|
|
1165
|
+
}
|
|
1166
|
+
$("agents-toggle").textContent = v.title || t("ide.agent.untitled");
|
|
1167
|
+
for (const tab of tabs) if (!isDirty(tab) && v.changes && v.changes.some((c) => c.path === tab.path)) reloadTab(tab).catch(() => {});
|
|
1168
|
+
}
|
|
1169
|
+
function eventNode(e) {
|
|
1170
|
+
if (e.kind === "user") return el("div", { class: "ev user", text: e.text });
|
|
1171
|
+
if (e.kind === "assistant") return el("div", { class: "ev", text: e.text });
|
|
1172
|
+
if (e.kind === "output") return el("div", { class: "ev output", text: e.text });
|
|
1173
|
+
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 || ""}` }));
|
|
1174
|
+
if (e.kind === "error") return el("div", { class: "ev error", text: `${explain(e.code)}${e.text ? `\n${e.text}` : ""}` });
|
|
1175
|
+
const key = `pj.note.${e.code}`;
|
|
1176
|
+
return el("div", { class: "ev notice", text: t(key, { code: e.text }) });
|
|
1177
|
+
}
|
|
1178
|
+
let shownPending = null;
|
|
1179
|
+
function renderPending(p) {
|
|
1180
|
+
const box = $("agent-pending");
|
|
1181
|
+
if (!p) { box.hidden = true; shownPending = null; return; }
|
|
1182
|
+
if (shownPending === p.id) return;
|
|
1183
|
+
shownPending = p.id;
|
|
1184
|
+
clear(box);
|
|
1185
|
+
box.hidden = false;
|
|
1186
|
+
const answer = async (approved) => { try { renderAgent(await api("/api/projects/approve", { id: session.id, action: p.id, approved })); } catch (err) { output(explain(err)); } closeReview(); };
|
|
1187
|
+
if (p.type === "write") {
|
|
1188
|
+
box.append(el("strong", { text: t("pj.ask.write", { path: p.path }) }));
|
|
1189
|
+
box.append(el("div", { class: "row" }, el("button", { class: "ghost", "data-tip": "tip.ide.review.open", text: t("ide.review.open"), onclick: () => showReview({ title: t("pj.ask.write", { path: p.path }), before: p.before, after: p.after, path: p.path, pieces: false, acceptLabel: t("pj.approve"), rejectLabel: t("pj.refuse"), accept: () => answer(true), reject: () => answer(false) }) })));
|
|
1190
|
+
if (monaco && p.before !== undefined) showReview({ title: t("pj.ask.write", { path: p.path }), before: p.before, after: p.after, path: p.path, pieces: false, acceptLabel: t("pj.approve"), rejectLabel: t("pj.refuse"), accept: () => answer(true), reject: () => answer(false) });
|
|
1191
|
+
} else {
|
|
1192
|
+
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")}` }));
|
|
1193
|
+
if (p.risk) box.append(el("p", { class: "small", text: t(`pj.risk.${p.risk}`) }));
|
|
1194
|
+
}
|
|
1195
|
+
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) })));
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** @-mentions: typing @ suggests files of the folder. */
|
|
1199
|
+
let mentionTimer = null, mentionSel = 0, mentionItems = [];
|
|
1200
|
+
function mentionQuery() {
|
|
1201
|
+
const ta = $("prompt");
|
|
1202
|
+
const before = ta.value.slice(0, ta.selectionStart);
|
|
1203
|
+
const m = /(?:^|\s)@([^\s@]*)$/.exec(before);
|
|
1204
|
+
return m ? m[1] : null;
|
|
1205
|
+
}
|
|
1206
|
+
function drawMentions() {
|
|
1207
|
+
const box = clear($("mention"));
|
|
1208
|
+
box.hidden = !mentionItems.length;
|
|
1209
|
+
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 }))));
|
|
1210
|
+
}
|
|
1211
|
+
function insertMention(path) {
|
|
1212
|
+
const ta = $("prompt");
|
|
1213
|
+
const before = ta.value.slice(0, ta.selectionStart).replace(/@([^\s@]*)$/, `@${path} `);
|
|
1214
|
+
ta.value = before + ta.value.slice(ta.selectionStart);
|
|
1215
|
+
ta.selectionStart = ta.selectionEnd = before.length;
|
|
1216
|
+
mentionItems = [];
|
|
1217
|
+
drawMentions();
|
|
1218
|
+
ta.focus();
|
|
1219
|
+
}
|
|
1220
|
+
$("prompt").addEventListener("input", () => {
|
|
1221
|
+
const q = mentionQuery();
|
|
1222
|
+
clearTimeout(mentionTimer);
|
|
1223
|
+
if (q === null || !folder) { mentionItems = []; drawMentions(); return; }
|
|
1224
|
+
mentionTimer = setTimeout(async () => {
|
|
1225
|
+
try { mentionItems = q ? (await api("/api/ws/search", { folder, query: q, names: true })).results.slice(0, 12).map((r) => r.path) : tabs.map((x) => x.path).slice(0, 12); } catch { mentionItems = []; }
|
|
1226
|
+
mentionSel = 0;
|
|
1227
|
+
drawMentions();
|
|
1228
|
+
}, 120);
|
|
1229
|
+
});
|
|
1230
|
+
$("prompt").addEventListener("keydown", (e) => {
|
|
1231
|
+
if (mentionItems.length) {
|
|
1232
|
+
if (e.key === "ArrowDown") { mentionSel = Math.min(mentionItems.length - 1, mentionSel + 1); drawMentions(); e.preventDefault(); return; }
|
|
1233
|
+
if (e.key === "ArrowUp") { mentionSel = Math.max(0, mentionSel - 1); drawMentions(); e.preventDefault(); return; }
|
|
1234
|
+
if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); insertMention(mentionItems[mentionSel]); return; }
|
|
1235
|
+
if (e.key === "Escape") { mentionItems = []; drawMentions(); return; }
|
|
1236
|
+
}
|
|
1237
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
|
|
1238
|
+
});
|
|
1239
|
+
async function send() {
|
|
1240
|
+
const text = $("prompt").value.trim();
|
|
1241
|
+
if (!text) return;
|
|
1242
|
+
if (!session && !(await newAgent())) return;
|
|
1243
|
+
try {
|
|
1244
|
+
$("prompt").value = "";
|
|
1245
|
+
renderAgent(await api("/api/projects/send", { id: session.id, text }));
|
|
1246
|
+
} catch (err) { $("prompt").value = text; output(explain(err)); }
|
|
1247
|
+
}
|
|
1248
|
+
$("agent-send").addEventListener("click", send);
|
|
1249
|
+
$("agent-stop").addEventListener("click", async () => { try { renderAgent(await api("/api/projects/stop", { id: session.id })); } catch (err) { output(explain(err)); } });
|
|
1250
|
+
$("limit-stop").addEventListener("click", async () => { try { renderAgent(await api("/api/projects/stop", { id: session.id })); } catch (err) { output(explain(err)); } });
|
|
1251
|
+
$("limit-go").addEventListener("click", async () => { try { renderAgent(await api("/api/projects/continue", { id: session.id })); } catch (err) { output(explain(err)); } });
|
|
1252
|
+
$("agent-undo").addEventListener("click", async () => {
|
|
1253
|
+
if (!(await confirmBox(t("pj.undo"), t("ide.undo.confirm"), t("pj.undo"), true))) return;
|
|
1254
|
+
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)); }
|
|
1255
|
+
});
|
|
1256
|
+
// The app works only while a page asks for news: this is also how it knows someone is watching.
|
|
1257
|
+
setInterval(async () => {
|
|
1258
|
+
if (!session || document.hidden) return;
|
|
1259
|
+
try { renderAgent(await api("/api/projects/state", { id: session.id, after: cursor })); } catch (err) { if (/project_not_found/.test(err.message)) { session = null; refreshAgents(); } }
|
|
1260
|
+
}, 800);
|
|
1261
|
+
setInterval(() => { if (!document.hidden && folder) refreshGit(); }, 15000);
|
|
1262
|
+
|
|
1263
|
+
/** Rewrites the selected text with the agent's model, shown as a change to accept. */
|
|
1264
|
+
async function inlineEdit() {
|
|
1265
|
+
if (!active) return;
|
|
1266
|
+
const ed = focused || editor;
|
|
1267
|
+
const sel = ed.getSelection();
|
|
1268
|
+
const model = active.model;
|
|
1269
|
+
const range = sel && !sel.isEmpty() ? sel : new monaco.Range(sel.startLineNumber, 1, sel.startLineNumber, model.getLineMaxColumn(sel.startLineNumber));
|
|
1270
|
+
const instruction = await askText(t("ide.inline"), t("ide.inline.hint"), "");
|
|
1271
|
+
if (!instruction) return;
|
|
1272
|
+
if (!session && !(await newAgent())) return;
|
|
1273
|
+
const selection = model.getValueInRange(range);
|
|
1274
|
+
const ctxStart = Math.max(1, range.startLineNumber - 40), ctxEnd = Math.min(model.getLineCount(), range.endLineNumber + 40);
|
|
1275
|
+
const context = model.getValueInRange(new monaco.Range(ctxStart, 1, ctxEnd, model.getLineMaxColumn(ctxEnd)));
|
|
1276
|
+
output(t("ide.inline.working"));
|
|
1277
|
+
try {
|
|
1278
|
+
const r = await api("/api/projects/edit-selection", { id: session.id, path: active.path, selection, instruction, context });
|
|
1279
|
+
const before = model.getValue();
|
|
1280
|
+
const after = before.slice(0, model.getOffsetAt(range.getStartPosition())) + r.replacement + before.slice(model.getOffsetAt(range.getEndPosition()));
|
|
1281
|
+
const tab = active;
|
|
1282
|
+
showReview({ title: t("ide.inline.review", { path: tab.path }), before, after, path: tab.path, pieces: true, acceptLabel: t("ide.inline.accept"), rejectLabel: t("pj.refuse"),
|
|
1283
|
+
accept: (text) => { tab.model.pushEditOperations([], [{ range: tab.model.getFullModelRange(), text }], () => null); activate(tab); }, reject: () => activate(tab) });
|
|
1284
|
+
} catch (err) { output(explain(err)); }
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// ---- keys ------------------------------------------------------------------------------------------
|
|
1288
|
+
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"]];
|
|
1289
|
+
function drawKeys() {
|
|
1290
|
+
const table = clear($("keys"));
|
|
1291
|
+
const keys = (s) => el("td", {}, ...s.split(/(?=⇧)|(?<=⇧)/).map((k) => el("kbd", { text: k })));
|
|
1292
|
+
for (const [k, label] of KEYS) table.append(el("tr", {}, el("td", { text: t(label) }), keys(`${MOD}${k}`)));
|
|
1293
|
+
table.append(el("tr", {}, el("td", { text: t("ide.key.mode") }), keys("⇧Tab")));
|
|
1294
|
+
}
|
|
1295
|
+
document.addEventListener("keydown", (e) => {
|
|
1296
|
+
const mod = isMac ? e.metaKey : e.ctrlKey;
|
|
1297
|
+
const inEditor = e.target && e.target.closest && e.target.closest(".monaco-editor");
|
|
1298
|
+
if (e.key === "Tab" && e.shiftKey && !inEditor && !mentionItems.length) { e.preventDefault(); cycleMode(); return; }
|
|
1299
|
+
if (e.ctrlKey && e.key === "`") { e.preventDefault(); showTerminal(); return; }
|
|
1300
|
+
if (!mod) return;
|
|
1301
|
+
const k = e.key.toLowerCase();
|
|
1302
|
+
if (k === "p" && e.shiftKey) { e.preventDefault(); palette(); }
|
|
1303
|
+
else if (k === "p") { e.preventDefault(); quickOpen(); }
|
|
1304
|
+
else if (k === "f" && e.shiftKey) { e.preventDefault(); showSide("search"); }
|
|
1305
|
+
else if (k === "e" && e.shiftKey) { e.preventDefault(); showSide("files"); }
|
|
1306
|
+
else if (k === "b" && e.altKey) { e.preventDefault(); toggle("agentOpen"); }
|
|
1307
|
+
else if (k === "b" && !e.shiftKey) { e.preventDefault(); toggle("sideOpen"); }
|
|
1308
|
+
else if (k === "j") { e.preventDefault(); if (layoutState.panelOpen && currentBottom() === "terminal") { layoutState.panelOpen = false; layout(); if (terminal) terminal.close(); } else showTerminal(); }
|
|
1309
|
+
else if (k === "l") { e.preventDefault(); newAgent(); }
|
|
1310
|
+
else if (k === "s" && !inEditor) { e.preventDefault(); saveActive(); }
|
|
1311
|
+
else if (k === "\\") { e.preventDefault(); if (active) toggleSplit(); }
|
|
1312
|
+
}, true);
|
|
1313
|
+
addEventListener("beforeunload", (e) => { if (tabs.some(isDirty)) { e.preventDefault(); e.returnValue = ""; } });
|
|
1314
|
+
|
|
1315
|
+
// ---- start ------------------------------------------------------------------------------------------
|
|
1316
|
+
(async () => {
|
|
1317
|
+
drawIcons(document);
|
|
1318
|
+
await loadWords();
|
|
1319
|
+
$("ide").hidden = false;
|
|
1320
|
+
$("back").addEventListener("click", () => { location.href = `/#token=${encodeURIComponent(token)}&lang=${encodeURIComponent(lang)}`; });
|
|
1321
|
+
for (const b of document.querySelectorAll("#activity button[data-side]")) b.classList.toggle("on", b.dataset.side === layoutState.sideView && layoutState.sideOpen);
|
|
1322
|
+
for (const p of document.querySelectorAll("#side > section")) p.hidden = p.dataset.panel !== layoutState.sideView;
|
|
1323
|
+
layout();
|
|
1324
|
+
drawKeys();
|
|
1325
|
+
renderModeHint();
|
|
1326
|
+
renderProblemCounts();
|
|
1327
|
+
showBottom(store.get("bottom", "problems") === "terminal" ? "problems" : store.get("bottom", "problems"));
|
|
1328
|
+
// Folders and models while the editor loads: the tree shows before Monaco is ready.
|
|
1329
|
+
const lists = Promise.all([loadModels().catch((err) => output(explain(err))), loadFolders().catch((err) => output(explain(err)))]).then(() => ($("folder").value ? setFolder($("folder").value) : null));
|
|
1330
|
+
try { monaco = await loadMonaco(); setupEditor(); } catch (err) { output(t("ide.err.editor_missing")); }
|
|
1331
|
+
editorLoaded();
|
|
1332
|
+
renderTabs();
|
|
1333
|
+
await lists;
|
|
1334
|
+
for (const b of document.querySelectorAll("[data-bottom]")) b.addEventListener("click", () => store.set("bottom", b.dataset.bottom));
|
|
1335
|
+
})();
|
|
1336
|
+
})();
|