bitpull 0.1.27 → 0.1.29
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 +4 -2
- package/bitpull.cjs +297 -44
- package/package.json +1 -1
- package/ui/ide-terminal.js +9 -1
- package/ui/ide.css +64 -0
- package/ui/ide.js +76 -3
- package/ui/terminal.js +89 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bitpull",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "BitPull (bitpull.cloud): share signed, AI-readable code, computing power and open AI models with AI tools through MCP. CLI and MCP server.",
|
|
5
5
|
"mcpName": "io.github.SamuealPK/bitpull",
|
|
6
6
|
"bin": {
|
package/ui/ide-terminal.js
CHANGED
|
@@ -12,7 +12,7 @@ function register() {
|
|
|
12
12
|
let current = null;
|
|
13
13
|
const start = () => {
|
|
14
14
|
if (term || !host || !opts || !opts.folder) return;
|
|
15
|
-
term = mountTerminal(host, { cwd: opts.folder, token: opts.token, t: opts.t });
|
|
15
|
+
term = mountTerminal(host, { cwd: opts.folder, token: opts.token, t: opts.t, fontSize: opts.phone ? 12 : 13, keep: !!opts.phone });
|
|
16
16
|
};
|
|
17
17
|
ide.terminal.register({
|
|
18
18
|
mount(container, o) {
|
|
@@ -30,6 +30,14 @@ function register() {
|
|
|
30
30
|
start();
|
|
31
31
|
if (term) term.newTab();
|
|
32
32
|
},
|
|
33
|
+
input(d) {
|
|
34
|
+
start();
|
|
35
|
+
if (term) term.input(d);
|
|
36
|
+
},
|
|
37
|
+
ctrl(on, done) {
|
|
38
|
+
start();
|
|
39
|
+
if (term) term.ctrl(on, done);
|
|
40
|
+
},
|
|
33
41
|
runVisible(command) {
|
|
34
42
|
start();
|
|
35
43
|
current = term ? term.runVisible(command) : null;
|
package/ui/ide.css
CHANGED
|
@@ -269,3 +269,67 @@ button.sb:hover { background: var(--hover); color: var(--text); }
|
|
|
269
269
|
|
|
270
270
|
/* Monaco draws long lines under the minimap, whose canvas is slightly transparent: a solid ground hides them. */
|
|
271
271
|
.monaco-editor .minimap { background: var(--bg); }
|
|
272
|
+
|
|
273
|
+
/* ---- opened from the person's phone: one part at a time, chosen at the bottom, sized for fingers ---- */
|
|
274
|
+
#ide.phone { grid-template-rows: 48px minmax(0, 1fr) auto; font-size: 15px; height: 100%; }
|
|
275
|
+
#ide.phone #statusbar, #ide.phone #toggle-side, #ide.phone #toggle-panel, #ide.phone #toggle-agent, #ide.phone #palette-label,
|
|
276
|
+
#ide.phone .sash, #ide.phone #split-editor, #ide.phone #crumbs, #ide.phone #panel-max, #ide.phone #bottom-close, #ide.phone #views-more,
|
|
277
|
+
#ide.phone #activity button[data-side="files"], #ide.phone table.keys { display: none !important; }
|
|
278
|
+
#ide.phone #titlebar { gap: 4px; padding: 0 4px; }
|
|
279
|
+
#ide.phone button.tb { height: 42px; min-width: 42px; }
|
|
280
|
+
#ide.phone button.tb svg.i { width: 20px; height: 20px; }
|
|
281
|
+
#ide.phone button.tb.folder { flex: 1 1 auto; max-width: none; font-size: 16px; justify-content: flex-start; }
|
|
282
|
+
#ide.phone #titlebar .grow { display: none; }
|
|
283
|
+
#ide.phone button.tb.search { width: 42px; border: 0; background: transparent; justify-content: center; }
|
|
284
|
+
#ide.phone #activity { flex-direction: row; justify-content: flex-start; padding: 0 4px; border-inline-end: 0; border-bottom: 1px solid var(--line); }
|
|
285
|
+
#ide.phone #activity button { width: 56px; height: 48px; }
|
|
286
|
+
#ide.phone #activity button.on::before { top: auto; bottom: 0; left: 10px; right: 10px; width: auto; height: 2px; }
|
|
287
|
+
#ide.phone #side, #ide.phone #agent { border: 0; }
|
|
288
|
+
#ide.phone .view-title { height: 40px; font-size: 12px; padding-inline-start: 16px; }
|
|
289
|
+
#ide.phone .pane-head { height: 44px; font-size: 12px; padding-inline: 8px; }
|
|
290
|
+
#ide.phone .pane-acts { display: inline-flex; gap: 4px; }
|
|
291
|
+
#ide.phone button.ib { width: 40px; height: 40px; }
|
|
292
|
+
#ide.phone button.ib svg.i { width: 20px; height: 20px; }
|
|
293
|
+
#ide.phone ul.tree .item { height: 44px; font-size: 15px; padding-inline-end: 12px; }
|
|
294
|
+
#ide.phone ul.tree .item svg.f { width: 18px; height: 18px; }
|
|
295
|
+
#ide.phone ul.list li { height: 44px; font-size: 15px; }
|
|
296
|
+
#ide.phone :is(input[type=text], input[type=number], select, textarea) { font-size: 16px !important; } /* below 16px iPhone zooms in */
|
|
297
|
+
#ide.phone .field input[type=text] { padding: 8px 4px !important; }
|
|
298
|
+
#ide.phone button.primary, #ide.phone button.ghost { min-height: 42px; padding: 6px 16px; font-size: 15px; }
|
|
299
|
+
#ide.phone #tabs-row { height: 46px; }
|
|
300
|
+
#ide.phone #tabs .tab { padding: 0 4px 0 12px; font-size: 14px; }
|
|
301
|
+
#ide.phone #tabs .tab .x { visibility: visible; width: 36px; height: 36px; }
|
|
302
|
+
#ide.phone .tabs-acts button.ib { width: 44px; height: 44px; }
|
|
303
|
+
#ide.phone .panel-head { height: 46px; padding: 0 4px; }
|
|
304
|
+
#ide.phone .ptabs { overflow-x: auto; scrollbar-width: none; }
|
|
305
|
+
#ide.phone .ptabs button[data-bottom] { height: 42px; font-size: 12px; padding: 0 10px; flex: none; }
|
|
306
|
+
#ide.phone .bp-term-bar { height: 44px !important; }
|
|
307
|
+
#ide.phone .bp-term-bar button.ib { width: 42px !important; height: 40px !important; }
|
|
308
|
+
#ide.phone .bp-term-bar button { font-size: 14px !important; padding: 6px 10px !important; }
|
|
309
|
+
#ide.phone .bp-term-pane { padding: 2px 0 0 4px !important; }
|
|
310
|
+
#ide.phone .agent-top { padding: 8px 10px; }
|
|
311
|
+
#ide.phone ul.list.agents { max-height: 26vh; }
|
|
312
|
+
#ide.phone #agent-log { padding: 10px 12px; font-size: 15px; }
|
|
313
|
+
#ide.phone #composer { margin: 0 8px 8px; padding: 8px; border-radius: 14px; }
|
|
314
|
+
#ide.phone #composer textarea { min-height: 48px; }
|
|
315
|
+
#ide.phone .composer-bar { gap: 6px; flex-wrap: wrap; }
|
|
316
|
+
#ide.phone .composer-bar select { font-size: 15px !important; min-height: 40px; max-width: 40%; }
|
|
317
|
+
#ide.phone button.send { width: 42px; height: 42px; }
|
|
318
|
+
#ide.phone button.send svg.i { width: 20px; height: 20px; }
|
|
319
|
+
#ide.phone .welcome-box h2 { font-size: 18px; }
|
|
320
|
+
#ide.phone ~ .menu li, #ide.phone ~ #menu li { height: 46px; font-size: 15px; }
|
|
321
|
+
#ide.phone ~ #modal { padding-top: 4vh; }
|
|
322
|
+
#ide.phone ~ #modal .box { width: calc(100% - 16px); max-height: 88vh; font-size: 15px; }
|
|
323
|
+
#ide.phone ~ #modal button { min-height: 42px; }
|
|
324
|
+
#ide.phone ~ #tip { display: none !important; } /* no hover on a phone: the tips stay as the buttons' names */
|
|
325
|
+
#phone-nav { display: flex; background: var(--bar); border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom); }
|
|
326
|
+
#phone-nav button { flex: 1 1 0; min-width: 0; height: 58px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; background: transparent; border: 0; color: var(--muted); font-size: 11px; }
|
|
327
|
+
#phone-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
328
|
+
#phone-nav button svg.i { width: 24px; height: 24px; stroke-width: 1.3; }
|
|
329
|
+
#phone-nav button.on { color: var(--bright); }
|
|
330
|
+
#phone-nav button.on svg.i { color: var(--accent); }
|
|
331
|
+
#ide.phone.typing #phone-nav { display: none; }
|
|
332
|
+
#phone-keys { display: flex; gap: 6px; overflow-x: auto; padding: 6px 8px; border-top: 1px solid var(--line); background: var(--side); flex: none; scrollbar-width: none; }
|
|
333
|
+
#phone-keys button { flex: none; min-width: 46px; height: 40px; padding: 0 10px; border: 1px solid var(--line2); border-radius: 8px; background: var(--raised); color: var(--bright); font: 14px ui-monospace, Menlo, Consolas, monospace; }
|
|
334
|
+
#phone-keys button.on { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
|
335
|
+
#ide.phone.typing .agent-top { display: none; }
|
package/ui/ide.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
(function () {
|
|
6
6
|
const BP = window.__BITPULL__ || {};
|
|
7
7
|
const token = BP.token || "";
|
|
8
|
+
// Opened from the person's phone (bitpull.cloud/app): one screen at a time, sized for fingers.
|
|
9
|
+
// The computer's own window keeps the editor layout untouched.
|
|
10
|
+
const PHONE = token === "phone";
|
|
8
11
|
const $ = (id) => document.getElementById(id);
|
|
9
12
|
// Where the page's files are: /ui/~<build>/, which the webview keeps without asking again.
|
|
10
13
|
const BASE = ((document.currentScript && document.currentScript.src) || "/ui/ide.js").replace(/ide\.js(\?.*)?$/, "");
|
|
@@ -213,7 +216,7 @@
|
|
|
213
216
|
register(impl) {
|
|
214
217
|
terminal = impl;
|
|
215
218
|
clear($("terminal-host"));
|
|
216
|
-
impl.mount($("terminal-host"), { token, folder, lang, t });
|
|
219
|
+
impl.mount($("terminal-host"), { token, folder, lang, t, phone: PHONE });
|
|
217
220
|
// Its buttons become icons, like the rest of the panel; the words stay as their name.
|
|
218
221
|
const TERM_ICONS = { "term.new": "plus", "term.split": "split", "term.close": "trash" };
|
|
219
222
|
const iconize = () => {
|
|
@@ -233,6 +236,7 @@
|
|
|
233
236
|
get folder() { return folder; },
|
|
234
237
|
};
|
|
235
238
|
function showTerminal() {
|
|
239
|
+
if (PHONE) phoneTab = "terminal";
|
|
236
240
|
showBottom("terminal");
|
|
237
241
|
if (terminal) terminal.open();
|
|
238
242
|
else clear($("terminal-host")).append(el("p", { class: "muted pad", text: t("ide.terminal.missing") }));
|
|
@@ -245,6 +249,7 @@
|
|
|
245
249
|
);
|
|
246
250
|
const narrow = () => innerWidth < 900;
|
|
247
251
|
function layout() {
|
|
252
|
+
if (PHONE) return phoneLayout();
|
|
248
253
|
const L = layoutState;
|
|
249
254
|
const ws = $("workbench");
|
|
250
255
|
$("ide").classList.toggle("narrow", narrow());
|
|
@@ -305,6 +310,67 @@
|
|
|
305
310
|
store.set("layout", L);
|
|
306
311
|
relayout();
|
|
307
312
|
}
|
|
313
|
+
// ---- on a phone: one part at a time, chosen with the bar at the bottom ---------------------------------
|
|
314
|
+
let phoneTab = "files";
|
|
315
|
+
let phoneMore = "git";
|
|
316
|
+
const PHONE_PARTS = { files: "side", more: "side", editor: "editor-area", terminal: "panel", agent: "agent" };
|
|
317
|
+
function phoneLayout() {
|
|
318
|
+
const ws = $("workbench");
|
|
319
|
+
$("ide").classList.add("phone");
|
|
320
|
+
const more = phoneTab === "more";
|
|
321
|
+
ws.style.gridTemplateColumns = "minmax(0, 1fr)";
|
|
322
|
+
ws.style.gridTemplateAreas = more ? '"act" "main"' : '"main"';
|
|
323
|
+
ws.style.gridTemplateRows = more ? "auto minmax(0, 1fr)" : "minmax(0, 1fr)";
|
|
324
|
+
$("activity").hidden = !more;
|
|
325
|
+
for (const id of ["side", "editor-area", "panel", "agent"]) {
|
|
326
|
+
$(id).style.gridArea = "main";
|
|
327
|
+
$(id).hidden = PHONE_PARTS[phoneTab] !== id;
|
|
328
|
+
}
|
|
329
|
+
for (const sash of ws.querySelectorAll(".sash")) sash.hidden = true;
|
|
330
|
+
if ($("phone-keys")) $("phone-keys").hidden = !(phoneTab === "terminal" && currentBottom() === "terminal");
|
|
331
|
+
for (const b of document.querySelectorAll("#phone-nav button")) b.classList.toggle("on", b.dataset.tab === phoneTab);
|
|
332
|
+
for (const p of document.querySelectorAll(".pane")) p.classList.toggle("open", !!layoutState.panes[p.dataset.pane]);
|
|
333
|
+
relayout();
|
|
334
|
+
}
|
|
335
|
+
function phoneShow(tab) {
|
|
336
|
+
phoneTab = tab;
|
|
337
|
+
if (tab === "files") showSide("files");
|
|
338
|
+
else if (tab === "more") showSide(phoneMore);
|
|
339
|
+
else if (tab === "terminal") showTerminal();
|
|
340
|
+
else phoneLayout();
|
|
341
|
+
}
|
|
342
|
+
function phoneSetup() {
|
|
343
|
+
const nav = el("nav", { id: "phone-nav" });
|
|
344
|
+
for (const [tab, ic, label] of [["files", "files", "phone.files"], ["editor", "output", "phone.editor"], ["terminal", "terminal", "pj.tab.terminal"], ["agent", "logo", "phone.agent"], ["more", "more", "phone.more"]]) {
|
|
345
|
+
nav.append(el("button", { type: "button", "data-tab": tab, "data-tip": `tip.phone.${tab}`, "aria-label": t(label), onclick: () => phoneShow(tab) }, icon(ic), el("span", { text: t(label) })));
|
|
346
|
+
}
|
|
347
|
+
$("ide").append(nav);
|
|
348
|
+
// Keys a phone's keyboard lacks, for the terminal. Pressing them keeps the keyboard open.
|
|
349
|
+
const keys = el("div", { id: "phone-keys", hidden: true });
|
|
350
|
+
const KEYS = [["Esc", "\x1b"], ["Tab", "\t"], ["Ctrl", null], ["↑", "\x1b[A"], ["↓", "\x1b[B"], ["←", "\x1b[D"], ["→", "\x1b[C"], ["Ctrl+C", "\x03"], ["|", "|"], ["/", "/"], ["~", "~"], ["-", "-"]];
|
|
351
|
+
for (const [label, seq] of KEYS) {
|
|
352
|
+
const b = el("button", { type: "button", text: label, "data-tip": seq === null ? "tip.phone.ctrl" : "tip.phone.key", onpointerdown: (e) => e.preventDefault() });
|
|
353
|
+
b.addEventListener("click", () => {
|
|
354
|
+
if (!terminal) return;
|
|
355
|
+
if (seq === null) {
|
|
356
|
+
const on = !b.classList.contains("on");
|
|
357
|
+
b.classList.toggle("on", on);
|
|
358
|
+
if (terminal.ctrl) terminal.ctrl(on, () => b.classList.remove("on"));
|
|
359
|
+
} else if (terminal.input) terminal.input(seq);
|
|
360
|
+
});
|
|
361
|
+
keys.append(b);
|
|
362
|
+
}
|
|
363
|
+
$("panel").append(keys);
|
|
364
|
+
// With the keyboard open the page gets much shorter (the phone page fits it to what stays visible):
|
|
365
|
+
// then the bar steps aside to leave room, and the terminal's keys stay.
|
|
366
|
+
let tallest = innerHeight;
|
|
367
|
+
const keyboard = () => {
|
|
368
|
+
tallest = Math.max(tallest, innerHeight);
|
|
369
|
+
$("ide").classList.toggle("typing", innerHeight < tallest * 0.75);
|
|
370
|
+
};
|
|
371
|
+
addEventListener("resize", keyboard);
|
|
372
|
+
addEventListener("orientationchange", () => { tallest = 0; setTimeout(keyboard, 300); });
|
|
373
|
+
}
|
|
308
374
|
/** Monaco and the terminal measure themselves after the grid has settled. */
|
|
309
375
|
function relayout() {
|
|
310
376
|
requestAnimationFrame(() => {
|
|
@@ -347,6 +413,7 @@
|
|
|
347
413
|
$("panel-max").addEventListener("click", () => toggle("panelMax"));
|
|
348
414
|
|
|
349
415
|
function showSide(name) {
|
|
416
|
+
if (PHONE) { if (name !== "files") phoneMore = name; phoneTab = name === "files" ? "files" : "more"; }
|
|
350
417
|
layoutState.sideOpen = true;
|
|
351
418
|
layoutState.sideView = name;
|
|
352
419
|
for (const b of document.querySelectorAll("#activity button[data-side]")) b.classList.toggle("on", b.dataset.side === name);
|
|
@@ -449,7 +516,9 @@
|
|
|
449
516
|
monaco.editor.defineTheme("bitpull-light", { base: "vs", inherit: true, rules: [], colors: { "editor.background": "#ffffff", "editorLineNumber.foreground": "#6e7681" } });
|
|
450
517
|
}
|
|
451
518
|
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 }
|
|
519
|
+
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 },
|
|
520
|
+
// A phone's screen: no minimap, long lines wrap, a size readable without zooming.
|
|
521
|
+
...(PHONE ? { minimap: { enabled: false }, wordWrap: "on", fontSize: 14, lineHeight: 21, stickyScroll: { enabled: false }, folding: false, glyphMargin: false, lineNumbersMinChars: 3, lineDecorationsWidth: 6, scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 } } : {}) });
|
|
453
522
|
function wireEditor(ed) {
|
|
454
523
|
ed.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveActive());
|
|
455
524
|
ed.addAction({ id: "bitpull.inline", label: t("ide.inline"), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], contextMenuGroupId: "1_modification", run: () => inlineEdit() });
|
|
@@ -590,6 +659,8 @@
|
|
|
590
659
|
if (r.secret) output(t("ide.secret_open", { path }));
|
|
591
660
|
}
|
|
592
661
|
activate(tab);
|
|
662
|
+
// On a phone a file chosen in the list, a search result or a problem opens on the editor's screen.
|
|
663
|
+
if (PHONE && phoneTab !== "editor" && phoneTab !== "agent") { phoneTab = "editor"; phoneLayout(); }
|
|
593
664
|
const ed = focused || editor;
|
|
594
665
|
if (line) { ed.revealLineInCenter(line); ed.setPosition({ lineNumber: line, column: 1 }); ed.focus(); }
|
|
595
666
|
}
|
|
@@ -1330,8 +1401,9 @@
|
|
|
1330
1401
|
(async () => {
|
|
1331
1402
|
drawIcons(document);
|
|
1332
1403
|
await loadWords();
|
|
1404
|
+
if (PHONE) phoneSetup();
|
|
1333
1405
|
$("ide").hidden = false;
|
|
1334
|
-
$("back").addEventListener("click", () => { location.href =
|
|
1406
|
+
$("back").addEventListener("click", () => { location.href = `${PHONE ? "/remote/" : "/"}#token=${encodeURIComponent(token)}&lang=${encodeURIComponent(lang)}`; });
|
|
1335
1407
|
for (const b of document.querySelectorAll("#activity button[data-side]")) b.classList.toggle("on", b.dataset.side === layoutState.sideView && layoutState.sideOpen);
|
|
1336
1408
|
for (const p of document.querySelectorAll("#side > section")) p.hidden = p.dataset.panel !== layoutState.sideView;
|
|
1337
1409
|
layout();
|
|
@@ -1339,6 +1411,7 @@
|
|
|
1339
1411
|
renderModeHint();
|
|
1340
1412
|
renderProblemCounts();
|
|
1341
1413
|
showBottom(store.get("bottom", "problems") === "terminal" ? "problems" : store.get("bottom", "problems"));
|
|
1414
|
+
if (PHONE) phoneShow("files");
|
|
1342
1415
|
// Folders and models while the editor loads: the tree shows before Monaco is ready.
|
|
1343
1416
|
const lists = Promise.all([loadModels().catch((err) => output(explain(err))), loadFolders().catch((err) => output(explain(err)))]).then(() => ($("folder").value ? setFolder($("folder").value) : null));
|
|
1344
1417
|
try { monaco = await loadMonaco(); setupEditor(); } catch (err) { output(t("ide.err.editor_missing")); }
|
package/ui/terminal.js
CHANGED
|
@@ -88,11 +88,25 @@ export function mountTerminalSetting(el, { token, t = (k) => k, onChange } = {})
|
|
|
88
88
|
terminalStatus(token).then(render, () => render({ enabled: false, available: false }));
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** This phone's name for its shells on the computer: the same every time the page opens. */
|
|
92
|
+
function keptName() {
|
|
93
|
+
try {
|
|
94
|
+
let n = localStorage.getItem("bitpull_term_keep");
|
|
95
|
+
if (!n || !/^[A-Za-z0-9_-]{16,64}$/.test(n)) {
|
|
96
|
+
n = Array.from(crypto.getRandomValues(new Uint8Array(18)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
97
|
+
localStorage.setItem("bitpull_term_keep", n);
|
|
98
|
+
}
|
|
99
|
+
return n;
|
|
100
|
+
} catch {
|
|
101
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(18)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
/**
|
|
92
106
|
* Mounts the terminal in `el`. `cwd` must be a folder the person authorised; `t` translates.
|
|
93
107
|
* Returns the controls the page and the Projects view use.
|
|
94
108
|
*/
|
|
95
|
-
export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
109
|
+
export function mountTerminal(el, { cwd, token, t = (k) => k, fontSize = 13, keep = false } = {}) {
|
|
96
110
|
const ended = (code) => String(t("term.exited", { code })).replace("{code}", String(code));
|
|
97
111
|
addCss();
|
|
98
112
|
el.replaceChildren();
|
|
@@ -117,6 +131,8 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
117
131
|
el.appendChild(root);
|
|
118
132
|
|
|
119
133
|
const tabs = []; // { title, panes: [{ term, fit, id?, box, readonly }] }
|
|
134
|
+
let ctrlNext = false;
|
|
135
|
+
let ctrlDone = null;
|
|
120
136
|
let active = null;
|
|
121
137
|
let ws = null;
|
|
122
138
|
let ready = false;
|
|
@@ -124,6 +140,9 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
124
140
|
const waiting = new Map();
|
|
125
141
|
let nextRef = 1;
|
|
126
142
|
const byId = new Map();
|
|
143
|
+
// On a phone the shells wait on the computer for an hour when the phone goes away, under this name.
|
|
144
|
+
const keepName = keep ? keptName() : null;
|
|
145
|
+
let firstReady = true;
|
|
127
146
|
|
|
128
147
|
const message = (key, withEnable) => {
|
|
129
148
|
panesEl.replaceChildren();
|
|
@@ -179,7 +198,7 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
179
198
|
const makePane = (readonly) => {
|
|
180
199
|
const box = document.createElement("div");
|
|
181
200
|
box.className = "bp-term-pane";
|
|
182
|
-
const term = new Terminal({ cursorBlink: !readonly, disableStdin: readonly, fontSize
|
|
201
|
+
const term = new Terminal({ cursorBlink: !readonly, disableStdin: readonly, fontSize, fontFamily: "ui-monospace, Menlo, Consolas, monospace", theme: theme(), scrollback: 5000, convertEol: readonly });
|
|
183
202
|
const fit = new FitAddon();
|
|
184
203
|
term.loadAddon(fit);
|
|
185
204
|
term.open(box);
|
|
@@ -199,12 +218,24 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
199
218
|
return { term, fit, box, readonly };
|
|
200
219
|
};
|
|
201
220
|
|
|
202
|
-
const startShell = (pane) => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
221
|
+
const startShell = (pane, existing) => {
|
|
222
|
+
if (existing) {
|
|
223
|
+
pane.id = existing;
|
|
224
|
+
byId.set(existing, pane);
|
|
225
|
+
} else {
|
|
226
|
+
pane.ref = nextRef++;
|
|
227
|
+
pane.go = () => ws.send(JSON.stringify({ t: "open", ref: pane.ref, cwd, cols: pane.term.cols, rows: pane.term.rows }));
|
|
228
|
+
waiting.set(pane.ref, pane);
|
|
229
|
+
if (ready) pane.go();
|
|
230
|
+
}
|
|
207
231
|
pane.term.onData((d) => {
|
|
232
|
+
// "Ctrl" pressed on a phone's extra keys: the next letter becomes Ctrl+letter.
|
|
233
|
+
if (ctrlNext && d.length === 1) {
|
|
234
|
+
const c = d.toUpperCase().charCodeAt(0);
|
|
235
|
+
if (c >= 64 && c <= 95) d = String.fromCharCode(c - 64);
|
|
236
|
+
ctrlNext = null;
|
|
237
|
+
if (ctrlDone) ctrlDone();
|
|
238
|
+
}
|
|
208
239
|
if (!pane.id || !ready) return;
|
|
209
240
|
// Huge pastes go in pieces: a message has a size limit.
|
|
210
241
|
for (let i = 0; i < d.length; i += MAX_INPUT) ws.send(JSON.stringify({ t: "in", id: pane.id, d: d.slice(i, i + MAX_INPUT) }));
|
|
@@ -215,7 +246,7 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
215
246
|
if (ws && ws.readyState <= 1) return;
|
|
216
247
|
ready = false;
|
|
217
248
|
ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/terminal`);
|
|
218
|
-
ws.onopen = () => ws.send(JSON.stringify({ t: "auth", token }));
|
|
249
|
+
ws.onopen = () => ws.send(JSON.stringify({ t: "auth", token, ...(keepName ? { keep: keepName } : {}) }));
|
|
219
250
|
ws.onmessage = (e) => {
|
|
220
251
|
let m;
|
|
221
252
|
try {
|
|
@@ -225,7 +256,14 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
225
256
|
}
|
|
226
257
|
if (m.t === "ready") {
|
|
227
258
|
ready = true;
|
|
259
|
+
if (keepName) pickUp(m.kept || []);
|
|
228
260
|
for (const p of waiting.values()) p.go();
|
|
261
|
+
if (keepName && firstReady && !tabs.length) newTab();
|
|
262
|
+
firstReady = false;
|
|
263
|
+
} else if (m.t === "attached") {
|
|
264
|
+
// The computer sends the screen again as it is now: it replaces what this page still showed.
|
|
265
|
+
byId.get(m.id)?.term.reset();
|
|
266
|
+
fitAll();
|
|
229
267
|
} else if (m.t === "opened") {
|
|
230
268
|
const p = waiting.get(m.ref);
|
|
231
269
|
if (!p) return;
|
|
@@ -241,6 +279,14 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
241
279
|
byId.delete(m.id);
|
|
242
280
|
p.id = undefined;
|
|
243
281
|
}
|
|
282
|
+
} else if (m.t === "error" && m.error === "no_terminal" && typeof m.id === "number") {
|
|
283
|
+
// Gone while the phone was away (more than an hour, or the computer restarted).
|
|
284
|
+
const p = byId.get(m.id);
|
|
285
|
+
if (p) {
|
|
286
|
+
byId.delete(m.id);
|
|
287
|
+
p.id = undefined;
|
|
288
|
+
p.term.write(`\r\n\x1b[2m${t("term.gone")}\x1b[0m\r\n`);
|
|
289
|
+
}
|
|
244
290
|
} else if (m.t === "error") {
|
|
245
291
|
const p = waiting.get(m.ref);
|
|
246
292
|
waiting.delete(m.ref);
|
|
@@ -251,6 +297,8 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
251
297
|
ws.onclose = (e) => {
|
|
252
298
|
ready = false;
|
|
253
299
|
if (disposed) return;
|
|
300
|
+
// A phone's connection comes back by itself: its shells are still there.
|
|
301
|
+
if (keepName && e.code !== 4409 && e.code !== 4410 && e.code !== 4401) setTimeout(() => !disposed && connect(), 2000);
|
|
254
302
|
if (e.code === 4409 || e.code === 1006) {
|
|
255
303
|
terminalStatus(token).then((s) => {
|
|
256
304
|
if (!s.available) message("term.unavailable", false);
|
|
@@ -260,6 +308,20 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
260
308
|
};
|
|
261
309
|
};
|
|
262
310
|
|
|
311
|
+
/** Shows again the shells this phone left running: those of this page first, then the others in this folder. */
|
|
312
|
+
function pickUp(kept) {
|
|
313
|
+
for (const p of byId.values()) ws.send(JSON.stringify({ t: "attach", id: p.id, cols: p.term.cols, rows: p.term.rows }));
|
|
314
|
+
for (const k of kept) {
|
|
315
|
+
if (byId.has(k.id) || k.cwd !== cwd) continue;
|
|
316
|
+
const pane = makePane(false);
|
|
317
|
+
const tab = { title: "", panes: [pane] };
|
|
318
|
+
tabs.push(tab);
|
|
319
|
+
show(tab);
|
|
320
|
+
startShell(pane, k.id);
|
|
321
|
+
ws.send(JSON.stringify({ t: "attach", id: k.id, cols: pane.term.cols, rows: pane.term.rows }));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
263
325
|
function newTab() {
|
|
264
326
|
const pane = makePane(false);
|
|
265
327
|
const tab = { title: "", panes: [pane] };
|
|
@@ -328,7 +390,7 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
328
390
|
bar.append(tabsEl, button("term.new", "tip.term.new", () => newTab()), button("term.split", "tip.term.split", () => split()), button("term.close", "tip.term.close", () => close()));
|
|
329
391
|
const resize = new ResizeObserver(() => fitAll());
|
|
330
392
|
resize.observe(panesEl);
|
|
331
|
-
// Closing the page ends its shells: the server closes them with the connection too.
|
|
393
|
+
// Closing the page ends its shells: the server closes them with the connection too. A phone's wait an hour.
|
|
332
394
|
window.addEventListener("pagehide", dispose);
|
|
333
395
|
|
|
334
396
|
terminalStatus(token).then(
|
|
@@ -337,10 +399,26 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
|
|
|
337
399
|
if (!s.available) return message("term.unavailable", false);
|
|
338
400
|
if (!s.enabled) return message("term.off", true);
|
|
339
401
|
connect();
|
|
340
|
-
|
|
402
|
+
// On a phone, the first answer says whether shells are waiting; a new one opens only if none is.
|
|
403
|
+
if (!keepName) newTab();
|
|
341
404
|
},
|
|
342
405
|
() => message("term.failed", false),
|
|
343
406
|
);
|
|
344
407
|
|
|
345
|
-
|
|
408
|
+
/** Keys from outside the terminal (a phone's extra keys): typed into the shell shown now. */
|
|
409
|
+
function input(d) {
|
|
410
|
+
const p = active && !active.readonly ? active.panes[active.panes.length - 1] : null;
|
|
411
|
+
if (!p) return;
|
|
412
|
+
if (p.id && ready) ws.send(JSON.stringify({ t: "in", id: p.id, d: String(d).slice(0, MAX_INPUT) }));
|
|
413
|
+
p.term.focus();
|
|
414
|
+
}
|
|
415
|
+
/** Makes the next letter typed a Ctrl+letter; done() runs once it is used. */
|
|
416
|
+
function ctrl(on, done) {
|
|
417
|
+
ctrlNext = !!on;
|
|
418
|
+
ctrlDone = on ? done : null;
|
|
419
|
+
const p = active && active.panes[active.panes.length - 1];
|
|
420
|
+
if (p) p.term.focus();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return { newTab, split, close, runVisible, dispose, fit: fitAll, input, ctrl };
|
|
346
424
|
}
|