bom-pi 0.0.0-stage → 0.0.2

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.
Files changed (131) hide show
  1. package/README.md +33 -2
  2. package/apps/README.md +7 -0
  3. package/apps/web/README.md +21 -0
  4. package/apps/web/pocket-adapter.mjs +323 -0
  5. package/apps/web/public/LICENSE.pocket +21 -0
  6. package/apps/web/public/activity-fold.js +28 -0
  7. package/apps/web/public/agent-now.js +37 -0
  8. package/apps/web/public/agent-tile.js +79 -0
  9. package/apps/web/public/app.js +975 -0
  10. package/apps/web/public/apple-touch-icon.png +0 -0
  11. package/apps/web/public/artifact-bridge.js +687 -0
  12. package/apps/web/public/avatar.js +87 -0
  13. package/apps/web/public/back.js +554 -0
  14. package/apps/web/public/box-keys.js +216 -0
  15. package/apps/web/public/browser-panel.js +1097 -0
  16. package/apps/web/public/browser.js +68 -0
  17. package/apps/web/public/calls.js +139 -0
  18. package/apps/web/public/chat.js +919 -0
  19. package/apps/web/public/clock.js +34 -0
  20. package/apps/web/public/commands.js +578 -0
  21. package/apps/web/public/composer.js +2068 -0
  22. package/apps/web/public/diff-parse.js +672 -0
  23. package/apps/web/public/diff.js +1646 -0
  24. package/apps/web/public/dock.js +299 -0
  25. package/apps/web/public/extension-ui.js +2734 -0
  26. package/apps/web/public/files-panel.js +775 -0
  27. package/apps/web/public/files.js +368 -0
  28. package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
  29. package/apps/web/public/fonts/Geist.woff2 +0 -0
  30. package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
  31. package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
  32. package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
  33. package/apps/web/public/fonts/InterVariable.woff2 +0 -0
  34. package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
  35. package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
  36. package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
  37. package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
  38. package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
  39. package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
  40. package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
  41. package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
  42. package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
  43. package/apps/web/public/fonts/SOURCES.md +69 -0
  44. package/apps/web/public/gestures.js +610 -0
  45. package/apps/web/public/highlight.js +480 -0
  46. package/apps/web/public/history.js +70 -0
  47. package/apps/web/public/home.js +169 -0
  48. package/apps/web/public/icon-192.png +0 -0
  49. package/apps/web/public/icon-512.png +0 -0
  50. package/apps/web/public/icon-maskable-512.png +0 -0
  51. package/apps/web/public/icon.svg +16 -0
  52. package/apps/web/public/index.html +71 -0
  53. package/apps/web/public/launcher.js +1293 -0
  54. package/apps/web/public/linked-sessions.js +55 -0
  55. package/apps/web/public/live.js +39 -0
  56. package/apps/web/public/manifest.webmanifest +32 -0
  57. package/apps/web/public/model-refresh.js +73 -0
  58. package/apps/web/public/notify.js +251 -0
  59. package/apps/web/public/peeks.js +920 -0
  60. package/apps/web/public/pip-attachments.js +182 -0
  61. package/apps/web/public/pip-window.js +141 -0
  62. package/apps/web/public/pip.js +1217 -0
  63. package/apps/web/public/quote-reply.js +109 -0
  64. package/apps/web/public/quote-text.js +31 -0
  65. package/apps/web/public/rich.js +403 -0
  66. package/apps/web/public/run-frame.js +106 -0
  67. package/apps/web/public/safe-mode.js +71 -0
  68. package/apps/web/public/seen.js +169 -0
  69. package/apps/web/public/session-tree.js +117 -0
  70. package/apps/web/public/sessions.js +2695 -0
  71. package/apps/web/public/share.js +126 -0
  72. package/apps/web/public/sheets/appearance.js +820 -0
  73. package/apps/web/public/sheets/branch.js +353 -0
  74. package/apps/web/public/sheets/extensions.js +818 -0
  75. package/apps/web/public/sheets/file.js +318 -0
  76. package/apps/web/public/sheets/find.js +250 -0
  77. package/apps/web/public/sheets/folder.js +680 -0
  78. package/apps/web/public/sheets/gallery.js +2340 -0
  79. package/apps/web/public/sheets/hooks.js +350 -0
  80. package/apps/web/public/sheets/memory.js +531 -0
  81. package/apps/web/public/sheets/menu.js +580 -0
  82. package/apps/web/public/sheets/message.js +220 -0
  83. package/apps/web/public/sheets/model.js +412 -0
  84. package/apps/web/public/sheets/people.js +414 -0
  85. package/apps/web/public/sheets/pi-sessions.js +264 -0
  86. package/apps/web/public/sheets/providers.js +525 -0
  87. package/apps/web/public/sheets/running.js +130 -0
  88. package/apps/web/public/sheets/schedules.js +71 -0
  89. package/apps/web/public/sheets/session.js +249 -0
  90. package/apps/web/public/sheets/shortcuts.js +101 -0
  91. package/apps/web/public/sheets/spend.js +150 -0
  92. package/apps/web/public/sheets/text.js +53 -0
  93. package/apps/web/public/sheets/viewers.js +363 -0
  94. package/apps/web/public/sheets/worktree.js +50 -0
  95. package/apps/web/public/sheets.js +410 -0
  96. package/apps/web/public/signin.js +57 -0
  97. package/apps/web/public/slots.js +208 -0
  98. package/apps/web/public/store.js +1760 -0
  99. package/apps/web/public/style.css +18625 -0
  100. package/apps/web/public/subagents-board.js +879 -0
  101. package/apps/web/public/subagents.js +273 -0
  102. package/apps/web/public/sw.js +169 -0
  103. package/apps/web/public/taps.js +26 -0
  104. package/apps/web/public/theme-surface.d.ts +4 -0
  105. package/apps/web/public/theme-surface.js +266 -0
  106. package/apps/web/public/theme.js +845 -0
  107. package/apps/web/public/themes/lantern/colors.toml +25 -0
  108. package/apps/web/public/themes/lantern/direction.css +60 -0
  109. package/apps/web/public/themes/lantern/frame.css +254 -0
  110. package/apps/web/public/themes/lantern/theme.css +10 -0
  111. package/apps/web/public/themes/lantern/theme.json +7 -0
  112. package/apps/web/public/themes/pocket/theme.css +189 -0
  113. package/apps/web/public/themes/pocket/theme.json +6 -0
  114. package/apps/web/public/themes.js +556 -0
  115. package/apps/web/public/transcript.js +3348 -0
  116. package/apps/web/public/trust.js +295 -0
  117. package/apps/web/public/turns.js +507 -0
  118. package/apps/web/public/ui.js +1910 -0
  119. package/apps/web/public/voice.js +345 -0
  120. package/apps/web/server.mjs +774 -0
  121. package/package.json +43 -4
  122. package/packages/cli/src/bom-pi.mjs +452 -0
  123. package/packages/cli/templates/blank.factory.json +7 -0
  124. package/packages/cli/templates/dev.factory.json +11 -0
  125. package/packages/cli/templates/docs.factory.json +12 -0
  126. package/packages/cli/templates/ops.factory.json +14 -0
  127. package/packages/cli/templates/triage.factory.json +10 -0
  128. package/packages/core/src/daemon.mjs +2047 -0
  129. package/packages/core/src/limit-gate.mjs +192 -0
  130. package/packages/core/src/rpc-client.mjs +66 -0
  131. package/systemd/README.md +11 -0
