k0-board 0.3.0 → 0.4.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.
package/web/files.js CHANGED
@@ -1,5 +1,7 @@
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'
@@ -64,10 +66,55 @@ let open = null // { path, mtime }
64
66
  // slice of the listing with a label at the top and a way out — so it is one piece of state.
65
67
  let scope = null
66
68
  let opened = new Set() // rows opened by hand: directories, and names that fit several files
69
+ // Whether the configuration files are shown as well. It is remembered because it is not a search
70
+ // but a way of working: somebody who wants to see the `.env` today wants to see it tomorrow.
71
+ let nerd = localStorage.getItem('k0-files-nerd') === '1'
72
+ // What this machine can do with a file outside the browser. Until the listing arrives we assume
73
+ // it can, which is what every machine k0 has met so far does.
74
+ let can = { reveal: true, open: true, whyNot: null }
75
+ // The file being written, while it is being written: `{ path, mtime }`. Everything that would
76
+ // redraw the right-hand pane has to stand aside while this is set, or what you have typed goes.
77
+ let editing = null
67
78
 
68
79
  // ── The list on the left ──────────────────────────────────────────────
69
80
  const dirOf = (p) => p.slice(0, p.lastIndexOf('/') + 1).replace(/\/$/, '')
70
81
  const nameOf = (p) => p.slice(p.lastIndexOf('/') + 1)
82
+ const extOf = (p) => (/\.[^./]+$/.exec(nameOf(p))?.[0] ?? '').toLowerCase()
83
+
84
+ // The same rules as the server's, written a second time on purpose: a browser cannot import a
85
+ // module that reads the filesystem, and this decides only what to *offer*. What is really allowed
86
+ // is decided over there, on the file it is about to write. Change one, change the other.
87
+ const ENV_NAME = /^\.env(\..+)?$/
88
+ const CONF_EXT = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
89
+ const NOTE_EXT = new Set(['.md', '.markdown', '.mdx', '.txt'])
90
+ const isConfig = (p) => CONF_EXT.has(extOf(p)) || ENV_NAME.test(nameOf(p))
91
+ const canEdit = (p) => NOTE_EXT.has(extOf(p)) || isConfig(p)
92
+ // The same ceiling the server puts on a save, so the pencil is not offered for a file that would
93
+ // then be refused. Far above anything anybody edits in a browser tab.
94
+ const MAX_EDIT = 1 << 18
95
+
96
+ /** The files on show right now: everything, or everything that is not configuration. */
97
+ const listed = () => (nerd ? all : all.filter((f) => !f.c))
98
+
99
+ /** The switch as it looks. */
100
+ const markNerd = () => {
101
+ $('#nerd').setAttribute('aria-pressed', String(nerd))
102
+ $('#nerd').classList.toggle('on', nerd)
103
+ }
104
+
105
+ /**
106
+ * The switch, and everything that follows from it. The search inside the files has to be asked
107
+ * again straight away: that one runs on the server, and the server was told to look at a smaller
108
+ * set of files than it should now be looking at.
109
+ */
110
+ function setNerd(on) {
111
+ nerd = on
112
+ localStorage.setItem('k0-files-nerd', on ? '1' : '0')
113
+ markNerd()
114
+ active = -1
115
+ paint()
116
+ lookInside()
117
+ }
71
118
 
