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.
- package/.claude/skills/k0-import/SKILL.md +83 -0
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +688 -0
- package/bin/k0-board.js +456 -0
- package/bin/prompt.js +41 -0
- package/menubar/Info.plist +25 -0
- package/menubar/K0MenuBar.swift +561 -0
- package/menubar/build.sh +28 -0
- package/package.json +45 -0
- package/platform/contract.js +134 -0
- package/platform/darwin/extras.js +254 -0
- package/platform/darwin/index.js +24 -0
- package/platform/darwin/metrics.js +59 -0
- package/platform/darwin/power.js +133 -0
- package/platform/darwin/service.js +143 -0
- package/platform/darwin/shell.js +59 -0
- package/platform/darwin/terminal.js +324 -0
- package/platform/index.js +97 -0
- package/platform/linux/index.js +60 -0
- package/platform/linux/metrics.js +88 -0
- package/platform/linux/power.js +137 -0
- package/platform/linux/service.js +111 -0
- package/platform/linux/shell.js +97 -0
- package/platform/linux/tray.py +229 -0
- package/platform/shared/command.js +26 -0
- package/platform/shared/ps.js +40 -0
- package/platform/shared/run.js +99 -0
- package/platform/shared/tmux.js +242 -0
- package/platform/win32/index.js +53 -0
- package/platform/win32/metrics.js +72 -0
- package/platform/win32/power.js +109 -0
- package/platform/win32/powershell.js +44 -0
- package/platform/win32/service.js +111 -0
- package/platform/win32/shell.js +64 -0
- package/platform/win32/terminal.js +209 -0
- package/platform/win32/tray.ps1 +159 -0
- package/server/db.js +262 -0
- package/server/files.js +488 -0
- package/server/git.js +157 -0
- package/server/index.js +636 -0
- package/server/launcher.js +121 -0
- package/server/machine.js +213 -0
- package/server/mode.js +182 -0
- package/server/paths.js +67 -0
- package/server/pdf.js +110 -0
- package/server/projects.js +152 -0
- package/server/sessions.js +341 -0
- package/server/watcher.js +200 -0
- package/web/base.css +134 -0
- package/web/board.css +561 -0
- package/web/board.js +777 -0
- package/web/favicon.js +41 -0
- package/web/favicon.svg +8 -0
- package/web/files.css +535 -0
- package/web/files.html +63 -0
- package/web/files.js +877 -0
- package/web/fonts/caveat-latin-ext.woff2 +0 -0
- package/web/fonts/caveat-latin.woff2 +0 -0
- package/web/fuzzy.js +57 -0
- package/web/index.html +112 -0
- package/web/md.js +238 -0
- package/web/mentions.js +188 -0
- package/web/refs.js +176 -0
- package/web/title.js +26 -0
- package/web/units.js +33 -0
- 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()
|