k0-board 0.2.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/CHANGELOG.md +116 -1
- package/README.md +179 -18
- package/bin/k0-board.js +16 -0
- package/package.json +1 -1
- package/platform/contract.js +7 -1
- package/platform/darwin/index.js +1 -0
- package/platform/darwin/shell.js +4 -1
- package/platform/index.js +1 -0
- package/platform/linux/index.js +11 -0
- package/platform/linux/shell.js +10 -1
- package/platform/win32/index.js +1 -0
- package/platform/win32/shell.js +4 -1
- package/server/db.js +11 -0
- package/server/files.js +105 -14
- package/server/idle.js +56 -0
- package/server/index.js +90 -4
- package/server/launcher.js +5 -1
- package/server/paths.js +5 -4
- package/server/settings.js +121 -0
- package/web/board.css +12 -0
- package/web/board.js +22 -4
- package/web/conf.js +118 -0
- package/web/files.css +206 -1
- package/web/files.html +10 -0
- package/web/files.js +463 -62
- package/web/fuzzy.js +73 -21
- package/web/json.js +145 -0
package/web/files.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { render, esc, matter, titleOf } from '/md.js'
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
120
|
-
const rest =
|
|
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>${
|
|
159
|
-
|
|
160
|
-
}
|
|
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) =>
|
|
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
|
|
199
|
-
*
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
?
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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'),
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|