k0-board 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +426 -1
  21. package/README.md +1021 -119
  22. package/bin/k0-board.js +81 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +37 -6
  26. package/platform/darwin/index.js +1 -0
  27. package/platform/darwin/shell.js +4 -1
  28. package/platform/darwin/terminal.js +230 -56
  29. package/platform/index.js +3 -1
  30. package/platform/linux/index.js +14 -0
  31. package/platform/linux/shell.js +10 -1
  32. package/platform/linux/tray.py +10 -10
  33. package/platform/shared/command.js +19 -3
  34. package/platform/shared/tmux.js +32 -14
  35. package/platform/win32/index.js +6 -2
  36. package/platform/win32/shell.js +4 -1
  37. package/platform/win32/terminal.js +24 -8
  38. package/platform/win32/tray.ps1 +13 -13
  39. package/server/backlog.js +618 -0
  40. package/server/changelog.js +13 -13
  41. package/server/db.js +1800 -146
  42. package/server/files.js +156 -26
  43. package/server/git.js +53 -14
  44. package/server/idle.js +15 -13
  45. package/server/index.js +1286 -189
  46. package/server/launcher.js +122 -14
  47. package/server/machine.js +10 -1
  48. package/server/mirror.js +1048 -0
  49. package/server/prices.js +59 -0
  50. package/server/projects.js +57 -12
  51. package/server/servers.js +22 -1
  52. package/server/sessions.js +5 -5
  53. package/server/settings.js +25 -2
  54. package/server/update.js +324 -0
  55. package/server/usage.js +642 -0
  56. package/server/watcher.js +67 -13
  57. package/server/worktree.js +600 -0
  58. package/server/writer.js +98 -26
  59. package/web/awake.js +62 -0
  60. package/web/base.css +4 -0
  61. package/web/board.css +404 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/conf.js +118 -0
  65. package/web/files.css +206 -1
  66. package/web/files.html +10 -0
  67. package/web/files.js +533 -75
  68. package/web/fuzzy.js +73 -21
  69. package/web/index.html +109 -12
  70. package/web/json.js +145 -0
  71. package/web/list.css +536 -0
  72. package/web/list.js +1015 -0
  73. package/web/mentions.js +27 -9
  74. package/web/recency.js +10 -10
  75. package/web/units.js +1 -1
  76. package/web/usage.css +169 -0
  77. package/web/usage.html +78 -0
  78. package/web/usage.js +447 -0
  79. package/web/view.js +22 -3
  80. package/web/whatsnew.css +211 -0
  81. package/web/whatsnew.html +49 -0
  82. package/web/whatsnew.js +339 -0
package/web/files.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { render, esc, matter, titleOf } from '/md.js'
2
- import { score } from '/fuzzy.js'
2
+ import { tree as jsonTree } from '/json.js'
3
+ import { envTable, highlight as confText } from '/conf.js'
4
+ import { score, positions, runs } from '/fuzzy.js'
3
5
  import { mentions, countIn } from '/mentions.js'
4
6
  import { resolveRel, index as refIndexOf, resolve as resolveRef, candidates, worth, trim, TOKEN_SRC } from '/refs.js'
5
7
  import { bytes } from '/units.js'
6
8
  import { setFavicon } from '/favicon.js'
9
+ import { whileVisible } from '/awake.js'
7
10
 
8
11
  // ── The viewer ────────────────────────────────────────────────────────
9
12
  // k0's second tab: the files on the left, what they say on the right. It opens from the mark on
10
- // a card and does not take the board away, which stays where it is.
13
+ // a post-it and does not take the board away, which stays where it is.
11
14
  //
12
15
  // There is no index: the listing arrives from the server ready-made and the search runs here, on
13
16
  // the names. First the files the session touched, then the recently modified ones — which is also
@@ -22,7 +25,9 @@ import { setFavicon } from '/favicon.js'
22
25
  const $ = (s) => document.querySelector(s)
23
26
  const params = new URLSearchParams(location.search)
24
27
  const REPO = params.get('repo') || ''
25
- const CARD = params.get('card') || ''
28
+ // `card` is the name this parameter had before stories were called stories, and somebody
29
+ // somewhere has this address bookmarked: both are read, only the new one is ever written.
30
+ const STORY = params.get('story') || params.get('card') || ''
26
31
  /** Nobody is watching: the page is open only to be printed into a PDF. */
27
32
  const HEADLESS = params.has('pdf')
28
33
 
@@ -34,7 +39,7 @@ const api = async (url, opts) => {
34
39
  }
35
40
 
36
41
  const qs = (extra = {}) =>
37
- new URLSearchParams({ repo: REPO, ...(CARD ? { card: CARD } : {}), ...extra }).toString()
42
+ new URLSearchParams({ repo: REPO, ...(STORY ? { story: STORY } : {}), ...extra }).toString()
38
43
 
39
44
  /** The address of a file's real bytes: images and PDFs come through here. */
40
45
  const rawUrl = (p) => `/api/file/raw?${qs({ path: p })}`
@@ -64,10 +69,55 @@ let open = null // { path, mtime }
64
69
  // slice of the listing with a label at the top and a way out — so it is one piece of state.
65
70
  let scope = null
66
71
  let opened = new Set() // rows opened by hand: directories, and names that fit several files
72
+ // Whether the configuration files are shown as well. It is remembered because it is not a search
73
+ // but a way of working: somebody who wants to see the `.env` today wants to see it tomorrow.
74
+ let nerd = localStorage.getItem('k0-files-nerd') === '1'
75
+ // What this machine can do with a file outside the browser. Until the listing arrives we assume
76
+ // it can, which is what every machine k0 has met so far does.
77
+ let can = { reveal: true, open: true, whyNot: null }
78
+ // The file being written, while it is being written: `{ path, mtime }`. Everything that would
79
+ // redraw the right-hand pane has to stand aside while this is set, or what you have typed goes.
80
+ let editing = null
67
81
 
68
82
  // ── The list on the left ──────────────────────────────────────────────
69
83
  const dirOf = (p) => p.slice(0, p.lastIndexOf('/') + 1).replace(/\/$/, '')
70
84
  const nameOf = (p) => p.slice(p.lastIndexOf('/') + 1)
