k0-board 0.1.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 (67) hide show
  1. package/.claude/skills/k0-import/SKILL.md +83 -0
  2. package/CHANGELOG.md +57 -0
  3. package/LICENSE +21 -0
  4. package/README.md +688 -0
  5. package/bin/k0-board.js +456 -0
  6. package/bin/prompt.js +41 -0
  7. package/menubar/Info.plist +25 -0
  8. package/menubar/K0MenuBar.swift +561 -0
  9. package/menubar/build.sh +28 -0
  10. package/package.json +45 -0
  11. package/platform/contract.js +134 -0
  12. package/platform/darwin/extras.js +254 -0
  13. package/platform/darwin/index.js +24 -0
  14. package/platform/darwin/metrics.js +59 -0
  15. package/platform/darwin/power.js +133 -0
  16. package/platform/darwin/service.js +143 -0
  17. package/platform/darwin/shell.js +59 -0
  18. package/platform/darwin/terminal.js +324 -0
  19. package/platform/index.js +97 -0
  20. package/platform/linux/index.js +60 -0
  21. package/platform/linux/metrics.js +88 -0
  22. package/platform/linux/power.js +137 -0
  23. package/platform/linux/service.js +111 -0
  24. package/platform/linux/shell.js +97 -0
  25. package/platform/linux/tray.py +229 -0
  26. package/platform/shared/command.js +26 -0
  27. package/platform/shared/ps.js +40 -0
  28. package/platform/shared/run.js +99 -0
  29. package/platform/shared/tmux.js +242 -0
  30. package/platform/win32/index.js +53 -0
  31. package/platform/win32/metrics.js +72 -0
  32. package/platform/win32/power.js +109 -0
  33. package/platform/win32/powershell.js +44 -0
  34. package/platform/win32/service.js +111 -0
  35. package/platform/win32/shell.js +64 -0
  36. package/platform/win32/terminal.js +209 -0
  37. package/platform/win32/tray.ps1 +159 -0
  38. package/server/db.js +262 -0
  39. package/server/files.js +488 -0
  40. package/server/git.js +157 -0
  41. package/server/index.js +636 -0
  42. package/server/launcher.js +121 -0
  43. package/server/machine.js +213 -0
  44. package/server/mode.js +182 -0
  45. package/server/paths.js +67 -0
  46. package/server/pdf.js +110 -0
  47. package/server/projects.js +152 -0
  48. package/server/sessions.js +341 -0
  49. package/server/watcher.js +200 -0
  50. package/web/base.css +134 -0
  51. package/web/board.css +561 -0
  52. package/web/board.js +777 -0
  53. package/web/favicon.js +41 -0
  54. package/web/favicon.svg +8 -0
  55. package/web/files.css +535 -0
  56. package/web/files.html +63 -0
  57. package/web/files.js +877 -0
  58. package/web/fonts/caveat-latin-ext.woff2 +0 -0
  59. package/web/fonts/caveat-latin.woff2 +0 -0
  60. package/web/fuzzy.js +57 -0
  61. package/web/index.html +112 -0
  62. package/web/md.js +238 -0
  63. package/web/mentions.js +188 -0
  64. package/web/refs.js +176 -0
  65. package/web/title.js +26 -0
  66. package/web/units.js +33 -0
  67. package/web/view.js +248 -0
