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