85
+ const extOf = (p) => (/\.[^./]+$/.exec(nameOf(p))?.[0] ?? '').toLowerCase()
86
+
87
+ // The same rules as the server's, written a second time on purpose: a browser cannot import a
88
+ // module that reads the filesystem, and this decides only what to *offer*. What is really allowed
89
+ // is decided over there, on the file it is about to write. Change one, change the other.
90
+ const ENV_NAME = /^\.env(\..+)?$/
91
+ const CONF_EXT = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
92
+ const NOTE_EXT = new Set(['.md', '.markdown', '.mdx', '.txt'])
93
+ const isConfig = (p) => CONF_EXT.has(extOf(p)) || ENV_NAME.test(nameOf(p))
94
+ const canEdit = (p) => NOTE_EXT.has(extOf(p)) || isConfig(p)
95
+ // The same ceiling the server puts on a save, so the pencil is not offered for a file that would
96
+ // then be refused. Far above anything anybody edits in a browser tab.
97
+ const MAX_EDIT = 1 << 18
98
+
99
+ /** The files on show right now: everything, or everything that is not configuration. */
100
+ const listed = () => (nerd ? all : all.filter((f) => !f.c))
101
+
102
+ /** The switch as it looks. */
103
+ const markNerd = () => {
104
+ $('#nerd').setAttribute('aria-pressed', String(nerd))
105
+ $('#nerd').classList.toggle('on', nerd)
106
+ }
107
+
108
+ /**
109
+ * The switch, and everything that follows from it. The search inside the files has to be asked
110
+ * again straight away: that one runs on the server, and the server was told to look at a smaller
111
+ * set of files than it should now be looking at.
112
+ */
113
+ function setNerd(on) {
114
+ nerd = on
115
+ localStorage.setItem('k0-files-nerd', on ? '1' : '0')
116
+ markNerd()
117
+ active = -1
118
+ paint()
119
+ lookInside()
120
+ }
71
121
 