package/web/files.js ADDED
@@ -0,0 +1,877 @@
1
+ import { render, esc, matter, titleOf } from '/md.js'
2
+ import { score } from '/fuzzy.js'
3
+ import { mentions, countIn } from '/mentions.js'
4
+ import { resolveRel, index as refIndexOf, resolve as resolveRef, candidates, worth, trim, TOKEN_SRC } from '/refs.js'
5
+ import { bytes } from '/units.js'
6
+ import { setFavicon } from '/favicon.js'
7
+
8
+ // ── The viewer ────────────────────────────────────────────────────────
9
+ // 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.
11
+ //
12
+ // There is no index: the listing arrives from the server ready-made and the search runs here, on
13
+ // the names. First the files the session touched, then the recently modified ones — which is also
14
+ // the only group you can have where there is no git — and below that everything else, which is
15
+ // what you need when the one you are after is not among the first.
16
+ //
17
+ // Then there is the other road, the one behind the lens: instead of searching for a file you
18
+ // paste the piece of conversation that names them, and the listing narrows to those. It fits what
19
+ // actually happens — a session ends, the summary says what it touched, and the files worth
20
+ // rereading are already written in there.
21
+
22
+ const $ = (s) => document.querySelector(s)
23
+ const params = new URLSearchParams(location.search)
24
+ const REPO = params.get('repo') || ''
25
+ const CARD = params.get('card') || ''
26
+ /** Nobody is watching: the page is open only to be printed into a PDF. */
27
+ const HEADLESS = params.has('pdf')
28
+
29
+ const api = async (url, opts) => {
30
+ const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
31
+ const body = await r.json()
32
+ if (!r.ok) throw new Error(body.error || 'Something went wrong')
33
+ return body
34
+ }
35
+
36
+ const qs = (extra = {}) =>
37
+ new URLSearchParams({ repo: REPO, ...(CARD ? { card: CARD } : {}), ...extra }).toString()
38
+
39
+ /** The address of a file's real bytes: images and PDFs come through here. */
40
+ const rawUrl = (p) => `/api/file/raw?${qs({ path: p })}`
41
+ /** The same bytes, but as an attachment: the button that downloads the file as it is. */
42
+ const dlUrl = (p) => `/api/file/raw?${qs({ path: p, dl: '1' })}`
43
+ /** The document laid out on paper, made by the server. */
44
+ const pdfUrl = (p) => `/api/file/pdf?${qs({ path: p })}`
45
+ /**
46
+ * A page served with its real path, rather than the one on the end of the address: only that way
47
+ * do the stylesheet next to it and the images in the directory below — which inside the page are
48
+ * written as relative paths — end up where they really are.
49
+ */
50
+ const siteUrl = (p) =>
51
+ `/api/site/${encodeURIComponent(REPO)}/${p.split('/').map(encodeURIComponent).join('/')}`
52
+ /** The viewer's address for another file, which is also a real link: it can be opened separately. */
53
+ const viewUrl = (p) => `?${qs({ f: p })}`
54
+
55
+ let all = [] // every file: { p, m, s }
56
+ let changed = new Set() // the ones touched right now
57
+ let known = new Set() // the same paths as `all`, so a new one gets noticed
58
+ let inText = [] // what was found inside the files, for the search running now
59
+ let rows = [] // the rows on screen right now, in the order they are seen
60
+ let active = -1 // where you are with the arrow keys
61
+ let open = null // { path, mtime }
62
+ // What is narrowing the listing, if anything is: a pasted piece of chat (`{ kind: 'text', text }`)
63
+ // or a directory (`{ kind: 'dir', dir }`). They are two different roads to the same place — a
64
+ // slice of the listing with a label at the top and a way out — so it is one piece of state.
65
+ let scope = null
66
+ let opened = new Set() // rows opened by hand: directories, and names that fit several files
67
+
68
+ // ── The list on the left ──────────────────────────────────────────────
69
+ const dirOf = (p) => p.slice(0, p.lastIndexOf('/') + 1).replace(/\/$/, '')
70
+ const nameOf = (p) => p.slice(p.lastIndexOf('/') + 1)
71
+
72
+ function since(ms) {
73
+ const m = Math.floor((Date.now() - ms) / 60000)
74
+ if (m < 1) return 'now'
75
+ if (m < 60) return `${m} min`
76
+ const h = Math.floor(m / 60)
77
+ if (h < 24) return `${h} h`
78
+ const d = Math.floor(h / 24)
79
+ return d === 1 ? 'yesterday' : `${d} days`
80
+ }
81
+
82
+ /**
83
+ * The line found inside the file, with the word highlighted. The positions are worked out by the
84
+ * server on the real text — accents included — so there is nothing to guess here.
85
+ */
86
+ function quoted(h) {
87
+ if (!h.len) return esc(h.line)
88
+ return (
89
+ esc(h.line.slice(0, h.at)) +
90
+ `<mark>${esc(h.line.slice(h.at, h.at + h.len))}</mark>` +
91
+ esc(h.line.slice(h.at + h.len))
92
+ )
93
+ }
94
+
95
+ /**
96
+ * The three groups. While searching they disappear and what is left is the names closest to what
97
+ * you typed, and below them what can only be found inside the files.
98
+ */
99
+ function groups() {
100
+ const q = $('#q').value.trim()
101
+ if (q) {
102
+ const hits = []
103
+ for (const f of all) {
104
+ const s = score(f.p, q)
105
+ if (s >= 0) hits.push({ f, s })
106
+ }
107
+ hits.sort((a, b) => b.s - a.s)
108
+ const byName = hits.slice(0, 300).map((h) => h.f)
109
+ // What can only be found by opening the file, and that you would never have guessed from the
110
+ // name. Anything already among the names is not repeated.
111
+ const found = new Set(byName.map((f) => f.p))
112
+ const byText = inText.filter((h) => !found.has(h.p))
113
+ return [
114
+ { title: 'Names', files: byName },
115
+ { title: 'In the text', files: byText },
116
+ ].filter((g) => g.files.length)
117
+ }
118
+
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)
121
+ return [
122
+ { title: 'Changed', files: touched },
123
+ { title: 'Recent', files: rest.slice(0, 30) },
124
+ { title: 'All files', files: rest.slice(30).sort((a, b) => a.p.localeCompare(b.p)) },
125
+ ].filter((g) => g.files.length)
126
+ }
127
+
128
+ // ── The files a piece of chat names ───────────────────────────────────
129
+ /**
130
+ * What the pasted text names, recomputed only when it has to be: `paint()` runs as often as every
131
+ * three seconds while a session is working, and redoing the sums every round would be wasted
132
+ * work. The file listing is a new array on every re-read, and that is signal enough that
133
+ * something changed.
134
+ */
135
+ let memo = { text: null, from: null, out: null }
136
+ function mentioned() {
137
+ if (memo.text !== scope.text || memo.from !== all) {
138
+ memo = { text: scope.text, from: all, out: mentions(scope.text, all) }
139
+ }
140
+ return memo.out
141
+ }
142
+
143
+ /** How many files the text touches, open or closed: the number must not jump about. */
144
+ const howMany = (out) => countIn(out.named) + countIn(out.maybe)
145
+
146
+ /**
147
+ * One file row, the same in every group.
148
+ *
149
+ * It also records the row in `rows`, which is the order the arrow keys meet them in: writing it
150
+ * and counting it in the same place is the only way the two cannot drift apart when a directory
151
+ * opens or closes.
152
+ */
153
+ function rowHtml(f) {
154
+ rows.push(f)
155
+ const dir = dirOf(f.p)
156
+ return `<a class="row${f.p === open?.path ? ' on' : ''}${changed.has(f.p) ? ' hot' : ''}" href="${esc(
157
+ 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>`
161
+ }
162
+
163
+ /**
164
+ * An entry found in the text. Two cases, one gesture: a directory opens and shows the files it
165
+ * holds; a name that fits several files — eight `README.md` — shows the most recent one and opens
166
+ * the others underneath. Both are clicked open and clicked shut, with nothing reloaded.
167
+ */
168
+ function entryHtml(e) {
169
+ const key = e.dir || e.file.p
170
+ const isOpen = opened.has(key)
171
+ const fold = (label) =>
172
+ `<button class="more" type="button" data-fold="${esc(key)}" aria-expanded="${isOpen}">${label}</button>`
173
+
174
+ if (e.dir) {
175
+ const up = dirOf(e.dir)
176
+ const n = e.files.length
177
+ const head = `<button class="row fold${isOpen ? ' open' : ''}" type="button" data-fold="${esc(
178
+ key
179
+ )}" aria-expanded="${isOpen}"><b>${esc(nameOf(e.dir))}/</b>${
180
+ up ? `<i>${esc(up)}</i>` : ''
181
+ }<em>${n} file${n === 1 ? '' : 's'}</em></button>`
182
+ return head + (isOpen ? `<div class="kids">${e.files.map((f) => rowHtml(f)).join('')}</div>` : '')
183
+ }
184
+
185
+ const main = rowHtml(e.file)
186
+ if (!e.more.length) return main
187
+ return (
188
+ main +
189
+ fold(isOpen ? 'fewer' : `${e.more.length} more`) +
190
+ (isOpen ? `<div class="kids">${e.more.map((f) => rowHtml(f)).join('')}</div>` : '')
191
+ )
192
+ }
193
+
194
+ /** 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)
196
+
197
+ /**
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.
201
+ */
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>`
208
+ }
209
+
210
+ /** The listing when a directory is what governs it. */
211
+ function dirHtml() {
212
+ 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
+ )
218
+ }
219
+
220
+ /** The listing when a pasted text is what governs it. */
221
+ function pastedHtml() {
222
+ const out = mentioned()
223
+ const n = howMany(out)
224
+ // Where you are stays visible at the top: the number, the way out, and the text itself one
225
+ // click away so it can be corrected without pasting it again.
226
+ let html = chipHtml(`${n} file${n === 1 ? '' : 's'} from your text`, true)
227
+ if (out.named.length) {
228
+ html += `<h2>From your text<small>${countIn(out.named)}</small></h2>`
229
+ html += out.named.map(entryHtml).join('')
230
+ }
231
+ if (out.maybe.length) {
232
+ // It arrives closed: these are guesses, and they must not cover what is written in full.
233
+ const isOpen = opened.has('maybe')
234
+ html += `<h2 class="fold${isOpen ? ' open' : ''}" data-fold="maybe" role="button" tabindex="0" aria-expanded="${isOpen}">Maybe these<small>${countIn(
235
+ out.maybe
236
+ )}</small></h2>`
237
+ if (isOpen) html += out.maybe.map(entryHtml).join('')
238
+ }
239
+ if (out.missing.length) {
240
+ html += `<p class="missing">not in this repository: ${esc(out.missing.join(', '))}</p>`
241
+ }
242
+ return html
243
+ }
244
+
245
+ /** The usual listing: the groups, and the files under them. */
246
+ 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('')
253
+ }
254
+
255
+ function paint() {
256
+ const list = $('#list')
257
+ // The listing redraws even while you are scrolling it — every three seconds, if a session is
258
+ // working. Putting it back where it was is the difference between a live list and one that
259
+ // slips out of your hands.
260
+ const scroll = list.scrollTop
261
+ rows = []
262
+ const text = scope?.kind === 'text'
263
+ const html = !scope ? plainHtml() : text ? pastedHtml() : dirHtml()
264
+ if (active >= rows.length) active = rows.length - 1
265
+
266
+ // Empty does not mean "no rows on screen": with a pasted text the guesses arrive closed, and a
267
+ // result made only of those has no rows to show while still having found everything it should.
268
+
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
281
+
282
+ list.scrollTop = scroll
283
+ $('#count').textContent = scope
284
+ ? `${found} of ${all.length}`
285
+ : `${all.length} file${all.length === 1 ? '' : 's'}`
286
+ mark()
287
+ }
288
+
289
+ /**
290
+ * The arrow-key highlight, which is a different thing from the open file. Links only: the rows
291
+ * that open a directory are buttons, and the arrows have nothing to do with them.
292
+ */
293
+ function mark() {
294
+ const els = [...$('#list').querySelectorAll('a.row')]
295
+ els.forEach((el, i) => el.classList.toggle('active', i === active))
296
+ if (active >= 0) els[active]?.scrollIntoView({ block: 'nearest' })
297
+ }
298
+
299
+ // ── In and out of a pasted text ───────────────────────────────────────
300
+ /** Opens the paste dialog, with the text from before or the one that just arrived. */
301
+ function openPaste(text) {
302
+ const box = $('#p-text')
303
+ box.value = text ?? (scope?.kind === 'text' ? scope.text : '')
304
+ $('#p-note').hidden = true
305
+ $('#paste').showModal()
306
+ box.focus()
307
+ box.setSelectionRange(box.value.length, box.value.length)
308
+ }
309
+
310
+ /**
311
+ * Takes the text and narrows the listing to what it names. If it names nothing the dialog does
312
+ * **not** close and the listing is not thrown away: it says what happened, so the text can be
313
+ * corrected where it is instead of being pasted again.
314
+ */
315
+ function applyPaste(text) {
316
+ const t = String(text ?? '').trim()
317
+ const out = mentions(t, all)
318
+ if (!howMany(out)) {
319
+ const note = $('#p-note')
320
+ note.hidden = false
321
+ note.textContent = out.missing.length
322
+ ? `Not in this repository: ${out.missing.join(', ')}. Maybe it is another one.`
323
+ : 'No file of this repository is named in that text.'
324
+ return
325
+ }
326
+ scope = { kind: 'text', text: t }
327
+ memo = { text: t, from: all, out }
328
+ opened = new Set()
329
+ active = -1
330
+ $('#q').value = ''
331
+ inText = []
332
+ $('#paste').close()
333
+ paint()
334
+ // The first one named opens by itself: when the text names one — the commonest case — it is the
335
+ // only thing you wanted, and clicking it would be one gesture too many.
336
+ const first = out.named[0] || out.maybe[0]
337
+ show(first.files ? first.files[0].p : first.file.p)
338
+ }
339
+
340
+ /**
341
+ * Back to the whole listing. The file open on the right stays where it is.
342
+ *
343
+ * A directory is also in the address, because it is a page that can be reloaded and sent: on the
344
+ * way out it has to be taken off, otherwise a reload would put you back inside.
345
+ */
346
+ function clearScope() {
347
+ const wasDir = scope?.kind === 'dir'
348
+ scope = null
349
+ opened = new Set()
350
+ active = -1
351
+ paint()
352
+ if (wasDir) history.replaceState(null, '', open ? viewUrl(open.path) : `?${qs()}`)
353
+ }
354
+
355
+ // ── File names written inside a document ──────────────────────────────
356
+ // Documents point at each other constantly, and until now those were dead references: to open one
357
+ // you had to go back to the listing and hunt by hand. The rules about what points at what live in
358
+ // `refs.js`, which knows nothing about pages; here there is only the browser's part of the job —
359
+ // asking the server about what the listing does not know, and stitching the links.
360
+
361
+ /** How many paths are asked of the server for one document. */
362
+ const MAX_ASK = 60
363
+
364
+ let refMemo = { from: null, ix: null }
365
+ const refIndex = () => {
366
+ if (refMemo.from !== all) refMemo = { from: all, ix: refIndexOf(all) }
367
+ return refMemo.ix
368
+ }
369
+
370
+ /** A directory's address: it is a real page, it reloads and it can be sent. */
371
+ const dirUrl = (d) => `?${qs({ dir: d })}`
372
+
373
+ /**
374
+ * What this document names that can really be opened: from a written name to an address. Almost
375
+ * 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.
378
+ */
379
+ async function refsIn(docPath, text) {
380
+ const ix = refIndex()
381
+ const map = new Map()
382
+ const pending = []
383
+ for (const t of candidates(text)) {
384
+ const r = resolveRef(t, docPath, ix, true)
385
+ if (!r) continue
386
+ // Inside backticks a single word gets through; in the middle of a sentence it does not, or in
387
+ // a text about onboarding the words "onboarding" and "search" would become links at random.
388
+ const both = worth(t, false)
389
+ if (r.path) map.set(t, { url: viewUrl(r.path), prose: both })
390
+ else if (r.dir) map.set(t, { url: dirUrl(r.dir), prose: both })
391
+ else pending.push([t, r.ask, both])
392
+ }
393
+ if (!pending.length) return map
394
+
395
+ const paths = [...new Set(pending.flatMap(([, ask]) => ask))].slice(0, MAX_ASK)
396
+ let there = new Set()
397
+ try {
398
+ const r = await api('/api/files/exist', { method: 'POST', body: JSON.stringify({ repo: REPO, paths }) })
399
+ there = new Set(r.paths)
400
+ } catch {
401
+ /* the server is not answering: what is left is the links that resolved on their own */
402
+ }
403
+ for (const [t, ask, both] of pending) {
404
+ const hit = ask.find((x) => there.has(x))
405
+ if (hit) map.set(t, { url: viewUrl(hit), prose: both })
406
+ }
407
+ return map
408
+ }
409
+
410
+ /** A clickable name: grey as before, and it opens in a new tab. */
411
+ function refLink(url, label) {
412
+ const a = document.createElement('a')
413
+ a.className = 'ref'
414
+ a.href = url
415
+ a.target = '_blank'
416
+ a.title = 'Open in a new tab'
417
+ a.textContent = label
418
+ return a
419
+ }
420
+
421
+ /**
422
+ * Stitches the links into the document that has already been laid out.
423
+ *
424
+ * It happens here and not in the layout engine because `md.js` is a markdown layout engine and
425
+ * nothing else: it does not know a repository exists, and it must not. And working on the real
426
+ * document rather than on the text gives away for free the things that regular expressions would
427
+ * charge dearly for — code blocks, links that are already there, and pieces inside attributes
428
+ * stay out without having to be excluded by hand.
429
+ */
430
+ function linkRefs(root, map) {
431
+ if (!map.size) return
432
+
433
+ // Code spans first: `name.md` inside backticks is how documents cite each other.
434
+ for (const el of root.querySelectorAll('code')) {
435
+ if (el.closest('pre, a') || el.querySelector('a')) continue
436
+ const hit = map.get(el.textContent.trim())
437
+ if (!hit) continue
438
+ const label = el.textContent
439
+ el.textContent = ''
440
+ el.append(refLink(hit.url, label))
441
+ }
442
+
443
+ // Then the names written in the middle of sentences. Only real pieces of text are looked at:
444
+ // inside a link or a code span it does not go.
445
+ const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
446
+ acceptNode: (n) =>
447
+ n.parentElement?.closest('a, code, pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT,
448
+ })
449
+ const found = []
450
+ for (let n = walk.nextNode(); n; n = walk.nextNode()) found.push(n)
451
+ for (const n of found) linkRefsInText(n, map)
452
+ }
453
+
454
+ /** Splits a piece of text where it names a file, and puts the link in. */
455
+ function linkRefsInText(node, map) {
456
+ const text = node.nodeValue
457
+ const re = new RegExp(TOKEN_SRC, 'g')
458
+ const frag = document.createDocumentFragment()
459
+ let at = 0
460
+ for (const m of text.matchAll(re)) {
461
+ const t = trim(m[0])
462
+ const hit = t && map.get(t)
463
+ if (!hit?.prose) continue
464
+ frag.append(text.slice(at, m.index), refLink(hit.url, t))
465
+ at = m.index + t.length
466
+ }
467
+ if (!at) return
468
+ frag.append(text.slice(at))
469
+ node.replaceWith(frag)
470
+ }
471
+
472
+ // ── The document on the right ─────────────────────────────────────────
473
+ async function show(p, { keepScroll = false } = {}) {
474
+ const doc = $('#doc')
475
+ const scroll = keepScroll ? doc.scrollTop : 0
476
+ let file
477
+ try {
478
+ file = await api(`/api/file?${qs({ path: p })}`)
479
+ } catch (e) {
480
+ doc.innerHTML = `<p class="blank">${esc(e.message)}</p>`
481
+ return
482
+ }
483
+ open = { path: p, mtime: file.mtime }
484
+
485
+ // What this document names, before laying it out: the layout engine is synchronous, and for the
486
+ // names the listing does not have there is a question to ask the server.
487
+ const refs = file.kind === 'markdown' ? await refsIn(p, file.text) : new Map()
488
+
489
+ const crumb = crumbs(p, file)
490
+ let head = ''
491
+ let body
492
+ if (file.kind === 'markdown') {
493
+ // Relative references point at real things: images at the bytes, links at the other documents.
494
+ // Without this a document full of references would be a dead end.
495
+ const link = (t, kind) => {
496
+ const abs = resolveRel(p, t)
497
+ if (!abs) return '#'
498
+ return kind === 'image' ? rawUrl(abs) : viewUrl(abs)
499
+ }
500
+ head = plate(matter(file.text).data)
501
+ body = `<article class="md">${render(file.text, link)}</article>`
502
+ } else if (file.kind === 'text') {
503
+ body = `<pre class="plain">${esc(file.text)}</pre>`
504
+ } else if (file.kind === 'image') {
505
+ body = `<div class="media"><img src="${esc(rawUrl(p))}" alt="${esc(nameOf(p))}"></div>`
506
+ } else if (file.kind === 'pdf') {
507
+ body = `<iframe class="pdf" src="${esc(rawUrl(p))}" title="${esc(nameOf(p))}"></iframe>`
508
+ } else if (file.kind === 'html') {
509
+ // A page is looked at, like the PDF above. Shut in a frame with no permissions: from in there
510
+ // it executes nothing and does not reach k0 — the server says so anyway, but saying it twice
511
+ // costs nothing and still holds if the frame ever changes.
512
+ body = `<iframe class="page" sandbox src="${esc(siteUrl(p))}" title="${esc(nameOf(p))}"></iframe>`
513
+ } 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>`
515
+ }
516
+
517
+ const cut = file.truncated ? '<p class="cut">Only the first 2 MB are shown.</p>' : ''
518
+ doc.innerHTML = crumb + head + body + cut
519
+ linkRefs(doc.querySelector('.md') ?? doc, refs)
520
+ doc.scrollTop = scroll
521
+ $('#reveal')?.addEventListener('click', reveal)
522
+
523
+ // The name of the page is the document's title: it is what the browser offers as the file name
524
+ // when you save to PDF, and it is worth far more than "k0 — Files".
525
+ const named = file.kind === 'markdown' ? titleOf(matter(file.text).data) : null
526
+ document.title = named || nameOf(p)
527
+
528
+ // The address follows what you are looking at: reload and you stay on the file, and the link can
529
+ // be sent to somebody else. Without adding an entry to the history. If you are inside a
530
+ // directory that is written down too: otherwise reloading would give you the right file but the
531
+ // whole listing.
532
+ history.replaceState(null, '', scope?.kind === 'dir' ? `?${qs({ dir: scope.dir, f: p })}` : viewUrl(p))
533
+ paint()
534
+ }
535
+
536
+ function crumbs(p, file) {
537
+ const parts = p.split('/')
538
+ const last = parts.pop()
539
+ const road = parts.map((x) => `<span>${esc(x)}</span>`).join('<i>/</i>')
540
+ 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>`
542
+ }
543
+
544
+ /** Drawn by hand like the others in the house: same stroke, same grid. */
545
+ const ICON = {
546
+ print:
547
+ '<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
+ 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>',
549
+ }
550
+
551
+ /**
552
+ * What you can do with the file you are looking at, at the end of its path row. It lives here and
553
+ * not in the bar at the top because these are things about this document, not about the search or
554
+ * the repository.
555
+ *
556
+ * Not every file offers the same things: taking the file away as it is always makes sense, but
557
+ * printing a `.docx` that cannot even be shown here does not, and asking for the PDF of something
558
+ * that is already a PDF does not either — that one is simply downloaded.
559
+ */
560
+ function acts(p, kind) {
561
+ const paper = kind === 'markdown' || kind === 'text'
562
+ // In capitals and without the dot, like the PDF next to it: they are two buttons that do the
563
+ // same thing, and writing them two different ways made them look like two different things.
564
+ const ext = /\.([^./]+)$/.exec(p)?.[1].toUpperCase() || 'FILE'
565
+ const out = []
566
+ if (paper || kind === 'image') {
567
+ out.push(`<button class="act" data-act="print" type="button" title="Print (⌘P)" aria-label="Print">${ICON.print}</button>`)
568
+ }
569
+ out.push(
570
+ `<a class="act" href="${esc(dlUrl(p))}" download="${esc(nameOf(p))}" title="Download the file as it is on disk">${ICON.down}${esc(ext)}</a>`
571
+ )
572
+ if (paper) {
573
+ out.push(`<button class="act" data-act="pdf" type="button" title="Download it already laid out on paper">${ICON.down}PDF</button>`)
574
+ }
575
+ return `<span class="acts">${out.join('')}</span>`
576
+ }
577
+
578
+ /**
579
+ * The PDF is laid out by the server, and it takes a few seconds: here we wait and say so, and when
580
+ * it arrives we hand it to the browser like any other download. It is not the print dialog — the
581
+ * sheet comes out with the right margin already and without the browser's additions.
582
+ */
583
+ let saving = false
584
+ async function savePdf() {
585
+ if (!open || saving) return
586
+ saving = true
587
+ toast('Making the PDF…')
588
+ try {
589
+ const r = await fetch(pdfUrl(open.path))
590
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'The PDF did not come out')
591
+ const url = URL.createObjectURL(await r.blob())
592
+ const a = document.createElement('a')
593
+ a.href = url
594
+ // The name of the page is the document's title; the characters you cannot put in a file name
595
+ // cannot go in here.
596
+ a.download = `${document.title.replace(/[\\/:*?"<>|]+/g, '-')}.pdf`
597
+ a.click()
598
+ URL.revokeObjectURL(url)
599
+ toast('Saved to your downloads.')
600
+ } catch (e) {
601
+ toast(e.message)
602
+ } finally {
603
+ saving = false
604
+ }
605
+ }
606
+
607
+ /**
608
+ * The label at the top of a document: what is written between the two rules. It is not text of
609
+ * the file — it is what the file says it is — so it is read as a label and not as a paragraph,
610
+ * with the title picked out and the rest kept quiet.
611
+ */
612
+ function plate(data) {
613
+ const title = titleOf(data)
614
+ const rest = Object.entries(data).filter(([k, v]) => v && !TITLE_KEYS.has(k))
615
+ if (!title && !rest.length) return ''
616
+ return `<div class="plate">
617
+ ${title ? `<b>${esc(title)}</b>` : ''}
618
+ ${rest.map(([k, v]) => `<span><i>${esc(k)}</i> ${esc(v)}</span>`).join('')}
619
+ </div>`
620
+ }
621
+
622
+ const TITLE_KEYS = new Set(['title', 'titolo', 'name', 'nome'])
623
+
624
+ async function reveal() {
625
+ try {
626
+ await api('/api/file/reveal', { method: 'POST', body: JSON.stringify({ repo: REPO, path: open.path }) })
627
+ } catch (e) {
628
+ toast(e.message)
629
+ }
630
+ }
631
+
632
+ function toast(msg) {
633
+ const t = $('#toast')
634
+ t.textContent = msg
635
+ t.classList.add('show')
636
+ clearTimeout(toast.timer)
637
+ toast.timer = setTimeout(() => t.classList.remove('show'), 4000)
638
+ }
639
+
640
+ // ── The divider ───────────────────────────────────────────────────────
641
+ /** How much room the left column takes, as a percentage. It remembers. */
642
+ function split(pct) {
643
+ const v = Math.min(60, Math.max(15, pct))
644
+ $('#split').style.setProperty('--left', `${v}%`)
645
+ localStorage.setItem('k0-files-split', String(v))
646
+ }
647
+
648
+ function grip() {
649
+ const g = $('#grip')
650
+ g.onpointerdown = (e) => {
651
+ e.preventDefault()
652
+ g.setPointerCapture(e.pointerId)
653
+ const move = (ev) => split((ev.clientX / window.innerWidth) * 100)
654
+ const up = () => {
655
+ g.removeEventListener('pointermove', move)
656
+ g.removeEventListener('pointerup', up)
657
+ }
658
+ g.addEventListener('pointermove', move)
659
+ g.addEventListener('pointerup', up)
660
+ }
661
+ // Double click: back to the starting size, without having to find it by hand.
662
+ g.ondblclick = () => split(28)
663
+ }
664
+
665
+ // ── The round ─────────────────────────────────────────────────────────
666
+ async function loadAll() {
667
+ const data = await api(`/api/files?${qs()}`)
668
+ all = data.files
669
+ known = new Set(all.map((f) => f.p))
670
+ changed = new Set(data.changed)
671
+ document.title = `k0 — ${data.name}`
672
+ $('#repo').textContent = data.name
673
+ $('#ctx').textContent = data.title ? `· ${data.title}` : data.git ? '' : '· not a git repository'
674
+ if (data.truncated) toast('This repository is huge: the list stops at 20 000 files.')
675
+ paint()
676
+ }
677
+
678
+ /**
679
+ * Every three seconds it asks only what changed: that is the part that moves, and it costs one
680
+ * read of git. The whole listing is big and ages slowly, so it is re-read only now and then — or
681
+ * straight away, if a file turns up that was not there.
682
+ */
683
+ async function poll() {
684
+ try {
685
+ // The icon in the tab is tinted like the one in the menu bar, from here too: you are reading a
686
+ // document while a session is waiting for you.
687
+ api('/api/status').then((s) => setFavicon(s.urgent)).catch(() => {})
688
+
689
+ const { changed: now } = await api(`/api/files?${qs({ only: 'changed' })}`)
690
+ const fresh = now.some((p) => !known.has(p))
691
+ const moved = now.length !== changed.size || now.some((p) => !changed.has(p))
692
+ changed = new Set(now)
693
+ if (fresh) await loadAll()
694
+ 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)) {
697
+ const f = await api(`/api/file?${qs({ path: open.path })}`)
698
+ if (f.mtime !== open.mtime) await show(open.path, { keepScroll: true })
699
+ }
700
+ } catch {
701
+ /* server down or repository gone: we try again next round */
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Searching inside the files goes through the server and reads a few hundred documents: it waits
707
+ * for you to finish typing, and an answer that arrives late for what is in the field now is
708
+ * thrown away.
709
+ */
710
+ function lookInside() {
711
+ clearTimeout(lookInside.timer)
712
+ const q = $('#q').value.trim()
713
+ if (q.length < 2) {
714
+ inText = []
715
+ return
716
+ }
717
+ lookInside.timer = setTimeout(async () => {
718
+ try {
719
+ const { hits } = await api(`/api/files?${qs({ only: 'text', q })}`)
720
+ if (q !== $('#q').value.trim()) return
721
+ inText = hits
722
+ paint()
723
+ } catch {
724
+ /* no results from the text: the names are what is left */
725
+ }
726
+ }, 250)
727
+ }
728
+
729
+ function keys() {
730
+ document.onkeydown = (e) => {
731
+ // With the paste dialog open the keys belong to it: `/` must not jump into the search while
732
+ // you are typing, and Esc is `<dialog>`'s own business.
733
+ if ($('#paste').open) return
734
+ const typing = document.activeElement === $('#q')
735
+ // Ctrl/Cmd+P is already the browser's print: here it is enough not to get in its way.
736
+ if (e.key === '/' && !typing) {
737
+ e.preventDefault()
738
+ $('#q').focus()
739
+ $('#q').select()
740
+ return
741
+ }
742
+ if (e.key === 'Escape') {
743
+ // First you leave the results of a pasted text: it is the narrowest state you can be in,
744
+ // and it is the one you want out of first.
745
+ if (scope) return clearScope()
746
+ if (!$('#q').value) return $('#q').blur()
747
+ $('#q').value = ''
748
+ active = -1
749
+ paint()
750
+ return
751
+ }
752
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
753
+ if (!rows.length) return
754
+ e.preventDefault()
755
+ const down = e.key === 'ArrowDown'
756
+ // The first arrow goes into the list: from the top going down, from the bottom going up.
757
+ if (active < 0) active = down ? 0 : rows.length - 1
758
+ else active = (active + (down ? 1 : rows.length - 1)) % rows.length
759
+ mark()
760
+ return
761
+ }
762
+ if (e.key === 'Enter' && rows[active]) {
763
+ e.preventDefault()
764
+ show(rows[active].p)
765
+ }
766
+ }
767
+ }
768
+
769
+ async function boot() {
770
+ if (!REPO) {
771
+ document.body.innerHTML = '<p class="blank">No repository. Open this from a card on the dashboard.</p>'
772
+ return
773
+ }
774
+ split(Number(localStorage.getItem('k0-files-split')) || 28)
775
+ grip()
776
+ keys()
777
+
778
+ $('#q').oninput = () => {
779
+ // Typing is the other search: you leave the pasted text's results and go back to searching the
780
+ // whole repository. One behaviour at a time.
781
+ scope = null
782
+ active = -1
783
+ paint() // the names are already here: they filter as you type, with nothing to wait for
784
+ lookInside()
785
+ }
786
+ // Pasting a piece of chat in here is not searching: a one-line box would hide it and a search on
787
+ // names would get nothing out of it. Above a line or a handful of words, the text goes where it
788
+ // is meant to go.
789
+ $('#q').addEventListener('paste', (e) => {
790
+ const t = e.clipboardData?.getData('text') ?? ''
791
+ if (!t.includes('\n') && t.trim().length <= 80) return
792
+ e.preventDefault()
793
+ openPaste(t)
794
+ })
795
+
796
+ $('#paste-open').onclick = () => openPaste()
797
+ $('#p-close').onclick = () => $('#paste').close()
798
+ $('#p-cancel').onclick = () => $('#paste').close()
799
+ $('#paste-form').onsubmit = (e) => {
800
+ e.preventDefault()
801
+ applyPaste($('#p-text').value)
802
+ }
803
+ // Enter starts a new line, which in a big box is what it should do. To send it without taking
804
+ // your hands off there is Ctrl/Cmd+Enter, as in every other box that gets sent.
805
+ $('#p-text').onkeydown = (e) => {
806
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
807
+ e.preventDefault()
808
+ applyPaste($('#p-text').value)
809
+ }
810
+ }
811
+
812
+ // An ordinary click: it opens here. With a modifier it does not — it is a real link, and anyone
813
+ // who wants it in another tab has to be able to have it.
814
+ $('#list').onclick = (e) => {
815
+ // The rows that open and close come first: a directory, or a name that fits several files.
816
+ // They do not lead anywhere, they are leafed through in place.
817
+ const fold = e.target.closest('[data-fold]')
818
+ if (fold) {
819
+ e.preventDefault()
820
+ const k = fold.dataset.fold
821
+ opened.has(k) ? opened.delete(k) : opened.add(k)
822
+ return paint()
823
+ }
824
+ if (e.target.closest('#paste-clear')) return clearScope()
825
+ if (e.target.closest('#paste-again')) return openPaste()
826
+
827
+ const row = e.target.closest('.row')
828
+ if (!row || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return
829
+ e.preventDefault()
830
+ show(row.dataset.p)
831
+ }
832
+ // The heading of a collapsible group is a heading, not a button: the keyboard reaches it all the
833
+ // same, and it has to be able to open it.
834
+ $('#list').onkeydown = (e) => {
835
+ if (e.key !== 'Enter' && e.key !== ' ') return
836
+ const fold = e.target.closest?.('h2[data-fold]')
837
+ if (!fold) return
838
+ e.preventDefault()
839
+ e.stopPropagation() // otherwise Enter also reaches below and opens the highlighted row
840
+ opened.has(fold.dataset.fold) ? opened.delete(fold.dataset.fold) : opened.add(fold.dataset.fold)
841
+ paint()
842
+ }
843
+ // The same for the references inside a document — and for the buttons at the top of it, which
844
+ // are rebuilt on every opening: the click is listened for once, here.
845
+ $('#doc').onclick = (e) => {
846
+ const act = e.target.closest('.crumb button[data-act]')
847
+ if (act) return void (act.dataset.act === 'print' ? window.print() : savePdf())
848
+ // File names found in the text open a new tab: they are real links, and nothing is intercepted
849
+ // here — the browser deals with it. References written by hand inside the document instead stay
850
+ // as they were, and open here.
851
+ if (e.target.closest('a.ref')) return
852
+ const a = e.target.closest('a[href^="?"]')
853
+ if (!a || e.metaKey || e.ctrlKey || e.shiftKey) return
854
+ e.preventDefault()
855
+ show(new URLSearchParams(a.getAttribute('href').slice(1)).get('f'))
856
+ }
857
+
858
+ await loadAll()
859
+ // A directory named inside a document opens a page of its own: the listing starts already
860
+ // narrowed to it. It is a real address, so it reloads and it can be sent.
861
+ const dir = params.get('dir')
862
+ if (dir) {
863
+ scope = { kind: 'dir', dir }
864
+ document.title = `${nameOf(dir) || '/'} — ${REPO.split('/').pop()}`
865
+ paint()
866
+ }
867
+ const start = params.get('f')
868
+ if (start) await show(start)
869
+ // When it is the server printing us, the page has nothing to do but show itself: interrogating
870
+ // git every three seconds while a sheet is being waited for is of no use to anybody.
871
+ if (HEADLESS) return
872
+ setInterval(poll, 3000)
873
+ // The full listing ages slowly, but the dates do not: half a minute is enough.
874
+ setInterval(() => loadAll().catch(() => {}), 30000)
875
+ }
876
+
877
+ boot()