@@ -0,0 +1,680 @@
1
+ // The folder picker, as a desktop's file dialog: places on the left (home, the computer, recent folders), the folders
2
+ // where you are on the right, and Cancel and Open at the bottom. With a mouse, a click selects a folder and a double
3
+ // click goes into it; on a touch screen, a tap goes into it. Open takes the selected folder, or the one you are in.
4
+ // It opens in the folder of the session you are in, so New session here is a tap away without walking the tree;
5
+ // favorites, each person's own and on all their devices, sit first among the places; Ctrl/⌘+1…9 starts in one.
6
+ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
7
+ import {
8
+ actions,
9
+ api,
10
+ attempt,
11
+ canSteer,
12
+ closeSheet,
13
+ navigate,
14
+ notify,
15
+ openSheet,
16
+ scoped,
17
+ store,
18
+ } from "../store.js";
19
+ import { sessionStarted } from "../extension-ui.js";
20
+ import { APPLE, html, Icon, Sheet, shortPath } from "../ui.js";
21
+ import { piSessionsAvailable } from "./pi-sessions.js";
22
+
23
+ /** Whether hidden folders show: kept for the next time the picker opens. */
24
+ const HIDDEN_KEY = "pocket.folderHidden";
25
+ /** Touch screens go into a folder on a tap; a mouse selects on a click and goes in on a double click. */
26
+ const touch = matchMedia("(pointer: coarse)").matches;
27
+
28
+ /** A folder's path with a name added: `/a` and `b` make `/a/b`, `/` and `b` make `/b`. */
29
+ const joined = (folder, name) => `${folder.replace(/\/+$/, "")}/${name.replace(/^\/+/, "")}`;
30
+
31
+ /** Whether a failure says a folder is not there yet, and so could be made: only a whole path, as the server makes no other. */
32
+ const notThere = (error, target) =>
33
+ error.status === 404 && /isn't there/.test(error.message) && /^\s*[~/]/.test(target);
34
+
35
+ /** A folder's name: the last part of its path, or ~ for home and / for the root. */
36
+ function nameOf(path, home) {
37
+ if (path === home) {
38
+ return "~";
39
+ }
40
+
41
+ return path.replace(/\/+$/, "").split("/").pop() || "/";
42
+ }
43
+
44
+ /** The path as steps to click back up to: ~ or /, then each folder under it. */
45
+ function crumbsOf(path, home) {
46
+ const underHome = Boolean(home) && (path === home || path.startsWith(`${home}/`));
47
+ const start = underHome ? home : "";
48
+ const crumbs = [{ name: underHome ? "~" : "/", path: underHome ? home : "/" }];
49
+ let at = start;
50
+
51
+ for (const part of path.slice(start.length).split("/").filter(Boolean)) {
52
+ at = `${at}/${part}`;
53
+ crumbs.push({ name: part, path: at });
54
+ }
55
+
56
+ return crumbs;
57
+ }
58
+
59
+ /** Recent folders' labels: each one's name, with its parent's where two have the same name. */
60
+ export function recentLabels(paths, home) {
61
+ const names = paths.map((path) => nameOf(path, home));
62
+
63
+ return paths.map((path, index) =>
64
+ names.indexOf(names[index]) === names.lastIndexOf(names[index])
65
+ ? names[index]
66
+ : shortPath(path, home).split("/").slice(-2).join("/"),
67
+ );
68
+ }
69
+
70
+ /** Marks the path's field when its start is scrolled out of sight, so it fades there. */
71
+ const markCut = (crumbs) => crumbs.classList.toggle("cut", crumbs.scrollLeft > 0);
72
+
73
+ /** A line to type a name or a path into: Enter takes it, Esc lets it go. */
74
+ function LineInput({ value, placeholder, label, onSubmit, onCancel, class: className = "" }) {
75
+ const [text, setText] = useState(value ?? "");
76
+ const input = useRef(null);
77
+
78
+ useEffect(() => {
79
+ input.current?.focus();
80
+ input.current?.setSelectionRange(text.length, text.length);
81
+ }, []);
82
+
83
+ return html`<form
84
+ class=${`fp-line ${className}`}
85
+ onSubmit=${(event) => {
86
+ event.preventDefault();
87
+
88
+ if (text.trim() !== "") {
89
+ onSubmit(text.trim());
90
+ }
91
+ }}
92
+ >
93
+ <input
94
+ ref=${input}
95
+ class="mono"
96
+ value=${text}
97
+ placeholder=${placeholder}
98
+ aria-label=${label}
99
+ autocapitalize="off"
100
+ autocomplete="off"
101
+ spellcheck="false"
102
+ enterkeyhint="go"
103
+ onInput=${(event) => setText(event.currentTarget.value)}
104
+ onKeyDown=${(event) => {
105
+ // Esc lets go of the line, not the whole picker.
106
+ if (event.key === "Escape") {
107
+ event.stopPropagation();
108
+ onCancel();
109
+ }
110
+ }}
111
+ />
112
+ <button class="button small primary" type="submit" disabled=${text.trim() === ""}>
113
+ OK
114
+ </button>
115
+ <button class="icon-button" type="button" aria-label="Cancel" onClick=${onCancel}>
116
+ <${Icon} name="close" size=${16} />
117
+ </button>
118
+ </form>`;
119
+ }
120
+
121
+ export function CwdSheet({ mode, fromList = false }) {
122
+ const { view, server, folderFavorites: favorites } = store.state;
123
+ const changing = mode === "change";
124
+ // Where it opens: the folder of the session you are in (New session here), else the server's own. Issue #96.
125
+ const initial = view.agent?.cwd ?? server?.defaultCwd ?? "~";
126
+ const [listing, setListing] = useState(null);
127
+ const [failed, setFailed] = useState(null);
128
+ // The folder a click selected (mouse only): Open takes it over the one you are in.
129
+ const [selected, setSelected] = useState(null);
130
+ const [hidden, setHidden] = useState(() => localStorage.getItem(HIDDEN_KEY) === "1");
131
+ const [worktree, setWorktree] = useState(false);
132
+ // Typing a new folder's name, or a path to go to; and a path typed that is not there yet.
133
+ const [naming, setNaming] = useState(false);
134
+ const [editing, setEditing] = useState(null);
135
+ const [missing, setMissing] = useState(null);
136
+ // Why a favorite in `missing` does not open, when it is there but is no folder to open (a link to nothing, a file).
137
+ const [missingWhy, setMissingWhy] = useState(null);
138
+ const [busy, setBusy] = useState(false);
139
+ // Set at once, unlike busy: a second shortcut in the same moment does not start a second session.
140
+ const starting = useRef(false);
141
+ const [saving, setSaving] = useState(false);
142
+ const list = useRef(null);
143
+ const crumbsBox = useRef(null);
144
+ // Only the latest read shows: a slow one answering after a later click is dropped.
145
+ const asked = useRef(0);
146
+ // Letters typed in the list jump to the folder they start; they add up for a moment.
147
+ const typed = useRef({ text: "", at: 0 });
148
+ const home = listing?.home ?? server?.home;
149
+ const folder = listing?.path ?? null;
150
+ const dirs = listing?.dirs ?? [];
151
+ const target = selected ?? folder;
152
+ const here = changing && target !== null && target === view.agent?.cwd;
153
+
154
+ /** Show a folder; `select`, a folder in it to select (the one stepped up from, with a mouse). */
155
+ const load = (path, { showHidden = hidden, select = null } = {}) => {
156
+ const ask = ++asked.current;
157
+
158
+ return api(`fs?path=${encodeURIComponent(path)}${showHidden ? "&hidden=1" : ""}`).then(
159
+ (result) => {
160
+ if (ask !== asked.current) {
161
+ return;
162
+ }
163
+
164
+ setListing(result);
165
+ setFailed(null);
166
+ setSelected(touch ? null : select);
167
+ setMissing(null);
168
+ setEditing(null);
169
+ setNaming(false);
170
+ },
171
+ (error) => {
172
+ if (ask !== asked.current) {
173
+ return;
174
+ }
175
+
176
+ // A favorite that does not open, however, gets the banner, which can remove it.
177
+ if (notThere(error, path) || store.state.folderFavorites.includes(path)) {
178
+ setMissing(path);
179
+ setMissingWhy(notThere(error, path) ? null : error.message);
180
+ setEditing(null);
181
+ } else if (listing) {
182
+ notify("error", error.message);
183
+ } else {
184
+ setFailed(error.message);
185
+ }
186
+ },
187
+ );
188
+ };
189
+
190
+ useEffect(() => {
191
+ load(initial);
192
+ }, []);
193
+
194
+ // A new folder shows from its top, or with the selected one in view; its path shows to its end. Keys go to the
195
+ // list, with a mouse and keyboard.
196
+ useLayoutEffect(() => {
197
+ if (crumbsBox.current) {
198
+ crumbsBox.current.scrollLeft = crumbsBox.current.scrollWidth;
199
+ markCut(crumbsBox.current);
200
+ }
201
+
202
+ const row =
203
+ selected && list.current?.querySelector(`.fp-row[data-path="${CSS.escape(selected)}"]`);
204
+
205
+ if (row) {
206
+ row.scrollIntoView({ block: "nearest" });
207
+ } else if (list.current) {
208
+ list.current.scrollTop = 0;
209
+ }
210
+
211
+ if (!touch && !naming && editing === null) {
212
+ list.current?.focus({ preventScroll: true });
213
+ }
214
+ }, [folder]);
215
+
216
+ useEffect(() => {
217
+ if (selected) {
218
+ list.current
219
+ ?.querySelector(`.fp-row[data-path="${CSS.escape(selected)}"]`)
220
+ ?.scrollIntoView({ block: "nearest" });
221
+ }
222
+ }, [selected]);
223
+
224
+ const up = () => listing?.parent && load(listing.parent, { select: listing.path });
225
+
226
+ /** Make a folder and go into it: Open starts there. */
227
+ const make = async (path) => {
228
+ try {
229
+ const made = await api("fs", { path });
230
+
231
+ await load(made.path);
232
+ } catch (error) {
233
+ notify("error", error.message);
234
+ }
235
+ };
236
+
237
+ /** Start the session in the folder Open names (or a favorite's), or move this one there. */
238
+ const use = async (path = target) => {
239
+ if (path === null || starting.current || (changing && path === view.agent?.cwd)) {
240
+ return;
241
+ }
242
+
243
+ starting.current = true;
244
+ setBusy(true);
245
+
246
+ try {
247
+ if (changing) {
248
+ await actions.configure({ cwd: path });
249
+ closeSheet();
250
+ } else {
251
+ const created = await actions.createSession(path, { worktree });
252
+
253
+ navigate(created.id);
254
+
255
+ // New in the session list: extensions' filters that are on can make it meet them.
256
+ if (fromList) {
257
+ sessionStarted(created.id);
258
+ }
259
+ }
260
+ } catch (error) {
261
+ notify("error", error.message);
262
+ } finally {
263
+ starting.current = false;
264
+ setBusy(false);
265
+ }
266
+ };
267
+
268
+ const mayStar = canSteer() && !scoped();
269
+ const starred = target !== null && favorites.includes(target);
270
+
271
+ /** Star or unstar a folder. The list changes when the server says it did, on every device. */
272
+ const setFavorite = (path, on) =>
273
+ attempt(async () => {
274
+ setSaving(true);
275
+
276
+ try {
277
+ await api("folder-favorites", { path, starred: on });
278
+
279
+ return true;
280
+ } finally {
281
+ setSaving(false);
282
+ }
283
+ });
284
+
285
+ const toggleFavorite = () => {
286
+ setFavorite(target, !starred);
287
+ // The list keeps the keys: the star, off while it saves, would drop the focus to the page.
288
+ list.current?.focus({ preventScroll: true });
289
+ };
290
+
291
+ // Ctrl/⌘+1…9 starts a session in a favorite, while this is the New session picker on top. Caught before the
292
+ // app's own keys; Alt+1…9 (sessions) has Alt, so it never gets here.
293
+ useEffect(() => {
294
+ if (changing) {
295
+ return;
296
+ }
297
+
298
+ const shortcut = (event) => {
299
+ const sheet = store.state.sheet;
300
+ const mod = APPLE ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
301
+ const digit = /^Digit([1-9])$/.exec(event.code);
302
+ const favorite = digit && favorites[Number(digit[1]) - 1];
303
+
304
+ if (
305
+ !mod ||
306
+ !favorite ||
307
+ event.altKey ||
308
+ event.shiftKey ||
309
+ event.isComposing ||
310
+ sheet?.type !== "cwd" ||
311
+ sheet.mode !== "new" ||
312
+ store.state.launcher
313
+ ) {
314
+ return;
315
+ }
316
+
317
+ event.preventDefault();
318
+ event.stopImmediatePropagation();
319
+
320
+ if (!event.repeat) {
321
+ use(favorite);
322
+ }
323
+ };
324
+
325
+ addEventListener("keydown", shortcut, true);
326
+
327
+ return () => removeEventListener("keydown", shortcut, true);
328
+ }, [changing, favorites, worktree]);
329
+
330
+ const toggleHidden = () => {
331
+ const next = !hidden;
332
+
333
+ localStorage.setItem(HIDDEN_KEY, next ? "1" : "0");
334
+ setHidden(next);
335
+
336
+ if (folder !== null) {
337
+ load(folder, { showHidden: next, select: selected });
338
+ }
339
+ };
340
+
341
+ /** A path typed: whole, or from the folder you are in. */
342
+ const goTo = (text) => load(/^[~/]/.test(text) ? text : joined(folder, text));
343
+
344
+ // The list's keys, as a file dialog's: arrows select, Enter goes into the selected folder (or opens the one you are
345
+ // in), Ctrl+Enter opens, Backspace goes up, letters jump to a folder, and / or ~ starts a path.
346
+ const onKeyDown = (event) => {
347
+ if (event.target !== list.current || event.isComposing) {
348
+ return;
349
+ }
350
+
351
+ const at = dirs.findIndex((dir) => dir.path === selected);
352
+
353
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
354
+ event.preventDefault();
355
+
356
+ if (event.altKey && event.key === "ArrowUp") {
357
+ up();
358
+
359
+ return;
360
+ }
361
+
362
+ const next =
363
+ at < 0
364
+ ? event.key === "ArrowDown"
365
+ ? 0
366
+ : dirs.length - 1
367
+ : Math.max(
368
+ 0,
369
+ Math.min(dirs.length - 1, at + (event.key === "ArrowDown" ? 1 : -1)),
370
+ );
371
+
372
+ setSelected(dirs[next]?.path ?? null);
373
+ } else if (event.key === "Home" || event.key === "End") {
374
+ event.preventDefault();
375
+ setSelected(dirs[event.key === "Home" ? 0 : dirs.length - 1]?.path ?? null);
376
+ } else if (event.key === "Enter") {
377
+ event.preventDefault();
378
+
379
+ if (event.ctrlKey || event.metaKey || at < 0) {
380
+ use();
381
+ } else {
382
+ load(selected);
383
+ }
384
+ } else if (event.key === "Backspace") {
385
+ event.preventDefault();
386
+ up();
387
+ } else if ((event.ctrlKey || event.metaKey) && event.key === "l") {
388
+ event.preventDefault();
389
+ setEditing(folder ?? "");
390
+ } else if (event.key === "/" || event.key === "~") {
391
+ event.preventDefault();
392
+ setEditing(event.key);
393
+ } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
394
+ const now = Date.now();
395
+ const text =
396
+ (now - typed.current.at < 800 ? typed.current.text : "") + event.key.toLowerCase();
397
+ const found = dirs.find((dir) => dir.name.toLowerCase().startsWith(text));
398
+
399
+ typed.current = { text, at: now };
400
+
401
+ if (found) {
402
+ setSelected(found.path);
403
+ }
404
+ }
405
+ };
406
+
407
+ const crumbs = folder === null ? [] : crumbsOf(folder, home);
408
+ const recent = (listing?.recent ?? []).filter((path) => !favorites.includes(path));
409
+ const favoriteLabels = recentLabels(favorites, home);
410
+ const labels = recentLabels(recent, home);
411
+ const places = [
412
+ ...(home ? [{ path: home, name: "Home", icon: "home" }] : []),
413
+ { path: "/", name: "Computer", icon: "monitor" },
414
+ ];
415
+
416
+ const place = (path, name, icon, key = null) => html`<button
417
+ key=${path}
418
+ type="button"
419
+ class=${`fp-place ${path === folder ? "on" : ""}`}
420
+ title=${shortPath(path, home)}
421
+ aria-current=${path === folder ? "location" : undefined}
422
+ onClick=${() => load(path)}
423
+ >
424
+ <${Icon} name=${icon} size=${15} />
425
+ <span>${name}</span>
426
+ ${key && html`<span class="fp-key">${key}</span>`}
427
+ </button>`;
428
+
429
+ return html`<${Sheet}
430
+ title=${changing ? "Move this session: pick a folder" : "New session: pick a folder"}
431
+ onClose=${closeSheet}
432
+ wide=${true}
433
+ className="folder-sheet"
434
+ >
435
+ <div class="fp-bar">
436
+ <button
437
+ type="button"
438
+ class="icon-button"
439
+ aria-label="Up one folder"
440
+ title="Up one folder (Backspace)"
441
+ disabled=${!listing?.parent}
442
+ onClick=${up}
443
+ >
444
+ <${Icon} name="up" size=${18} />
445
+ </button>
446
+ ${
447
+ editing !== null
448
+ ? html`<${LineInput}
449
+ class="fp-path-input"
450
+ value=${editing}
451
+ placeholder="A path: ~/code or /srv"
452
+ label="Go to a path"
453
+ onSubmit=${goTo}
454
+ onCancel=${() => {
455
+ setEditing(null);
456
+ list.current?.focus({ preventScroll: true });
457
+ }}
458
+ />`
459
+ : html`<nav
460
+ class="fp-crumbs"
461
+ ref=${crumbsBox}
462
+ onScroll=${(event) => markCut(event.currentTarget)}
463
+ aria-label="Folder"
464
+ title="Click the path's end to type one (Ctrl+L)"
465
+ >
466
+ ${crumbs.map((crumb, index) =>
467
+ index === crumbs.length - 1
468
+ ? html`<button
469
+ key=${crumb.path}
470
+ type="button"
471
+ class="fp-crumb here"
472
+ aria-current="location"
473
+ aria-label=${`${crumb.name}: type a path`}
474
+ onClick=${() => setEditing(folder)}
475
+ >
476
+ ${crumb.name}
477
+ </button>`
478
+ : html`<button
479
+ key=${crumb.path}
480
+ type="button"
481
+ class="fp-crumb"
482
+ onClick=${() => load(crumb.path)}
483
+ >
484
+ ${crumb.name}
485
+ </button>
486
+ <${Icon}
487
+ key=${`${crumb.path}/`}
488
+ name="chevron"
489
+ size=${11}
490
+ class="fp-sep"
491
+ />`,
492
+ )}
493
+ </nav>`
494
+ }
495
+ <button
496
+ type="button"
497
+ class="button small fp-new"
498
+ disabled=${folder === null}
499
+ onClick=${() => {
500
+ setEditing(null);
501
+ setNaming(true);
502
+ }}
503
+ >
504
+ <${Icon} name="plus" size=${14} /> New folder
505
+ </button>
506
+ </div>
507
+ ${
508
+ missing !== null &&
509
+ html`<div class="folder-missing">
510
+ <span>
511
+ ${missingWhy ?? html`<span class="mono">${shortPath(missing, home)}</span> isn't there.`}
512
+ </span>
513
+ <span class="fp-missing-actions">
514
+ ${
515
+ missingWhy === null &&
516
+ html`<button class="button small" onClick=${() => make(missing)}>
517
+ Create it
518
+ </button>`
519
+ }
520
+ ${
521
+ // A favorite that is gone is unstarred here: the star acts only on a folder that opens.
522
+ mayStar &&
523
+ favorites.includes(missing) &&
524
+ html`<button
525
+ class="button small"
526
+ disabled=${saving}
527
+ onClick=${() =>
528
+ setFavorite(missing, false).then(
529
+ (done) => done && setMissing(null),
530
+ )}
531
+ >
532
+ Remove from favorites
533
+ </button>`
534
+ }
535
+ <button
536
+ class="icon-button"
537
+ aria-label="Never mind"
538
+ onClick=${() => setMissing(null)}
539
+ >
540
+ <${Icon} name="close" size=${15} />
541
+ </button>
542
+ </span>
543
+ </div>`
544
+ }
545
+ <div class="fp-main">
546
+ <nav class="fp-places" aria-label="Places">
547
+ ${places.map((each) => place(each.path, each.name, each.icon))}
548
+ ${favorites.length > 0 && html`<div class="fp-places-title">Favorites</div>`}
549
+ ${favorites.map((path, index) =>
550
+ place(
551
+ path,
552
+ favoriteLabels[index],
553
+ "star",
554
+ !changing && !touch && index < 9
555
+ ? `${APPLE ? "⌘" : "Ctrl+"}${index + 1}`
556
+ : null,
557
+ ),
558
+ )}
559
+ ${recent.length > 0 && html`<div class="fp-places-title">Recent</div>`}
560
+ ${recent.map((path, index) => place(path, labels[index], "folder"))}
561
+ ${
562
+ !changing &&
563
+ piSessionsAvailable() &&
564
+ html`<button
565
+ type="button"
566
+ class="link small fp-pi"
567
+ onClick=${() => openSheet({ type: "pi-sessions" })}
568
+ >
569
+ Continue a Pi session…
570
+ </button>`
571
+ }
572
+ </nav>
573
+ <div
574
+ class="fp-list"
575
+ ref=${list}
576
+ role="listbox"
577
+ aria-label="Folders"
578
+ tabindex="0"
579
+ onKeyDown=${onKeyDown}
580
+ >
581
+ ${
582
+ naming &&
583
+ html`<${LineInput}
584
+ class="fp-naming"
585
+ placeholder="Folder name"
586
+ label="The new folder's name"
587
+ onSubmit=${(name) => make(joined(folder, name))}
588
+ onCancel=${() => {
589
+ setNaming(false);
590
+ list.current?.focus({ preventScroll: true });
591
+ }}
592
+ />`
593
+ }
594
+ ${failed && html`<p class="fp-note">${failed}</p>`}
595
+ ${!listing && !failed && html`<p class="fp-note">Opening…</p>`}
596
+ ${dirs.map(
597
+ (dir) => html`<button
598
+ key=${dir.path}
599
+ type="button"
600
+ tabindex="-1"
601
+ class=${`fp-row ${dir.path === selected ? "on" : ""}`}
602
+ role="option"
603
+ aria-selected=${dir.path === selected ? "true" : "false"}
604
+ data-path=${dir.path}
605
+ title=${touch ? undefined : "Double-click to open"}
606
+ onClick=${() => {
607
+ if (touch) {
608
+ load(dir.path);
609
+
610
+ return;
611
+ }
612
+
613
+ // The list keeps the keys: a click gives the row the focus, which the list's keys ignore.
614
+ setSelected(dir.path);
615
+ list.current?.focus({ preventScroll: true });
616
+ }}
617
+ onDblClick=${() => !touch && load(dir.path)}
618
+ >
619
+ <${Icon} name="folder" size=${16} />
620
+ <span class="fp-name">${dir.name}</span>
621
+ ${touch && html`<${Icon} name="chevron" size=${14} class="fp-chev" />`}
622
+ </button>`,
623
+ )}
624
+ ${listing && dirs.length === 0 && html`<p class="fp-note">No folders in here.</p>`}
625
+ </div>
626
+ </div>
627
+ <div class="fp-foot">
628
+ <div class="fp-target" title=${target ? shortPath(target, home) : ""}>
629
+ <span class="muted">Folder:</span>
630
+ <span class="mono">${target ? shortPath(target, home) : "…"}</span>
631
+ ${
632
+ mayStar &&
633
+ target !== null &&
634
+ html`<button
635
+ type="button"
636
+ class=${`icon-button fp-star ${starred ? "on" : ""}`}
637
+ aria-pressed=${starred ? "true" : "false"}
638
+ aria-label=${starred ? "Remove from favorites" : "Add to favorites"}
639
+ title=${starred ? "Remove from favorites" : "Add to favorites"}
640
+ disabled=${saving}
641
+ onClick=${toggleFavorite}
642
+ >
643
+ <${Icon} name="star" size=${15} />
644
+ </button>`
645
+ }
646
+ </div>
647
+ <div class="fp-options">
648
+ ${
649
+ !changing &&
650
+ html`<label
651
+ class="check"
652
+ title="The session gets a branch of its own, apart from this folder"
653
+ >
654
+ <input
655
+ type="checkbox"
656
+ checked=${worktree}
657
+ onChange=${(event) => setWorktree(event.currentTarget.checked)}
658
+ />
659
+ Own git worktree
660
+ </label>`
661
+ }
662
+ <label class="check">
663
+ <input type="checkbox" checked=${hidden} onChange=${toggleHidden} /> Show hidden
664
+ </label>
665
+ </div>
666
+ <div class="fp-buttons">
667
+ <button type="button" class="button" onClick=${closeSheet}>Cancel</button>
668
+ <button
669
+ type="button"
670
+ class="button primary"
671
+ title=${touch ? undefined : "Ctrl+Enter"}
672
+ disabled=${target === null || busy || here}
673
+ onClick=${() => use()}
674
+ >
675
+ ${here ? "Already here" : changing ? "Move here" : "Open"}
676
+ </button>
677
+ </div>
678
+ </div>
679
+ <//>`;
680
+ }