72
122
  function since(ms) {
73
123
  const m = Math.floor((Date.now() - ms) / 60000)
@@ -98,9 +148,10 @@ function quoted(h) {
98
148
  */
99
149
  function groups() {
100
150
  const q = $('#q').value.trim()
151
+ const here = listed()
101
152
  if (q) {
102
153
  const hits = []
103
- for (const f of all) {
154
+ for (const f of here) {
104
155
  const s = score(f.p, q)
105
156
  if (s >= 0) hits.push({ f, s })
106
157
  }
@@ -116,8 +167,8 @@ function groups() {
116
167
  ].filter((g) => g.files.length)
117
168
  }
118
169
 
119
- const touched = all.filter((f) => changed.has(f.p)).sort((a, b) => b.m - a.m)
120
- const rest = all.filter((f) => !changed.has(f.p)).sort((a, b) => b.m - a.m)
170
+ const touched = here.filter((f) => changed.has(f.p)).sort((a, b) => b.m - a.m)
171
+ const rest = here.filter((f) => !changed.has(f.p)).sort((a, b) => b.m - a.m)
121
172
  return [
122
173
  { title: 'Changed', files: touched },
123
174
  { title: 'Recent', files: rest.slice(0, 30) },
@@ -125,6 +176,33 @@ function groups() {
125
176
  ].filter((g) => g.files.length)
126
177
  }
127
178
 
179
+ /**
180
+ * How many configuration files the switch is hiding that would have come **above** everything it
181
+ * is showing.
182
+ *
183
+ * Without this, searching for `.env` with the switch off is the worst kind of answer: eighty files
184
+ * that merely happen to contain those four letters somewhere in their path, and no sign at all
185
+ * that the one you asked for is sitting right there behind a button.
186
+ *
187
+ * "Better than anything visible" and not "matches at all" is the whole rule. The name search
188
+ * matches letters in order, so nearly any word matches something somewhere: counting every hidden
189
+ * match would put this line above almost every search, and a warning that is always there is read
190
+ * as decoration within a day. This way it appears exactly when the answer really is the one being
191
+ * held back.
192
+ */
193
+ function hiddenByTheSwitch(q) {
194
+ if (nerd || !q) return 0
195
+ let best = -1
196
+ const hidden = []
197
+ for (const f of all) {
198
+ const s = score(f.p, q)
199
+ if (s < 0) continue
200
+ if (f.c) hidden.push(s)
201
+ else if (s > best) best = s
202
+ }
203
+ return hidden.filter((s) => s > best).length
204
+ }
205
+
128
206
  // ── The files a piece of chat names ───────────────────────────────────
129
207
  /**
130
208
  * What the pasted text names, recomputed only when it has to be: `paint()` runs as often as every
@@ -134,12 +212,34 @@ function groups() {
134
212
  */
135
213
  let memo = { text: null, from: null, out: null }
136
214
  function mentioned() {
137
- if (memo.text !== scope.text || memo.from !== all) {
138
- memo = { text: scope.text, from: all, out: mentions(scope.text, all) }
215
+ const from = searchable()
216
+ if (memo.text !== scope.text || memo.from !== from) {
217
+ memo = { text: scope.text, from, out: mentions(scope.text, from) }
139
218
  }
140
219
  return memo.out
141
220
  }
142
221
 
222
+ /**
223
+ * The files the pasted text is read against: the listing, plus the ones the disk confirmed when
224
+ * the text named something the listing does not carry — an HTML beside its PDF, anything under an
225
+ * ignored directory. They are handed over as ordinary files on purpose: order, the "7 more" fold,
226
+ * the count in the chip and the first one opening by itself all go on working without knowing
227
+ * where a row came from. `listed()` is untouched, so the listing on the left does not grow with
228
+ * what was merely named.
229
+ */
230
+ let found = []
231
+ let searchMemo = { from: null, with: null, out: null }
232
+ function searchable() {
233
+ if (!found.length) return all
234
+ if (searchMemo.from !== all || searchMemo.with !== found) {
235
+ // Rebuilt only when one of the two changes: `paint()` runs every three seconds, and a fresh
236
+ // array each round would throw the memo above away every time.
237
+ const have = new Set(all.map((f) => f.p))
238
+ searchMemo = { from: all, with: found, out: all.concat(found.filter((f) => !have.has(f.p))) }
239
+ }
240
+ return searchMemo.out
241
+ }
242
+
143
243
  /** How many files the text touches, open or closed: the number must not jump about. */
144
244
  const howMany = (out) => countIn(out.named) + countIn(out.maybe)
145
245
 
@@ -153,11 +253,33 @@ const howMany = (out) => countIn(out.named) + countIn(out.maybe)
153
253
  function rowHtml(f) {
154
254
  rows.push(f)
155
255
  const dir = dirOf(f.p)
256
+ // Which letters put this row here — the search only ever looks inside the name, so every
257
+ // position `positions()` hands back already falls inside it; the offset just undoes what it
258
+ // added to make that position mean something against the whole path.
259
+ const at = positions(f.p, $('#q').value.trim())
260
+ const nameAt = dir ? dir.length + 1 : 0
261
+ const name = lit(nameOf(f.p), at?.map((i) => i - nameAt))
262
+ const road = dir ? `<i>${esc(dir)}</i>` : ''
156
263
  return `<a class="row${f.p === open?.path ? ' on' : ''}${changed.has(f.p) ? ' hot' : ''}" href="${esc(
157
264
  viewUrl(f.p)
158
- )}" data-p="${esc(f.p)}"><b>${esc(nameOf(f.p))}</b>${
159
- dir ? `<i>${esc(dir)}</i>` : ''
160
- }<em>${since(f.m)}</em>${f.line ? `<q>${quoted(f)}</q>` : ''}</a>`
265
+ )}" data-p="${esc(f.p)}"><b>${name}</b>${road}<em>${since(f.m)}</em>${
266
+ f.line ? `<q>${quoted(f)}</q>` : ''
267
+ }</a>`
268
+ }
269
+
270
+ /** A piece of text with the stretches that matched underlined. */
271
+ function lit(text, at) {
272
+ const spans = runs(at)
273
+ if (!spans.length) return esc(text)
274
+ let out = ''
275
+ let from = 0
276
+ for (const [start, stop] of spans) {
277
+ if (start >= text.length) break
278
+ const end = Math.min(stop, text.length)
279
+ out += `${esc(text.slice(from, start))}<u>${esc(text.slice(start, end))}</u>`
280
+ from = end
281
+ }
282
+ return out + esc(text.slice(from))
161
283
  }
162
284
 
163
285
  /**
@@ -192,29 +314,84 @@ function entryHtml(e) {
192
314
  }
193
315
 
194
316
  /** The files inside a directory. Only its own, not the tree below it. */
195
- const filesIn = (d) => all.filter((f) => dirOf(f.p) === d).sort((a, b) => b.m - a.m)
317
+ const filesIn = (d) => listed().filter((f) => dirOf(f.p) === d).sort((a, b) => b.m - a.m)
196
318
 
197
319
  /**
198
- * The label at the top of the listing: where you are, and how to get out. The first button is
199
- * also the way back to whatever brought you here — the text, to correct it — while for a
200
- * directory there is nothing to correct and it simply says so.
320
+ * The directories directly inside `d`, with how many files each one holds below it — all the way
321
+ * down, not only its own floor. The number is what tells you whether it is worth going in, and a
322
+ * folder of folders would otherwise say nothing at all.
323
+ *
324
+ * There is no separate listing of directories, and there does not need to be one: a directory is
325
+ * a piece of the path of the files inside it, and the listing is already here.
201
326
  */
202
- function chipHtml(label, again) {
203
- const back = `<button type="button" id="paste-clear" class="x" title="Back to all files (Esc)" aria-label="Back to all files"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>`
204
- const head = again
205
- ? `<button type="button" id="paste-again" title="See and change the text">${label}</button>`
206
- : `<span>${label}</span>`
207
- return `<div class="from-text">${head}${back}</div>`
327
+ function foldersIn(d) {
328
+ const prefix = d ? `${d}/` : ''
329
+ const kids = new Map()
330
+ for (const f of listed()) {
331
+ if (prefix && !f.p.startsWith(prefix)) continue
332
+ const rest = f.p.slice(prefix.length)
333
+ const at = rest.indexOf('/')
334
+ if (at === -1) continue
335
+ const name = rest.slice(0, at)
336
+ kids.set(name, (kids.get(name) ?? 0) + 1)
337
+ }
338
+ return [...kids]
339
+ .sort((a, b) => a[0].localeCompare(b[0]))
340
+ .map(([name, n]) => ({ dir: prefix + name, name, n }))
341
+ }
342
+
343
+ /**
344
+ * A folder, in the same list and the same shape as the files: it is a real link, so it opens in
345
+ * another tab like everything else here, and it goes into `rows` so the arrow keys walk it too.
346
+ */
347
+ function folderHtml(f) {
348
+ rows.push(f)
349
+ return `<a class="row folder" href="${esc(dirUrl(f.dir))}" data-dir="${esc(f.dir)}"><b>${esc(
350
+ f.name
351
+ )}</b><em>${f.n} file${f.n === 1 ? '' : 's'}</em></a>`
208
352
  }
209
353
 
210
- /** The listing when a directory is what governs it. */
354
+ /** The way out of a narrowed listing, drawn the same wherever the narrowing came from. */
355
+ const backOut = `<button type="button" id="paste-clear" class="x" title="Back to all files (Esc)" aria-label="Back to all files"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>`
356
+
357
+ /**
358
+ * The label at the top of the listing when a pasted text is what narrowed it: how many files it
359
+ * named, the way back to the text itself to correct it, and the way out.
360
+ */
361
+ const chipHtml = (label) =>
362
+ `<div class="from-text"><button type="button" id="paste-again" title="See and change the text">${label}</button>${backOut}</div>`
363
+
364
+ /**
365
+ * Where you are, one piece of the path at a time, each one a way back up. The first piece is the
366
+ * repository itself, which is the way out altogether.
367
+ *
368
+ * It is a trail rather than a "back" button because you never climb only one floor: from
369
+ * `docs/backlog/handover` what you want is `docs`, and a button would make you press it twice
370
+ * without telling you where you were going to land.
371
+ */
372
+ function trailHtml(d) {
373
+ const parts = d.split('/')
374
+ const road = parts
375
+ .map((name, i) => {
376
+ const upto = parts.slice(0, i + 1).join('/')
377
+ const last = i === parts.length - 1
378
+ return last
379
+ ? `<b>${esc(name)}</b>`
380
+ : `<a href="${esc(dirUrl(upto))}" data-dir="${esc(upto)}">${esc(name)}</a>`
381
+ })
382
+ .join('<i>/</i>')
383
+ const home = `<a href="?${qs()}" data-dir="">${esc($('#repo').textContent || 'all files')}</a>`
384
+ return `<div class="from-text trail">${home}<i>/</i>${road}${finderButton('dir-open', 'Open this folder in your file manager', can.open)}${backOut}</div>`
385
+ }
386
+
387
+ /** The listing when a directory is what governs it: what is under it, then what is in it. */
211
388
  function dirHtml() {
389
+ const dirs = foldersIn(scope.dir)
212
390
  const fs = filesIn(scope.dir)
213
- return (
214
- chipHtml(`${fs.length} file${fs.length === 1 ? '' : 's'} in ${esc(scope.dir)}`, false) +
215
- `<h2>${esc(nameOf(scope.dir))}<small>${fs.length}</small></h2>` +
216
- fs.map((f) => rowHtml(f)).join('')
217
- )
391
+ let html = trailHtml(scope.dir)
392
+ if (dirs.length) html += `<h2>Folders<small>${dirs.length}</small></h2>${dirs.map(folderHtml).join('')}`
393
+ if (fs.length) html += `<h2>${esc(nameOf(scope.dir))}<small>${fs.length}</small></h2>${fs.map((f) => rowHtml(f)).join('')}`
394
+ return html
218
395
  }
219
396
 
220
397
  /** The listing when a pasted text is what governs it. */
@@ -223,7 +400,7 @@ function pastedHtml() {
223
400
  const n = howMany(out)
224
401
  // Where you are stays visible at the top: the number, the way out, and the text itself one
225
402
  // click away so it can be corrected without pasting it again.
226
- let html = chipHtml(`${n} file${n === 1 ? '' : 's'} from your text`, true)
403
+ let html = chipHtml(`${n} file${n === 1 ? '' : 's'} from your text`)
227
404
  if (out.named.length) {
228
405
  html += `<h2>From your text<small>${countIn(out.named)}</small></h2>`
229
406
  html += out.named.map(entryHtml).join('')
@@ -242,14 +419,39 @@ function pastedHtml() {
242
419
  return html
243
420
  }
244
421
 
245
- /** The usual listing: the groups, and the files under them. */
422
+ /**
423
+ * The usual listing: the folders, then the groups and the files under them.
424
+ *
425
+ * The folders come first because they are the only thing here that is not a document: skip past
426
+ * them and you are in the list you already knew. While searching they are gone — you are looking
427
+ * for a name, and the folders are not among the names you are looking at.
428
+ */
429
+ /**
430
+ * The line above the results when the switch is hiding something you were looking for. It is the
431
+ * button itself, not an instruction: one click, and the file appears.
432
+ */
433
+ function nudgeHtml() {
434
+ const hidden = hiddenByTheSwitch($('#q').value.trim())
435
+ if (!hidden) return ''
436
+ return `<button type="button" class="nudge" id="show-config">${hidden} configuration file${
437
+ hidden === 1 ? '' : 's'
438
+ } also match — show them</button>`
439
+ }
440
+
246
441
  function plainHtml() {
247
- return groups()
248
- .map(
249
- (g) =>
250
- `<h2>${g.title}<small>${g.files.length}</small></h2>` + g.files.map((f) => rowHtml(f)).join('')
251
- )
252
- .join('')
442
+ const dirs = $('#q').value.trim() ? [] : foldersIn('')
443
+ const head =
444
+ nudgeHtml() +
445
+ (dirs.length ? `<h2>Folders<small>${dirs.length}</small></h2>${dirs.map(folderHtml).join('')}` : '')
446
+ return (
447
+ head +
448
+ groups()
449
+ .map(
450
+ (g) =>
451
+ `<h2>${g.title}<small>${g.files.length}</small></h2>` + g.files.map((f) => rowHtml(f)).join('')
452
+ )
453
+ .join('')
454
+ )
253
455
  }
254
456
 
255
457
  function paint() {
@@ -266,23 +468,34 @@ function paint() {
266
468
  // Empty does not mean "no rows on screen": with a pasted text the guesses arrive closed, and a
267
469
  // result made only of those has no rows to show while still having found everything it should.
268
470
 
269
- const found = text ? howMany(mentioned()) : scope ? filesIn(scope.dir).length : rows.length
270
- list.innerHTML = !found
271
- ? `<p class="blank">${
272
- text
273
- ? 'Nothing of that text is here any more.'
274
- : scope
275
- ? 'Nothing in that folder any more.'
276
- : $('#q').value.trim()
277
- ? 'Nothing with that name.'
278
- : 'No documents here. This only lists what you can read and print — text, not code.'
279
- }</p>`
280
- : html
471
+ const shown = listed().length
472
+ const found = text
473
+ ? howMany(mentioned())
474
+ : scope
475
+ ? filesIn(scope.dir).length + foldersIn(scope.dir).length
476
+ : rows.length
477
+ const nothing = text
478
+ ? 'Nothing of that text is here any more.'
479
+ : scope
480
+ ? 'Nothing in that folder any more.'
481
+ : $('#q').value.trim()
482
+ ? 'Nothing with that name.'
483
+ : nerd
484
+ ? 'Nothing here to read or to configure.'
485
+ : 'No documents here. This lists what you can read and print — and, with the config ' +
486
+ 'button beside the search, the configuration too.'
487
+ // An empty folder still shows the path across the top: it is the way back up, and taking it
488
+ // away would leave you inside a folder with nothing in it and nothing to press. And a search
489
+ // that found nothing visible still says what the switch is holding back — that is the case
490
+ // where saying it matters most.
491
+ list.innerHTML = found
492
+ ? html
493
+ : (scope && !text ? trailHtml(scope.dir) : '') +
494
+ (scope ? '' : nudgeHtml()) +
495
+ `<p class="blank">${nothing}</p>`
281
496
 
282
497
  list.scrollTop = scroll
283
- $('#count').textContent = scope
284
- ? `${found} of ${all.length}`
285
- : `${all.length} file${all.length === 1 ? '' : 's'}`
498
+ $('#count').textContent = scope ? `${found} of ${shown}` : `${shown} file${shown === 1 ? '' : 's'}`
286
499
  mark()
287
500
  }
288
501
 
@@ -311,10 +524,15 @@ function openPaste(text) {
311
524
  * Takes the text and narrows the listing to what it names. If it names nothing the dialog does
312
525
  * **not** close and the listing is not thrown away: it says what happened, so the text can be
313
526
  * corrected where it is instead of being pasted again.
527
+ *
528
+ * Almost always it finishes without asking anybody anything. The one question goes out when the
529
+ * text writes a document's name in full and the listing has no such file — then the disk is asked,
530
+ * once, about those names and no others.
314
531
  */
315
- function applyPaste(text) {
532
+ async function applyPaste(text) {
316
533
  const t = String(text ?? '').trim()
317
- const out = mentions(t, all)
534
+ found = await confirmed(mentions(t, all).unknown)
535
+ const out = mentions(t, searchable())
318
536
  if (!howMany(out)) {
319
537
  const note = $('#p-note')
320
538
  note.hidden = false
@@ -324,7 +542,7 @@ function applyPaste(text) {
324
542
  return
325
543
  }
326
544
  scope = { kind: 'text', text: t }
327
- memo = { text: t, from: all, out }
545
+ memo = { text: t, from: searchable(), out }
328
546
  opened = new Set()
329
547
  active = -1
330
548
  $('#q').value = ''
@@ -337,6 +555,45 @@ function applyPaste(text) {
337
555
  show(first.files ? first.files[0].p : first.file.p)
338
556
  }
339
557
 
558
+ /**
559
+ * The names the listing does not have, handed to the disk and given back as rows.
560
+ *
561
+ * Only the ones that look like a document are worth a question: `example.com` is a website and
562
+ * `v1.5` is a number. The same rules as the server's, written a second time on purpose — the
563
+ * server checks them again anyway, this only keeps the question short. Change one, change the
564
+ * other.
565
+ */
566
+ const DOC_EXT = /\.(md|markdown|mdx|html?|txt|rtf|pdf|docx?|odt|pages)$/i
567
+ async function confirmed(unknown) {
568
+ const paths = (unknown || []).filter((t) => DOC_EXT.test(t)).slice(0, MAX_ASK)
569
+ if (!paths.length) return []
570
+ try {
571
+ const r = await api('/api/files/exist', { method: 'POST', body: JSON.stringify({ repo: REPO, paths }) })
572
+ return r.files || []
573
+ } catch {
574
+ // The server is not answering: what is left is what the listing could answer on its own.
575
+ return []
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Into a folder. The file open on the right stays open: you go looking for the next one without
581
+ * losing the one you were reading.
582
+ *
583
+ * The address follows, because a folder is a real page — it reloads into the same place and the
584
+ * link can be sent to somebody.
585
+ */
586
+ function enterDir(d) {
587
+ scope = { kind: 'dir', dir: d }
588
+ opened = new Set()
589
+ active = -1
590
+ $('#q').value = ''
591
+ inText = []
592
+ paint()
593
+ document.title = `${nameOf(d) || '/'} — ${REPO.split('/').pop()}`
594
+ history.replaceState(null, '', open ? `?${qs({ dir: d, f: open.path })}` : dirUrl(d))
595
+ }
596
+
340
597
  /**
341
598
  * Back to the whole listing. The file open on the right stays where it is.
342
599
  *
@@ -346,6 +603,8 @@ function applyPaste(text) {
346
603
  function clearScope() {
347
604
  const wasDir = scope?.kind === 'dir'
348
605
  scope = null
606
+ // What the disk confirmed belonged to that text and to no other: it goes out with it.
607
+ found = []
349
608
  opened = new Set()
350
609
  active = -1
351
610
  paint()
@@ -373,8 +632,8 @@ const dirUrl = (d) => `?${qs({ dir: d })}`
373
632
  /**
374
633
  * What this document names that can really be opened: from a written name to an address. Almost
375
634
  * always it finishes without asking anybody anything, because the file listing is already here;
376
- * the server is only reached for the names the listing does not have, which are the documents
377
- * inside the directories the listing skips.
635
+ * the server is only reached for the names the listing does not have — the page beside a finished
636
+ * PDF, anything under a directory git was told to ignore.
378
637
  */
379
638
  async function refsIn(docPath, text) {
380
639
  const ix = refIndex()
@@ -480,7 +739,9 @@ async function show(p, { keepScroll = false } = {}) {
480
739
  doc.innerHTML = `<p class="blank">${esc(e.message)}</p>`
481
740
  return
482
741
  }
483
- open = { path: p, mtime: file.mtime }
742
+ // The text is kept as well as shown: the search inside a tree rebuilds it from here, and the
743
+ // editor starts from it rather than asking the server for what it has just been given.
744
+ open = { path: p, mtime: file.mtime, size: file.size, truncated: !!file.truncated, text: file.text ?? null }
484
745
 
485
746
  // What this document names, before laying it out: the layout engine is synchronous, and for the
486
747
  // names the listing does not have there is a question to ask the server.
@@ -500,7 +761,7 @@ async function show(p, { keepScroll = false } = {}) {
500
761
  head = plate(matter(file.text).data)
501
762
  body = `<article class="md">${render(file.text, link)}</article>`
502
763
  } else if (file.kind === 'text') {
503
- body = `<pre class="plain">${esc(file.text)}</pre>`
764
+ body = textBody(p, file.text)
504
765
  } else if (file.kind === 'image') {
505
766
  body = `<div class="media"><img src="${esc(rawUrl(p))}" alt="${esc(nameOf(p))}"></div>`
506
767
  } else if (file.kind === 'pdf') {
@@ -511,14 +772,15 @@ async function show(p, { keepScroll = false } = {}) {
511
772
  // costs nothing and still holds if the frame ever changes.
512
773
  body = `<iframe class="page" sandbox src="${esc(siteUrl(p))}" title="${esc(nameOf(p))}"></iframe>`
513
774
  } else {
514
- body = `<div class="blank"><p>There's no way to show this one here.</p><button id="reveal">Reveal in Finder</button></div>`
775
+ // The way out is the button in the path row above, the same one every other file has: there
776
+ // is no reason for this case to have a second one of its own.
777
+ body = `<div class="blank"><p>There's no way to show this one here. Open it in your file manager, or take it away with the button above.</p></div>`
515
778
  }
516
779
 
517
780
  const cut = file.truncated ? '<p class="cut">Only the first 2 MB are shown.</p>' : ''
518
781
  doc.innerHTML = crumb + head + body + cut
519
782
  linkRefs(doc.querySelector('.md') ?? doc, refs)
520
783
  doc.scrollTop = scroll
521
- $('#reveal')?.addEventListener('click', reveal)
522
784
 
523
785
  // The name of the page is the document's title: it is what the browser offers as the file name
524
786
  // when you save to PDF, and it is worth far more than "k0 — Files".
@@ -533,12 +795,64 @@ async function show(p, { keepScroll = false } = {}) {
533
795
  paint()
534
796
  }
535
797
 
798
+ /**
799
+ * A text file, shown as the kind of thing it is.
800
+ *
801
+ * A `package.json` is a tree, an `.env` is a table, a YAML file is a shape you must not flatten,
802
+ * and everything else is text. Deciding it here rather than on the server is deliberate: the
803
+ * server says what a file *is* — text, image, page — and that answer is already used by printing,
804
+ * by the PDF and by the download. What it should *look like* is the page's business.
805
+ */
806
+ function textBody(p, text) {
807
+ if (extOf(p) === '.json') {
808
+ const out = jsonTree(text, '')
809
+ // A broken JSON is usually exactly why you opened it: the reason first, then the file as it
810
+ // really is, so you can see the line that is wrong.
811
+ if (out.error) return `<p class="cut">${esc(out.error)}</p><pre class="plain">${esc(text)}</pre>`
812
+ return `<div class="tree" data-json>${out.html}</div>`
813
+ }
814
+ if (ENV_NAME.test(nameOf(p))) return envTable(text)
815
+ if (CONF_EXT.has(extOf(p))) return confText(text)
816
+ return `<pre class="plain">${esc(text)}</pre>`
817
+ }
818
+
819
+ /** The box that searches inside the tree, and what it found. Only where the browser cannot. */
820
+ const findBox = () =>
821
+ `<span class="findin"><input id="jfind" type="search" autocomplete="off" spellcheck="false" placeholder="find in this file…"><em id="jcount"></em></span>`
822
+
823
+ /**
824
+ * The tree again, with what is in the box picked out. Only the tree is rebuilt, so the box keeps
825
+ * the cursor and what you have typed — and the branches holding a result come back open, which is
826
+ * the whole reason this search exists rather than the browser's.
827
+ */
828
+ function findInJson() {
829
+ const box = $('#jfind')
830
+ const host = $('#doc [data-json]')
831
+ if (!box || !host || !open?.text) return
832
+ const q = box.value.trim()
833
+ const out = jsonTree(open.text, q)
834
+ if (out.error) return
835
+ host.innerHTML = out.html
836
+ $('#jcount').textContent = q ? `${out.matches}` : ''
837
+ }
838
+
536
839
  function crumbs(p, file) {
537
840
  const parts = p.split('/')
538
841
  const last = parts.pop()
539
- const road = parts.map((x) => `<span>${esc(x)}</span>`).join('<i>/</i>')
540
842
  const meta = `${bytes(file.size)} · ${since(file.mtime)}`
541
- return `<div class="crumb">${road ? `${road}<i>/</i>` : ''}<b>${esc(last)}</b><em>${meta}</em>${acts(p, file.kind)}</div>`
843
+ // The pieces of the path lead into the folders they name: from a file, the way to its
844
+ // neighbours is the path that is already written above it.
845
+ const walk = parts
846
+ .map((x, i) => {
847
+ const upto = parts.slice(0, i + 1).join('/')
848
+ return `<a class="up" href="${esc(dirUrl(upto))}" data-dir="${esc(upto)}">${esc(x)}</a>`
849
+ })
850
+ .join('<i>/</i>')
851
+ const find = extOf(p) === '.json' && file.kind === 'text' ? findBox() : ''
852
+ return `<div class="crumb">${walk ? `${walk}<i>/</i>` : ''}<b>${esc(last)}</b><em>${meta}</em>${find}${acts(
853
+ p,
854
+ file.kind
855
+ )}</div>`
542
856
  }
543
857
 
544
858
  /** Drawn by hand like the others in the house: same stroke, same grid. */
@@ -546,6 +860,22 @@ const ICON = {
546
860
  print:
547
861
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V3h12v6"/><path d="M6 18H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2"/><path d="M6 14h12v7H6z"/></svg>',
548
862
  down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11"/><path d="M7.5 9.5 12 14l4.5-4.5"/><path d="M4 20h16"/></svg>',
863
+ finder:
864
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
865
+ edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17z"/><path d="M14.5 6.5l3 3"/></svg>',
866
+ }
867
+
868
+ /**
869
+ * The button that hands a file or a folder to the file manager — or the same button, in the same
870
+ * place, switched off with the reason in its title. A control that disappears when the machine
871
+ * cannot do the thing teaches you nothing; one that stays and says why teaches you what is
872
+ * missing.
873
+ */
874
+ function finderButton(id, label, allowed) {
875
+ const why = allowed ? label : can.whyNot || `${label} — this system gives k0 no way to do that.`
876
+ return `<button class="act" id="${id}" data-act="reveal" type="button" title="${esc(why)}" aria-label="${esc(
877
+ label
878
+ )}"${allowed ? '' : ' disabled'}>${ICON.finder}</button>`
549
879
  }
550
880
 
551
881
  /**
@@ -557,12 +887,19 @@ const ICON = {
557
887
  * printing a `.docx` that cannot even be shown here does not, and asking for the PDF of something
558
888
  * that is already a PDF does not either — that one is simply downloaded.
559
889
  */
560
- function acts(p, kind) {
890
+ function acts(p, file) {
891
+ const kind = file.kind
561
892
  const paper = kind === 'markdown' || kind === 'text'
562
893
  // In capitals and without the dot, like the PDF next to it: they are two buttons that do the
563
894
  // same thing, and writing them two different ways made them look like two different things.
564
895
  const ext = /\.([^./]+)$/.exec(p)?.[1].toUpperCase() || 'FILE'
565
896
  const out = []
897
+ // Not on a file that is only half here. What is on screen when a document is cut is the first
898
+ // two megabytes of it, and saving that back would throw the rest away without a word.
899
+ if (paper && canEdit(p) && !file.truncated && file.size <= MAX_EDIT) {
900
+ out.push(`<button class="act" data-act="edit" type="button" title="Edit this file here" aria-label="Edit">${ICON.edit}</button>`)
901
+ }
902
+ out.push(finderButton('reveal', 'Show this file in your file manager', can.reveal))
566
903
  if (paper || kind === 'image') {
567
904
  out.push(`<button class="act" data-act="print" type="button" title="Print (⌘P)" aria-label="Print">${ICON.print}</button>`)
568
905
  }
@@ -621,20 +958,87 @@ function plate(data) {
621
958
 
622
959
  const TITLE_KEYS = new Set(['title', 'titolo', 'name', 'nome'])
623
960
 
624
- async function reveal() {
961
+ /**
962
+ * Hand a path to the file manager. The same call for a file and for a folder: which of the two
963
+ * gestures it is — point at it, or open it — is decided on the other side, by looking at what is
964
+ * really there.
965
+ */
966
+ async function reveal(rel) {
625
967
  try {
626
- await api('/api/file/reveal', { method: 'POST', body: JSON.stringify({ repo: REPO, path: open.path }) })
968
+ await api('/api/file/reveal', { method: 'POST', body: JSON.stringify({ repo: REPO, path: rel }) })
627
969
  } catch (e) {
628
970
  toast(e.message)
629
971
  }
630
972
  }
631
973
 
632
- function toast(msg) {
974
+ // ── Writing a file ────────────────────────────────────────────────────
975
+ // k0 reads your repositories; this is the one place it writes into one. It is deliberately small:
976
+ // configuration and notes only, a file that is already there, and no saving over somebody else's
977
+ // work. Anything larger than that is what you opened the repository with Claude for.
978
+ //
979
+ // The editing happens where the document was, not in a dialog over it: a dialog is the right size
980
+ // for a title and the wrong size for a README, and here the pane is already as tall as the file.
981
+
982
+ /** Turns the open document into the text it is made of. */
983
+ function startEdit() {
984
+ if (!open || open.text == null || !canEdit(open.path)) return
985
+ // The same refusal the button already makes, said again here: what is on screen when a document
986
+ // is cut is only the first two megabytes of it, and saving that back would lose the rest.
987
+ if (open.truncated || open.size > MAX_EDIT) return toast('This file is too big to edit here.')
988
+ editing = { path: open.path, mtime: open.mtime }
989
+ const parts = open.path.split('/')
990
+ const last = parts.pop()
991
+ const road = parts.map((x) => `<span>${esc(x)}</span>`).join('<i>/</i>')
992
+ $('#doc').innerHTML =
993
+ `<div class="crumb editing">${road ? `${road}<i>/</i>` : ''}<b>${esc(last)}</b><em>editing</em>` +
994
+ `<span class="acts"><button class="act" data-act="cancel" type="button">Cancel</button>` +
995
+ `<button class="act keep" data-act="save" type="button" title="Save (⌘S)">Save</button></span></div>` +
996
+ // The newline after the tag is not decoration: the HTML parser eats the first one inside a
997
+ // `<textarea>`, and without it a file that begins with a blank line would quietly lose it.
998
+ `<textarea class="edit" spellcheck="false" autocomplete="off">\n${esc(open.text)}</textarea>`
999
+ const box = $('#doc .edit')
1000
+ box.focus()
1001
+ box.setSelectionRange(0, 0)
1002
+ }
1003
+
1004
+ /** Back to reading, with whatever is on disk. What was typed and not saved is gone, as it says. */
1005
+ function cancelEdit() {
1006
+ const p = editing.path
1007
+ editing = null
1008
+ show(p)
1009
+ }
1010
+
1011
+ let writing = false
1012
+ async function saveEdit() {
1013
+ const box = $('#doc .edit')
1014
+ if (!box || writing) return
1015
+ writing = true
1016
+ try {
1017
+ await api('/api/file/save', {
1018
+ method: 'POST',
1019
+ body: JSON.stringify({ repo: REPO, path: editing.path, text: box.value, mtime: editing.mtime }),
1020
+ })
1021
+ const p = editing.path
1022
+ editing = null
1023
+ toast('Saved.')
1024
+ // Straight back to the file as it now is — read again from disk, not from what we sent, so
1025
+ // what you are looking at is what is really there.
1026
+ await show(p)
1027
+ } catch (e) {
1028
+ // Including the clash: the message says what happened and the text stays exactly where it is,
1029
+ // so it can be copied out or saved again once you have looked.
1030
+ toast(e.message, 8000)
1031
+ } finally {
1032
+ writing = false
1033
+ }
1034
+ }
1035
+
1036
+ function toast(msg, ms = 4000) {
633
1037
  const t = $('#toast')
634
1038
  t.textContent = msg
635
1039
  t.classList.add('show')
636
1040
  clearTimeout(toast.timer)
637
- toast.timer = setTimeout(() => t.classList.remove('show'), 4000)
1041
+ toast.timer = setTimeout(() => t.classList.remove('show'), ms)
638
1042
  }
639
1043
 
640
1044
  // ── The divider ───────────────────────────────────────────────────────
@@ -668,6 +1072,7 @@ async function loadAll() {
668
1072
  all = data.files
669
1073
  known = new Set(all.map((f) => f.p))
670
1074
  changed = new Set(data.changed)
1075
+ can = data.can ?? can
671
1076
  document.title = `k0 — ${data.name}`
672
1077
  $('#repo').textContent = data.name
673
1078
  $('#ctx').textContent = data.title ? `· ${data.title}` : data.git ? '' : '· not a git repository'
@@ -692,8 +1097,10 @@ async function poll() {
692
1097
  changed = new Set(now)
693
1098
  if (fresh) await loadAll()
694
1099
  else if (moved) paint()
695
- // The open file is changing under your eyes: it is re-read, and you stay where you are.
696
- if (open && changed.has(open.path)) {
1100
+ // The open file is changing under your eyes: it is re-read, and you stay where you are. Not
1101
+ // while it is being edited, though — redrawing the pane there would take away what you have
1102
+ // typed. The save is the one that finds out, and it says so instead of overwriting.
1103
+ if (!editing && open && changed.has(open.path)) {
697
1104
  const f = await api(`/api/file?${qs({ path: open.path })}`)
698
1105
  if (f.mtime !== open.mtime) await show(open.path, { keepScroll: true })
699
1106
  }
@@ -716,7 +1123,7 @@ function lookInside() {
716
1123
  }
717
1124
  lookInside.timer = setTimeout(async () => {
718
1125
  try {
719
- const { hits } = await api(`/api/files?${qs({ only: 'text', q })}`)
1126
+ const { hits } = await api(`/api/files?${qs({ only: 'text', q, nerd: nerd ? '1' : '0' })}`)
720
1127
  if (q !== $('#q').value.trim()) return
721
1128
  inText = hits
722
1129
  paint()
@@ -731,6 +1138,19 @@ function keys() {
731
1138
  // With the paste dialog open the keys belong to it: `/` must not jump into the search while
732
1139
  // you are typing, and Esc is `<dialog>`'s own business.
733
1140
  if ($('#paste').open) return
1141
+ // While a file is being written the page's shortcuts step aside altogether: `/` is a
1142
+ // character, the arrows move the cursor, and Escape must not throw the work away by accident.
1143
+ // The one that is added is the one every editor has.
1144
+ if (editing) {
1145
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
1146
+ e.preventDefault()
1147
+ saveEdit()
1148
+ }
1149
+ return
1150
+ }
1151
+ // The box that searches inside a tree owns its keys too: it is a text field, and the arrows
1152
+ // belong to the cursor in it.
1153
+ if (document.activeElement?.id === 'jfind') return
734
1154
  const typing = document.activeElement === $('#q')
735
1155
  // Ctrl/Cmd+P is already the browser's print: here it is enough not to get in its way.
736
1156
  if (e.key === '/' && !typing) {
@@ -761,14 +1181,15 @@ function keys() {
761
1181
  }
762
1182
  if (e.key === 'Enter' && rows[active]) {
763
1183
  e.preventDefault()
764
- show(rows[active].p)
1184
+ const row = rows[active]
1185
+ row.dir ? enterDir(row.dir) : show(row.p)
765
1186
  }
766
1187
  }
767
1188
  }
768
1189
 
769
1190
  async function boot() {
770
1191
  if (!REPO) {
771
- document.body.innerHTML = '<p class="blank">No repository. Open this from a card on the dashboard.</p>'
1192
+ document.body.innerHTML = '<p class="blank">No repository. Open this from a story on the dashboard.</p>'
772
1193
  return
773
1194
  }
774
1195
  split(Number(localStorage.getItem('k0-files-split')) || 28)
@@ -793,6 +1214,9 @@ async function boot() {
793
1214
  openPaste(t)
794
1215
  })
795
1216
 
1217
+ markNerd()
1218
+ $('#nerd').onclick = () => setNerd(!nerd)
1219
+
796
1220
  $('#paste-open').onclick = () => openPaste()
797
1221
  $('#p-close').onclick = () => $('#paste').close()
798
1222
  $('#p-cancel').onclick = () => $('#paste').close()
@@ -821,8 +1245,18 @@ async function boot() {
821
1245
  opened.has(k) ? opened.delete(k) : opened.add(k)
822
1246
  return paint()
823
1247
  }
1248
+ if (e.target.closest('#show-config')) return setNerd(true)
824
1249
  if (e.target.closest('#paste-clear')) return clearScope()
825
1250
  if (e.target.closest('#paste-again')) return openPaste()
1251
+ if (e.target.closest('#dir-open')) return void reveal(scope.dir)
1252
+
1253
+ // A folder, in the list or in the trail above it. Empty means the repository itself, which is
1254
+ // the way back out to the whole listing.
1255
+ const into = e.target.closest('[data-dir]')
1256
+ if (into && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
1257
+ e.preventDefault()
1258
+ return into.dataset.dir ? enterDir(into.dataset.dir) : clearScope()
1259
+ }
826
1260
 
827
1261
  const row = e.target.closest('.row')
828
1262
  if (!row || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return
@@ -844,7 +1278,22 @@ async function boot() {
844
1278
  // are rebuilt on every opening: the click is listened for once, here.
845
1279
  $('#doc').onclick = (e) => {
846
1280
  const act = e.target.closest('.crumb button[data-act]')
847
- if (act) return void (act.dataset.act === 'print' ? window.print() : savePdf())
1281
+ if (act) {
1282
+ const what = act.dataset.act
1283
+ if (what === 'print') return void window.print()
1284
+ if (what === 'pdf') return void savePdf()
1285
+ if (what === 'reveal') return void reveal(open.path)
1286
+ if (what === 'edit') return void startEdit()
1287
+ if (what === 'save') return void saveEdit()
1288
+ if (what === 'cancel') return void cancelEdit()
1289
+ return
1290
+ }
1291
+ // A piece of the path above the document: it opens the folder it names, in the list.
1292
+ const into = e.target.closest('.crumb a[data-dir]')
1293
+ if (into && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
1294
+ e.preventDefault()
1295
+ return void enterDir(into.dataset.dir)
1296
+ }
848
1297
  // File names found in the text open a new tab: they are real links, and nothing is intercepted
849
1298
  // here — the browser deals with it. References written by hand inside the document instead stay
850
1299
  // as they were, and open here.
@@ -854,6 +1303,11 @@ async function boot() {
854
1303
  e.preventDefault()
855
1304
  show(new URLSearchParams(a.getAttribute('href').slice(1)).get('f'))
856
1305
  }
1306
+ // Searching inside a tree: it runs here, on the data, because the browser's own find does not
1307
+ // look inside a branch that is closed.
1308
+ $('#doc').addEventListener('input', (e) => {
1309
+ if (e.target.id === 'jfind') findInJson()
1310
+ })
857
1311
 
858
1312
  await loadAll()
859
1313
  // A directory named inside a document opens a page of its own: the listing starts already
@@ -869,9 +1323,13 @@ async function boot() {
869
1323
  // When it is the server printing us, the page has nothing to do but show itself: interrogating
870
1324
  // git every three seconds while a sheet is being waited for is of no use to anybody.
871
1325
  if (HEADLESS) return
872
- setInterval(poll, 3000)
1326
+ // And only while the tab is in front of you: both of these run git, and a viewer left open in a
1327
+ // window you are not in has nothing to show anybody. See `whileVisible`.
1328
+ // Ten seconds, not three. Every round of this runs two git processes against the repository you
1329
+ // are reading, and you are reading it, not watching a counter for the moment a file changes.
1330
+ whileVisible(poll, 10000)
873
1331
  // The full listing ages slowly, but the dates do not: half a minute is enough.
874
- setInterval(() => loadAll().catch(() => {}), 30000)
1332
+ whileVisible(() => loadAll().catch(() => {}), 30000)
875
1333
  }
876
1334
 
877
1335
  boot()