72
119
  function since(ms) {
73
120
  const m = Math.floor((Date.now() - ms) / 60000)
@@ -98,9 +145,10 @@ function quoted(h) {
98
145
  */
99
146
  function groups() {
100
147
  const q = $('#q').value.trim()
148
+ const here = listed()
101
149
  if (q) {
102
150
  const hits = []
103
- for (const f of all) {
151
+ for (const f of here) {
104
152
  const s = score(f.p, q)
105
153
  if (s >= 0) hits.push({ f, s })
106
154
  }
@@ -116,8 +164,8 @@ function groups() {
116
164
  ].filter((g) => g.files.length)
117
165
  }
118
166
 
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)
167
+ const touched = here.filter((f) => changed.has(f.p)).sort((a, b) => b.m - a.m)
168
+ const rest = here.filter((f) => !changed.has(f.p)).sort((a, b) => b.m - a.m)
121
169
  return [
122
170
  { title: 'Changed', files: touched },
123
171
  { title: 'Recent', files: rest.slice(0, 30) },
@@ -125,6 +173,33 @@ function groups() {
125
173
  ].filter((g) => g.files.length)
126
174
  }
127
175
 
176
+ /**
177
+ * How many configuration files the switch is hiding that would have come **above** everything it
178
+ * is showing.
179
+ *
180
+ * Without this, searching for `.env` with the switch off is the worst kind of answer: eighty files
181
+ * that merely happen to contain those four letters somewhere in their path, and no sign at all
182
+ * that the one you asked for is sitting right there behind a button.
183
+ *
184
+ * "Better than anything visible" and not "matches at all" is the whole rule. The name search
185
+ * matches letters in order, so nearly any word matches something somewhere: counting every hidden
186
+ * match would put this line above almost every search, and a warning that is always there is read
187
+ * as decoration within a day. This way it appears exactly when the answer really is the one being
188
+ * held back.
189
+ */
190
+ function hiddenByTheSwitch(q) {
191
+ if (nerd || !q) return 0
192
+ let best = -1
193
+ const hidden = []
194
+ for (const f of all) {
195
+ const s = score(f.p, q)
196
+ if (s < 0) continue
197
+ if (f.c) hidden.push(s)
198
+ else if (s > best) best = s
199
+ }
200
+ return hidden.filter((s) => s > best).length
201
+ }
202
+
128
203
  // ── The files a piece of chat names ───────────────────────────────────
129
204
  /**
130
205
  * What the pasted text names, recomputed only when it has to be: `paint()` runs as often as every
@@ -153,11 +228,33 @@ const howMany = (out) => countIn(out.named) + countIn(out.maybe)
153
228
  function rowHtml(f) {
154
229
  rows.push(f)
155
230
  const dir = dirOf(f.p)
231
+ // Which letters put this row here — the search only ever looks inside the name, so every
232
+ // position `positions()` hands back already falls inside it; the offset just undoes what it
233
+ // added to make that position mean something against the whole path.
234
+ const at = positions(f.p, $('#q').value.trim())
235
+ const nameAt = dir ? dir.length + 1 : 0
236
+ const name = lit(nameOf(f.p), at?.map((i) => i - nameAt))
237
+ const road = dir ? `<i>${esc(dir)}</i>` : ''
156
238
  return `<a class="row${f.p === open?.path ? ' on' : ''}${changed.has(f.p) ? ' hot' : ''}" href="${esc(
157
239
  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>`
240
+ )}" data-p="${esc(f.p)}"><b>${name}</b>${road}<em>${since(f.m)}</em>${
241
+ f.line ? `<q>${quoted(f)}</q>` : ''
242
+ }</a>`
243
+ }
244
+
245
+ /** A piece of text with the stretches that matched underlined. */
246
+ function lit(text, at) {
247
+ const spans = runs(at)
248
+ if (!spans.length) return esc(text)
249
+ let out = ''
250
+ let from = 0
251
+ for (const [start, stop] of spans) {
252
+ if (start >= text.length) break
253
+ const end = Math.min(stop, text.length)
254
+ out += `${esc(text.slice(from, start))}<u>${esc(text.slice(start, end))}</u>`
255
+ from = end
256
+ }
257
+ return out + esc(text.slice(from))
161
258
  }
162
259
 
163
260
  /**
@@ -192,29 +289,84 @@ function entryHtml(e) {
192
289
  }
193
290
 
194
291
  /** 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)
292
+ const filesIn = (d) => listed().filter((f) => dirOf(f.p) === d).sort((a, b) => b.m - a.m)
293
+
294
+ /**
295
+ * The directories directly inside `d`, with how many files each one holds below it — all the way
296
+ * down, not only its own floor. The number is what tells you whether it is worth going in, and a
297
+ * folder of folders would otherwise say nothing at all.
298
+ *
299
+ * There is no separate listing of directories, and there does not need to be one: a directory is
300
+ * a piece of the path of the files inside it, and the listing is already here.
301
+ */
302
+ function foldersIn(d) {
303
+ const prefix = d ? `${d}/` : ''
304
+ const kids = new Map()
305
+ for (const f of listed()) {
306
+ if (prefix && !f.p.startsWith(prefix)) continue
307
+ const rest = f.p.slice(prefix.length)
308
+ const at = rest.indexOf('/')
309
+ if (at === -1) continue
310
+ const name = rest.slice(0, at)
311
+ kids.set(name, (kids.get(name) ?? 0) + 1)
312
+ }
313
+ return [...kids]
314
+ .sort((a, b) => a[0].localeCompare(b[0]))
315
+ .map(([name, n]) => ({ dir: prefix + name, name, n }))
316
+ }
317
+
318
+ /**
319
+ * A folder, in the same list and the same shape as the files: it is a real link, so it opens in
320
+ * another tab like everything else here, and it goes into `rows` so the arrow keys walk it too.
321
+ */
322
+ function folderHtml(f) {
323
+ rows.push(f)
324
+ return `<a class="row folder" href="${esc(dirUrl(f.dir))}" data-dir="${esc(f.dir)}"><b>${esc(
325
+ f.name
326
+ )}</b><em>${f.n} file${f.n === 1 ? '' : 's'}</em></a>`
327
+ }
328
+
329
+ /** The way out of a narrowed listing, drawn the same wherever the narrowing came from. */
330
+ 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>`
196
331
 
197
332
  /**
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.
333
+ * The label at the top of the listing when a pasted text is what narrowed it: how many files it
334
+ * named, the way back to the text itself to correct it, and the way out.
201
335
  */
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>`
336
+ const chipHtml = (label) =>
337
+ `<div class="from-text"><button type="button" id="paste-again" title="See and change the text">${label}</button>${backOut}</div>`
338
+
339
+ /**
340
+ * Where you are, one piece of the path at a time, each one a way back up. The first piece is the
341
+ * repository itself, which is the way out altogether.
342
+ *
343
+ * It is a trail rather than a "back" button because you never climb only one floor: from
344
+ * `docs/backlog/handover` what you want is `docs`, and a button would make you press it twice
345
+ * without telling you where you were going to land.
346
+ */
347
+ function trailHtml(d) {
348
+ const parts = d.split('/')
349
+ const road = parts
350
+ .map((name, i) => {
351
+ const upto = parts.slice(0, i + 1).join('/')
352
+ const last = i === parts.length - 1
353
+ return last
354
+ ? `<b>${esc(name)}</b>`
355
+ : `<a href="${esc(dirUrl(upto))}" data-dir="${esc(upto)}">${esc(name)}</a>`
356
+ })
357
+ .join('<i>/</i>')
358
+ const home = `<a href="?${qs()}" data-dir="">${esc($('#repo').textContent || 'all files')}</a>`
359
+ return `<div class="from-text trail">${home}<i>/</i>${road}${finderButton('dir-open', 'Open this folder in your file manager', can.open)}${backOut}</div>`
208
360
  }
209
361
 
210
- /** The listing when a directory is what governs it. */
362
+ /** The listing when a directory is what governs it: what is under it, then what is in it. */
211
363
  function dirHtml() {
364
+ const dirs = foldersIn(scope.dir)
212
365
  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
- )
366
+ let html = trailHtml(scope.dir)
367
+ if (dirs.length) html += `<h2>Folders<small>${dirs.length}</small></h2>${dirs.map(folderHtml).join('')}`
368
+ if (fs.length) html += `<h2>${esc(nameOf(scope.dir))}<small>${fs.length}</small></h2>${fs.map((f) => rowHtml(f)).join('')}`
369
+ return html
218
370
  }
219
371
 
220
372
  /** The listing when a pasted text is what governs it. */
@@ -223,7 +375,7 @@ function pastedHtml() {
223
375
  const n = howMany(out)
224
376
  // Where you are stays visible at the top: the number, the way out, and the text itself one
225
377
  // click away so it can be corrected without pasting it again.
226
- let html = chipHtml(`${n} file${n === 1 ? '' : 's'} from your text`, true)
378
+ let html = chipHtml(`${n} file${n === 1 ? '' : 's'} from your text`)
227
379
  if (out.named.length) {
228
380
  html += `<h2>From your text<small>${countIn(out.named)}</small></h2>`
229
381
  html += out.named.map(entryHtml).join('')
@@ -242,14 +394,39 @@ function pastedHtml() {
242
394
  return html
243
395
  }
244
396
 
245
- /** The usual listing: the groups, and the files under them. */
397
+ /**
398
+ * The usual listing: the folders, then the groups and the files under them.
399
+ *
400
+ * The folders come first because they are the only thing here that is not a document: skip past
401
+ * them and you are in the list you already knew. While searching they are gone — you are looking
402
+ * for a name, and the folders are not among the names you are looking at.
403
+ */
404
+ /**
405
+ * The line above the results when the switch is hiding something you were looking for. It is the
406
+ * button itself, not an instruction: one click, and the file appears.
407
+ */
408
+ function nudgeHtml() {
409
+ const hidden = hiddenByTheSwitch($('#q').value.trim())
410
+ if (!hidden) return ''
411
+ return `<button type="button" class="nudge" id="show-config">${hidden} configuration file${
412
+ hidden === 1 ? '' : 's'
413
+ } also match — show them</button>`
414
+ }
415
+
246
416
  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('')
417
+ const dirs = $('#q').value.trim() ? [] : foldersIn('')
418
+ const head =
419
+ nudgeHtml() +
420
+ (dirs.length ? `<h2>Folders<small>${dirs.length}</small></h2>${dirs.map(folderHtml).join('')}` : '')
421
+ return (
422
+ head +
423
+ groups()
424
+ .map(
425
+ (g) =>
426
+ `<h2>${g.title}<small>${g.files.length}</small></h2>` + g.files.map((f) => rowHtml(f)).join('')
427
+ )
428
+ .join('')
429
+ )
253
430
  }
254
431
 
255
432
  function paint() {
@@ -266,23 +443,34 @@ function paint() {
266
443
  // Empty does not mean "no rows on screen": with a pasted text the guesses arrive closed, and a
267
444
  // result made only of those has no rows to show while still having found everything it should.
268
445
 
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
446
+ const shown = listed().length
447
+ const found = text
448
+ ? howMany(mentioned())
449
+ : scope
450
+ ? filesIn(scope.dir).length + foldersIn(scope.dir).length
451
+ : rows.length
452
+ const nothing = text
453
+ ? 'Nothing of that text is here any more.'
454
+ : scope
455
+ ? 'Nothing in that folder any more.'
456
+ : $('#q').value.trim()
457
+ ? 'Nothing with that name.'
458
+ : nerd
459
+ ? 'Nothing here to read or to configure.'
460
+ : 'No documents here. This lists what you can read and print — and, with the config ' +
461
+ 'button beside the search, the configuration too.'
462
+ // An empty folder still shows the path across the top: it is the way back up, and taking it
463
+ // away would leave you inside a folder with nothing in it and nothing to press. And a search
464
+ // that found nothing visible still says what the switch is holding back — that is the case
465
+ // where saying it matters most.
466
+ list.innerHTML = found
467
+ ? html
468
+ : (scope && !text ? trailHtml(scope.dir) : '') +
469
+ (scope ? '' : nudgeHtml()) +
470
+ `<p class="blank">${nothing}</p>`
281
471
 
282
472
  list.scrollTop = scroll
283
- $('#count').textContent = scope
284
- ? `${found} of ${all.length}`
285
- : `${all.length} file${all.length === 1 ? '' : 's'}`
473
+ $('#count').textContent = scope ? `${found} of ${shown}` : `${shown} file${shown === 1 ? '' : 's'}`
286
474
  mark()
287
475
  }
288
476
 
@@ -343,6 +531,24 @@ function applyPaste(text) {
343
531
  * A directory is also in the address, because it is a page that can be reloaded and sent: on the
344
532
  * way out it has to be taken off, otherwise a reload would put you back inside.
345
533
  */
534
+ /**
535
+ * Into a folder. The file open on the right stays open: you go looking for the next one without
536
+ * losing the one you were reading.
537
+ *
538
+ * The address follows, because a folder is a real page — it reloads into the same place and the
539
+ * link can be sent to somebody.
540
+ */
541
+ function enterDir(d) {
542
+ scope = { kind: 'dir', dir: d }
543
+ opened = new Set()
544
+ active = -1
545
+ $('#q').value = ''
546
+ inText = []
547
+ paint()
548
+ document.title = `${nameOf(d) || '/'} — ${REPO.split('/').pop()}`
549
+ history.replaceState(null, '', open ? `?${qs({ dir: d, f: open.path })}` : dirUrl(d))
550
+ }
551
+
346
552
  function clearScope() {
347
553
  const wasDir = scope?.kind === 'dir'
348
554
  scope = null
@@ -480,7 +686,9 @@ async function show(p, { keepScroll = false } = {}) {
480
686
  doc.innerHTML = `<p class="blank">${esc(e.message)}</p>`
481
687
  return
482
688
  }
483
- open = { path: p, mtime: file.mtime }
689
+ // The text is kept as well as shown: the search inside a tree rebuilds it from here, and the
690
+ // editor starts from it rather than asking the server for what it has just been given.
691
+ open = { path: p, mtime: file.mtime, size: file.size, truncated: !!file.truncated, text: file.text ?? null }
484
692
 
485
693
  // What this document names, before laying it out: the layout engine is synchronous, and for the
486
694
  // names the listing does not have there is a question to ask the server.
@@ -500,7 +708,7 @@ async function show(p, { keepScroll = false } = {}) {
500
708
  head = plate(matter(file.text).data)
501
709
  body = `<article class="md">${render(file.text, link)}</article>`
502
710
  } else if (file.kind === 'text') {
503
- body = `<pre class="plain">${esc(file.text)}</pre>`
711
+ body = textBody(p, file.text)
504
712
  } else if (file.kind === 'image') {
505
713
  body = `<div class="media"><img src="${esc(rawUrl(p))}" alt="${esc(nameOf(p))}"></div>`
506
714
  } else if (file.kind === 'pdf') {
@@ -511,14 +719,15 @@ async function show(p, { keepScroll = false } = {}) {
511
719
  // costs nothing and still holds if the frame ever changes.
512
720
  body = `<iframe class="page" sandbox src="${esc(siteUrl(p))}" title="${esc(nameOf(p))}"></iframe>`
513
721
  } 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>`
722
+ // The way out is the button in the path row above, the same one every other file has: there
723
+ // is no reason for this case to have a second one of its own.
724
+ 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
725
  }
516
726
 
517
727
  const cut = file.truncated ? '<p class="cut">Only the first 2 MB are shown.</p>' : ''
518
728
  doc.innerHTML = crumb + head + body + cut
519
729
  linkRefs(doc.querySelector('.md') ?? doc, refs)
520
730
  doc.scrollTop = scroll
521
- $('#reveal')?.addEventListener('click', reveal)
522
731
 
523
732
  // The name of the page is the document's title: it is what the browser offers as the file name
524
733
  // when you save to PDF, and it is worth far more than "k0 — Files".
@@ -533,12 +742,64 @@ async function show(p, { keepScroll = false } = {}) {
533
742
  paint()
534
743
  }
535
744
 
745
+ /**
746
+ * A text file, shown as the kind of thing it is.
747
+ *
748
+ * A `package.json` is a tree, an `.env` is a table, a YAML file is a shape you must not flatten,
749
+ * and everything else is text. Deciding it here rather than on the server is deliberate: the
750
+ * server says what a file *is* — text, image, page — and that answer is already used by printing,
751
+ * by the PDF and by the download. What it should *look like* is the page's business.
752
+ */
753
+ function textBody(p, text) {
754
+ if (extOf(p) === '.json') {
755
+ const out = jsonTree(text, '')
756
+ // A broken JSON is usually exactly why you opened it: the reason first, then the file as it
757
+ // really is, so you can see the line that is wrong.
758
+ if (out.error) return `<p class="cut">${esc(out.error)}</p><pre class="plain">${esc(text)}</pre>`
759
+ return `<div class="tree" data-json>${out.html}</div>`
760
+ }
761
+ if (ENV_NAME.test(nameOf(p))) return envTable(text)
762
+ if (CONF_EXT.has(extOf(p))) return confText(text)
763
+ return `<pre class="plain">${esc(text)}</pre>`
764
+ }
765
+
766
+ /** The box that searches inside the tree, and what it found. Only where the browser cannot. */
767
+ const findBox = () =>
768
+ `<span class="findin"><input id="jfind" type="search" autocomplete="off" spellcheck="false" placeholder="find in this file…"><em id="jcount"></em></span>`
769
+
770
+ /**
771
+ * The tree again, with what is in the box picked out. Only the tree is rebuilt, so the box keeps
772
+ * the cursor and what you have typed — and the branches holding a result come back open, which is
773
+ * the whole reason this search exists rather than the browser's.
774
+ */
775
+ function findInJson() {
776
+ const box = $('#jfind')
777
+ const host = $('#doc [data-json]')
778
+ if (!box || !host || !open?.text) return
779
+ const q = box.value.trim()
780
+ const out = jsonTree(open.text, q)
781
+ if (out.error) return
782
+ host.innerHTML = out.html
783
+ $('#jcount').textContent = q ? `${out.matches}` : ''
784
+ }
785
+
536
786
  function crumbs(p, file) {
537
787
  const parts = p.split('/')
538
788
  const last = parts.pop()
539
- const road = parts.map((x) => `<span>${esc(x)}</span>`).join('<i>/</i>')
540
789
  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>`
790
+ // The pieces of the path lead into the folders they name: from a file, the way to its
791
+ // neighbours is the path that is already written above it.
792
+ const walk = parts
793
+ .map((x, i) => {
794
+ const upto = parts.slice(0, i + 1).join('/')
795
+ return `<a class="up" href="${esc(dirUrl(upto))}" data-dir="${esc(upto)}">${esc(x)}</a>`
796
+ })
797
+ .join('<i>/</i>')
798
+ const find = extOf(p) === '.json' && file.kind === 'text' ? findBox() : ''
799
+ return `<div class="crumb">${walk ? `${walk}<i>/</i>` : ''}<b>${esc(last)}</b><em>${meta}</em>${find}${acts(
800
+ p,
801
+ file.kind
802
+ )}</div>`
542
803
  }
543
804
 
544
805
  /** Drawn by hand like the others in the house: same stroke, same grid. */
@@ -546,6 +807,22 @@ const ICON = {
546
807
  print:
547
808
  '<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
809
  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>',
810
+ finder:
811
+ '<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>',
812
+ 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>',
813
+ }
814
+
815
+ /**
816
+ * The button that hands a file or a folder to the file manager — or the same button, in the same
817
+ * place, switched off with the reason in its title. A control that disappears when the machine
818
+ * cannot do the thing teaches you nothing; one that stays and says why teaches you what is
819
+ * missing.
820
+ */
821
+ function finderButton(id, label, allowed) {
822
+ const why = allowed ? label : can.whyNot || `${label} — this system gives k0 no way to do that.`
823
+ return `<button class="act" id="${id}" data-act="reveal" type="button" title="${esc(why)}" aria-label="${esc(
824
+ label
825
+ )}"${allowed ? '' : ' disabled'}>${ICON.finder}</button>`
549
826
  }
550
827
 
551
828
  /**
@@ -557,12 +834,19 @@ const ICON = {
557
834
  * printing a `.docx` that cannot even be shown here does not, and asking for the PDF of something
558
835
  * that is already a PDF does not either — that one is simply downloaded.
559
836
  */
560
- function acts(p, kind) {
837
+ function acts(p, file) {
838
+ const kind = file.kind
561
839
  const paper = kind === 'markdown' || kind === 'text'
562
840
  // In capitals and without the dot, like the PDF next to it: they are two buttons that do the
563
841
  // same thing, and writing them two different ways made them look like two different things.
564
842
  const ext = /\.([^./]+)$/.exec(p)?.[1].toUpperCase() || 'FILE'
565
843
  const out = []
844
+ // Not on a file that is only half here. What is on screen when a document is cut is the first
845
+ // two megabytes of it, and saving that back would throw the rest away without a word.
846
+ if (paper && canEdit(p) && !file.truncated && file.size <= MAX_EDIT) {
847
+ out.push(`<button class="act" data-act="edit" type="button" title="Edit this file here" aria-label="Edit">${ICON.edit}</button>`)
848
+ }
849
+ out.push(finderButton('reveal', 'Show this file in your file manager', can.reveal))
566
850
  if (paper || kind === 'image') {
567
851
  out.push(`<button class="act" data-act="print" type="button" title="Print (⌘P)" aria-label="Print">${ICON.print}</button>`)
568
852
  }
@@ -621,20 +905,87 @@ function plate(data) {
621
905
 
622
906
  const TITLE_KEYS = new Set(['title', 'titolo', 'name', 'nome'])
623
907
 
624
- async function reveal() {
908
+ /**
909
+ * Hand a path to the file manager. The same call for a file and for a folder: which of the two
910
+ * gestures it is — point at it, or open it — is decided on the other side, by looking at what is
911
+ * really there.
912
+ */
913
+ async function reveal(rel) {
625
914
  try {
626
- await api('/api/file/reveal', { method: 'POST', body: JSON.stringify({ repo: REPO, path: open.path }) })
915
+ await api('/api/file/reveal', { method: 'POST', body: JSON.stringify({ repo: REPO, path: rel }) })
627
916
  } catch (e) {
628
917
  toast(e.message)
629
918
  }
630
919
  }
631
920
 
632
- function toast(msg) {
921
+ // ── Writing a file ────────────────────────────────────────────────────
922
+ // k0 reads your repositories; this is the one place it writes into one. It is deliberately small:
923
+ // configuration and notes only, a file that is already there, and no saving over somebody else's
924
+ // work. Anything larger than that is what you opened the repository with Claude for.
925
+ //
926
+ // The editing happens where the document was, not in a dialog over it: a dialog is the right size
927
+ // for a title and the wrong size for a README, and here the pane is already as tall as the file.
928
+
929
+ /** Turns the open document into the text it is made of. */
930
+ function startEdit() {
931
+ if (!open || open.text == null || !canEdit(open.path)) return
932
+ // The same refusal the button already makes, said again here: what is on screen when a document
933
+ // is cut is only the first two megabytes of it, and saving that back would lose the rest.
934
+ if (open.truncated || open.size > MAX_EDIT) return toast('This file is too big to edit here.')
935
+ editing = { path: open.path, mtime: open.mtime }
936
+ const parts = open.path.split('/')
937
+ const last = parts.pop()
938
+ const road = parts.map((x) => `<span>${esc(x)}</span>`).join('<i>/</i>')
939
+ $('#doc').innerHTML =
940
+ `<div class="crumb editing">${road ? `${road}<i>/</i>` : ''}<b>${esc(last)}</b><em>editing</em>` +
941
+ `<span class="acts"><button class="act" data-act="cancel" type="button">Cancel</button>` +
942
+ `<button class="act keep" data-act="save" type="button" title="Save (⌘S)">Save</button></span></div>` +
943
+ // The newline after the tag is not decoration: the HTML parser eats the first one inside a
944
+ // `<textarea>`, and without it a file that begins with a blank line would quietly lose it.
945
+ `<textarea class="edit" spellcheck="false" autocomplete="off">\n${esc(open.text)}</textarea>`
946
+ const box = $('#doc .edit')
947
+ box.focus()
948
+ box.setSelectionRange(0, 0)
949
+ }
950
+
951
+ /** Back to reading, with whatever is on disk. What was typed and not saved is gone, as it says. */
952
+ function cancelEdit() {
953
+ const p = editing.path
954
+ editing = null
955
+ show(p)
956
+ }
957
+
958
+ let writing = false
959
+ async function saveEdit() {
960
+ const box = $('#doc .edit')
961
+ if (!box || writing) return
962
+ writing = true
963
+ try {
964
+ await api('/api/file/save', {
965
+ method: 'POST',
966
+ body: JSON.stringify({ repo: REPO, path: editing.path, text: box.value, mtime: editing.mtime }),
967
+ })
968
+ const p = editing.path
969
+ editing = null
970
+ toast('Saved.')
971
+ // Straight back to the file as it now is — read again from disk, not from what we sent, so
972
+ // what you are looking at is what is really there.
973
+ await show(p)
974
+ } catch (e) {
975
+ // Including the clash: the message says what happened and the text stays exactly where it is,
976
+ // so it can be copied out or saved again once you have looked.
977
+ toast(e.message, 8000)
978
+ } finally {
979
+ writing = false
980
+ }
981
+ }
982
+
983
+ function toast(msg, ms = 4000) {
633
984
  const t = $('#toast')
634
985
  t.textContent = msg
635
986
  t.classList.add('show')
636
987
  clearTimeout(toast.timer)
637
- toast.timer = setTimeout(() => t.classList.remove('show'), 4000)
988
+ toast.timer = setTimeout(() => t.classList.remove('show'), ms)
638
989
  }
639
990
 
640
991
  // ── The divider ───────────────────────────────────────────────────────
@@ -668,6 +1019,7 @@ async function loadAll() {
668
1019
  all = data.files
669
1020
  known = new Set(all.map((f) => f.p))
670
1021
  changed = new Set(data.changed)
1022
+ can = data.can ?? can
671
1023
  document.title = `k0 — ${data.name}`
672
1024
  $('#repo').textContent = data.name
673
1025
  $('#ctx').textContent = data.title ? `· ${data.title}` : data.git ? '' : '· not a git repository'
@@ -692,8 +1044,10 @@ async function poll() {
692
1044
  changed = new Set(now)
693
1045
  if (fresh) await loadAll()
694
1046
  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)) {
1047
+ // The open file is changing under your eyes: it is re-read, and you stay where you are. Not
1048
+ // while it is being edited, though — redrawing the pane there would take away what you have
1049
+ // typed. The save is the one that finds out, and it says so instead of overwriting.
1050
+ if (!editing && open && changed.has(open.path)) {
697
1051
  const f = await api(`/api/file?${qs({ path: open.path })}`)
698
1052
  if (f.mtime !== open.mtime) await show(open.path, { keepScroll: true })
699
1053
  }
@@ -716,7 +1070,7 @@ function lookInside() {
716
1070
  }
717
1071
  lookInside.timer = setTimeout(async () => {
718
1072
  try {
719
- const { hits } = await api(`/api/files?${qs({ only: 'text', q })}`)
1073
+ const { hits } = await api(`/api/files?${qs({ only: 'text', q, nerd: nerd ? '1' : '0' })}`)
720
1074
  if (q !== $('#q').value.trim()) return
721
1075
  inText = hits
722
1076
  paint()
@@ -731,6 +1085,19 @@ function keys() {
731
1085
  // With the paste dialog open the keys belong to it: `/` must not jump into the search while
732
1086
  // you are typing, and Esc is `<dialog>`'s own business.
733
1087
  if ($('#paste').open) return
1088
+ // While a file is being written the page's shortcuts step aside altogether: `/` is a
1089
+ // character, the arrows move the cursor, and Escape must not throw the work away by accident.
1090
+ // The one that is added is the one every editor has.
1091
+ if (editing) {
1092
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
1093
+ e.preventDefault()
1094
+ saveEdit()
1095
+ }
1096
+ return
1097
+ }
1098
+ // The box that searches inside a tree owns its keys too: it is a text field, and the arrows
1099
+ // belong to the cursor in it.
1100
+ if (document.activeElement?.id === 'jfind') return
734
1101
  const typing = document.activeElement === $('#q')
735
1102
  // Ctrl/Cmd+P is already the browser's print: here it is enough not to get in its way.
736
1103
  if (e.key === '/' && !typing) {
@@ -761,7 +1128,8 @@ function keys() {
761
1128
  }
762
1129
  if (e.key === 'Enter' && rows[active]) {
763
1130
  e.preventDefault()
764
- show(rows[active].p)
1131
+ const row = rows[active]
1132
+ row.dir ? enterDir(row.dir) : show(row.p)
765
1133
  }
766
1134
  }
767
1135
  }
@@ -793,6 +1161,9 @@ async function boot() {
793
1161
  openPaste(t)
794
1162
  })
795
1163
 
1164
+ markNerd()
1165
+ $('#nerd').onclick = () => setNerd(!nerd)
1166
+
796
1167
  $('#paste-open').onclick = () => openPaste()
797
1168
  $('#p-close').onclick = () => $('#paste').close()
798
1169
  $('#p-cancel').onclick = () => $('#paste').close()
@@ -821,8 +1192,18 @@ async function boot() {
821
1192
  opened.has(k) ? opened.delete(k) : opened.add(k)
822
1193
  return paint()
823
1194
  }
1195
+ if (e.target.closest('#show-config')) return setNerd(true)
824
1196
  if (e.target.closest('#paste-clear')) return clearScope()
825
1197
  if (e.target.closest('#paste-again')) return openPaste()
1198
+ if (e.target.closest('#dir-open')) return void reveal(scope.dir)
1199
+
1200
+ // A folder, in the list or in the trail above it. Empty means the repository itself, which is
1201
+ // the way back out to the whole listing.
1202
+ const into = e.target.closest('[data-dir]')
1203
+ if (into && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
1204
+ e.preventDefault()
1205
+ return into.dataset.dir ? enterDir(into.dataset.dir) : clearScope()
1206
+ }
826
1207
 
827
1208
  const row = e.target.closest('.row')
828
1209
  if (!row || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return
@@ -844,7 +1225,22 @@ async function boot() {
844
1225
  // are rebuilt on every opening: the click is listened for once, here.
845
1226
  $('#doc').onclick = (e) => {
846
1227
  const act = e.target.closest('.crumb button[data-act]')
847
- if (act) return void (act.dataset.act === 'print' ? window.print() : savePdf())
1228
+ if (act) {
1229
+ const what = act.dataset.act
1230
+ if (what === 'print') return void window.print()
1231
+ if (what === 'pdf') return void savePdf()
1232
+ if (what === 'reveal') return void reveal(open.path)
1233
+ if (what === 'edit') return void startEdit()
1234
+ if (what === 'save') return void saveEdit()
1235
+ if (what === 'cancel') return void cancelEdit()
1236
+ return
1237
+ }
1238
+ // A piece of the path above the document: it opens the folder it names, in the list.
1239
+ const into = e.target.closest('.crumb a[data-dir]')
1240
+ if (into && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
1241
+ e.preventDefault()
1242
+ return void enterDir(into.dataset.dir)
1243
+ }
848
1244
  // File names found in the text open a new tab: they are real links, and nothing is intercepted
849
1245
  // here — the browser deals with it. References written by hand inside the document instead stay
850
1246
  // as they were, and open here.
@@ -854,6 +1250,11 @@ async function boot() {
854
1250
  e.preventDefault()
855
1251
  show(new URLSearchParams(a.getAttribute('href').slice(1)).get('f'))
856
1252
  }
1253
+ // Searching inside a tree: it runs here, on the data, because the browser's own find does not
1254
+ // look inside a branch that is closed.
1255
+ $('#doc').addEventListener('input', (e) => {
1256
+ if (e.target.id === 'jfind') findInJson()
1257
+ })
857
1258
 
858
1259
  await loadAll()
859
1260
  // A directory named inside a document opens a page of its own: the listing starts already