k0-board 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +96 -14
- package/bin/k0-board.js +2 -0
- package/package.json +1 -1
- package/platform/contract.js +7 -1
- package/platform/darwin/index.js +1 -0
- package/platform/darwin/shell.js +4 -1
- package/platform/index.js +1 -0
- package/platform/linux/index.js +11 -0
- package/platform/linux/shell.js +10 -1
- package/platform/win32/index.js +1 -0
- package/platform/win32/shell.js +4 -1
- package/server/files.js +105 -14
- package/server/index.js +35 -3
- package/web/board.css +8 -0
- package/web/conf.js +118 -0
- package/web/files.css +206 -1
- package/web/files.html +10 -0
- package/web/files.js +463 -62
- package/web/fuzzy.js +73 -21
- package/web/json.js +145 -0
package/server/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import * as files from './files.js'
|
|
|
27
27
|
import * as pdf from './pdf.js'
|
|
28
28
|
import * as machine from './machine.js'
|
|
29
29
|
import * as servers from './servers.js'
|
|
30
|
-
import { report as platformReport } from '../platform/index.js'
|
|
30
|
+
import { report as platformReport, capabilities, why } from '../platform/index.js'
|
|
31
31
|
// The same front-matter reader the viewer uses: the PDF has to be called what the page is
|
|
32
32
|
// called, and two different readings of the same header would give two different names.
|
|
33
33
|
import { matter, titleOf } from '../web/md.js'
|
|
@@ -341,6 +341,11 @@ function site(req, res, seg) {
|
|
|
341
341
|
// is a much smaller loss than that, so the header is gone.
|
|
342
342
|
const SANDBOX = 'sandbox'
|
|
343
343
|
|
|
344
|
+
// How much a save is allowed to carry. Well under the megabyte the body reader stops at, which
|
|
345
|
+
// would otherwise refuse a large file with a message about requests instead of about the file —
|
|
346
|
+
// and far above anything you would actually edit in a browser tab: k0's own README is 56 KB.
|
|
347
|
+
const MAX_SAVE = 1 << 18
|
|
348
|
+
|
|
344
349
|
// ── The recap of a window ────────────────────────────────────────────────────
|
|
345
350
|
// Held for a minute, and not as a saved result: the page asks for the facts, then asks for the
|
|
346
351
|
// write-up to start, then asks every second whether it is done. Reading every repository's git
|
|
@@ -540,8 +545,11 @@ async function api(req, res, url) {
|
|
|
540
545
|
const card = Number.isInteger(cardId) && cardId > 0 ? db.getCard(cardId) : null
|
|
541
546
|
// Searching inside the files: the page asks for it as you type, and it needs neither the
|
|
542
547
|
// listing nor git's state.
|
|
548
|
+
// `nerd` is the switch in the page: with it on, the configuration files are listed, so the
|
|
549
|
+
// search inside the text has to look in them too.
|
|
550
|
+
const nerd = url.searchParams.get('nerd') === '1'
|
|
543
551
|
if (url.searchParams.get('only') === 'text') {
|
|
544
|
-
return send(res, 200, { hits: await files.grep(root, url.searchParams.get('q') || '') })
|
|
552
|
+
return send(res, 200, { hits: await files.grep(root, url.searchParams.get('q') || '', nerd) })
|
|
545
553
|
}
|
|
546
554
|
const changed = await files.changed(root, card?.head_at_start)
|
|
547
555
|
if (url.searchParams.get('only') === 'changed') return send(res, 200, { changed, now: Date.now() })
|
|
@@ -554,6 +562,13 @@ async function api(req, res, url) {
|
|
|
554
562
|
truncated: all.truncated,
|
|
555
563
|
changed,
|
|
556
564
|
files: all.files,
|
|
565
|
+
// What this machine can do about a file, so the viewer greys the buttons out and says why
|
|
566
|
+
// rather than offering something that would quietly do nothing.
|
|
567
|
+
can: {
|
|
568
|
+
reveal: capabilities.revealInFileManager,
|
|
569
|
+
open: capabilities.openInFileManager,
|
|
570
|
+
whyNot: why('revealInFileManager') || why('openInFileManager'),
|
|
571
|
+
},
|
|
557
572
|
now: Date.now(),
|
|
558
573
|
})
|
|
559
574
|
}
|
|
@@ -609,10 +624,27 @@ async function api(req, res, url) {
|
|
|
609
624
|
return res.end(out)
|
|
610
625
|
}
|
|
611
626
|
|
|
627
|
+
// One button, two neighbouring things: a file is pointed at inside its folder, a folder is
|
|
628
|
+
// opened. Which of the two is decided here rather than in the page, because the page would
|
|
629
|
+
// have to guess and the disk already knows.
|
|
612
630
|
if (idRaw === 'reveal' && req.method === 'POST') {
|
|
613
|
-
await files.
|
|
631
|
+
if (fs.statSync(abs).isDirectory()) await files.openFolder(abs)
|
|
632
|
+
else await files.reveal(abs)
|
|
614
633
|
return send(res, 200, { ok: true })
|
|
615
634
|
}
|
|
635
|
+
|
|
636
|
+
// The first thing k0 writes into one of your repositories. Only configuration and notes, only
|
|
637
|
+
// a file that is already there, and only if nobody has touched it since the page read it.
|
|
638
|
+
if (idRaw === 'save' && req.method === 'POST') {
|
|
639
|
+
if (!files.isEditable(rel)) return send(res, 400, { error: 'This kind of file is not edited here' })
|
|
640
|
+
if (typeof body.text !== 'string') return send(res, 400, { error: 'Nothing to save' })
|
|
641
|
+
if (Buffer.byteLength(body.text) > MAX_SAVE) return send(res, 400, { error: 'That is too big to save from here' })
|
|
642
|
+
try {
|
|
643
|
+
return send(res, 200, files.write(abs, body.text, Number(body.mtime)))
|
|
644
|
+
} catch (e) {
|
|
645
|
+
return send(res, e.conflict ? 409 : 500, { error: e.message })
|
|
646
|
+
}
|
|
647
|
+
}
|
|
616
648
|
if (!idRaw && req.method === 'GET') return send(res, 200, { path: rel, ...files.read(abs) })
|
|
617
649
|
}
|
|
618
650
|
|
package/web/board.css
CHANGED
|
@@ -387,10 +387,18 @@ body {
|
|
|
387
387
|
gap: 7px;
|
|
388
388
|
margin-bottom: 3px;
|
|
389
389
|
}
|
|
390
|
+
/* The repository's name takes the room that is left, so everything after it — the git mark, the
|
|
391
|
+
memory chip — sits against the right edge of the note. The same shape as the heading of a
|
|
392
|
+
column, and for the same reason: hanging off the end of the name those marks landed in a
|
|
393
|
+
different place on every note, and a row of notes had no line to read down. */
|
|
390
394
|
.postit .tag {
|
|
395
|
+
flex: 1;
|
|
391
396
|
font: 500 1.05rem/1 var(--hand);
|
|
392
397
|
color: rgba(0, 0, 0, .5);
|
|
393
398
|
min-width: 0;
|
|
399
|
+
overflow: hidden;
|
|
400
|
+
text-overflow: ellipsis;
|
|
401
|
+
white-space: nowrap;
|
|
394
402
|
}
|
|
395
403
|
/* The name is the session's: it is in monospace like the k0 mark at the top.
|
|
396
404
|
Monospace is heavier at the same size, which is why it drops to .94rem. */
|
package/web/conf.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// ── Configuration files, laid out ─────────────────────────────────────────────
|
|
2
|
+
// An `.env` is a table pretending to be a text file: two columns, one per line, and the reason
|
|
3
|
+
// you opened it is always to compare a name with a value. Shown as text, the values start in a
|
|
4
|
+
// different place on every line and the eye has to walk each one to the `=`. Shown as a table,
|
|
5
|
+
// you read the column.
|
|
6
|
+
//
|
|
7
|
+
// The rest — YAML, TOML, INI — is not a table: nesting matters, and a table would flatten the one
|
|
8
|
+
// thing that carries the meaning. Those keep their shape and only get their keys, their values
|
|
9
|
+
// and their comments told apart by colour.
|
|
10
|
+
//
|
|
11
|
+
// Like the tree next door, this knows nothing about the page: text in, a string of HTML out.
|
|
12
|
+
|
|
13
|
+
/** Written again here for the same reason as in `json.js`: the tests run in Node. */
|
|
14
|
+
const esc = (s) =>
|
|
15
|
+
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An `.env` read line by line, keeping everything — the comments and the blank lines included.
|
|
19
|
+
*
|
|
20
|
+
* They are kept because they are the file: a comment above a key says what the key is for, and a
|
|
21
|
+
* view that dropped it would be a different file that happens to have the same values. Anything
|
|
22
|
+
* this does not understand comes back as it was written rather than being guessed at.
|
|
23
|
+
*
|
|
24
|
+
* Returns rows of `{ kind: 'pair' | 'comment' | 'blank' | 'raw' }`.
|
|
25
|
+
*/
|
|
26
|
+
export function envRows(text) {
|
|
27
|
+
const rows = []
|
|
28
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
29
|
+
const line = raw.replace(/\r$/, '')
|
|
30
|
+
const trimmed = line.trim()
|
|
31
|
+
if (!trimmed) {
|
|
32
|
+
rows.push({ kind: 'blank' })
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (trimmed.startsWith('#')) {
|
|
36
|
+
rows.push({ kind: 'comment', text: trimmed.replace(/^#\s?/, '') })
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
// `export FOO=bar` is the same line with a word in front of it: shell files and `.env` files
|
|
40
|
+
// are copied back and forth, and both spellings turn up in the same repository.
|
|
41
|
+
const body = trimmed.replace(/^export\s+/, '')
|
|
42
|
+
const at = body.indexOf('=')
|
|
43
|
+
if (at <= 0) {
|
|
44
|
+
rows.push({ kind: 'raw', text: line })
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
const key = body.slice(0, at).trim()
|
|
48
|
+
rows.push({ kind: 'pair', key, value: unquote(body.slice(at + 1).trim()) })
|
|
49
|
+
}
|
|
50
|
+
// A file ends with a newline, and that last empty line is not a blank line in the file: it is
|
|
51
|
+
// the end of it. Showing it would put a hole at the bottom of every table.
|
|
52
|
+
if (rows.length && rows[rows.length - 1].kind === 'blank') rows.pop()
|
|
53
|
+
return rows
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The value without the quotes around it, and without the comment after it.
|
|
58
|
+
*
|
|
59
|
+
* The comment is only cut off an unquoted value: inside quotes a `#` is a character like any
|
|
60
|
+
* other, and a password with a hash in it must not lose half of itself on the way to the screen.
|
|
61
|
+
*/
|
|
62
|
+
function unquote(value) {
|
|
63
|
+
const quoted = /^(['"])([\s\S]*)\1$/.exec(value)
|
|
64
|
+
if (quoted) return quoted[2]
|
|
65
|
+
const hash = value.search(/\s#/)
|
|
66
|
+
return hash === -1 ? value : value.slice(0, hash).trimEnd()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The two columns. An empty value is said in words, or the row would look like a mistake. */
|
|
70
|
+
export function envTable(text) {
|
|
71
|
+
const body = envRows(text)
|
|
72
|
+
.map((r) => {
|
|
73
|
+
if (r.kind === 'pair') {
|
|
74
|
+
const value = r.value
|
|
75
|
+
? `<td class="val">${esc(r.value)}</td>`
|
|
76
|
+
: '<td class="val none">empty</td>'
|
|
77
|
+
return `<tr><th>${esc(r.key)}</th>${value}</tr>`
|
|
78
|
+
}
|
|
79
|
+
if (r.kind === 'comment') return `<tr class="note"><td colspan="2">${esc(r.text)}</td></tr>`
|
|
80
|
+
if (r.kind === 'raw') return `<tr class="odd"><td colspan="2">${esc(r.text)}</td></tr>`
|
|
81
|
+
return '<tr class="gap"><td colspan="2"></td></tr>'
|
|
82
|
+
})
|
|
83
|
+
.join('')
|
|
84
|
+
return `<table class="env">${body}</table>`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* YAML, TOML and INI: the same file, with the parts told apart.
|
|
89
|
+
*
|
|
90
|
+
* No parsing. A YAML parser written here would be a second thing to keep correct, and every one
|
|
91
|
+
* of its mistakes would show you a file that is not the file. Reading a line at a time never
|
|
92
|
+
* shows you anything that is not there — at worst it colours something in as though it were
|
|
93
|
+
* ordinary text, which is what it does with everything it does not recognise.
|
|
94
|
+
*/
|
|
95
|
+
export function highlight(text) {
|
|
96
|
+
const out = String(text ?? '')
|
|
97
|
+
.split('\n')
|
|
98
|
+
.map((raw) => {
|
|
99
|
+
const line = raw.replace(/\r$/, '')
|
|
100
|
+
const lead = line.length - line.trimStart().length
|
|
101
|
+
const indent = esc(line.slice(0, lead))
|
|
102
|
+
const rest = line.slice(lead)
|
|
103
|
+
if (!rest) return ''
|
|
104
|
+
if (rest.startsWith('#') || rest.startsWith(';')) return `${indent}<span class="c">${esc(rest)}</span>`
|
|
105
|
+
// A TOML or INI heading: the one line that says where you are in the file.
|
|
106
|
+
if (/^\[.*]$/.test(rest)) return `${indent}<span class="sec">${esc(rest)}</span>`
|
|
107
|
+
// `- ` is a YAML list, and what follows it may itself be a pair.
|
|
108
|
+
const dash = /^-\s+/.exec(rest)
|
|
109
|
+
const item = dash ? rest.slice(dash[0].length) : rest
|
|
110
|
+
const bullet = dash ? `<span class="dash">${esc(dash[0])}</span>` : ''
|
|
111
|
+
const pair = /^([^:=#]+?)\s*([:=])\s*([\s\S]*)$/.exec(item)
|
|
112
|
+
if (!pair) return `${indent}${bullet}<span class="v">${esc(item)}</span>`
|
|
113
|
+
const value = pair[3] ? `<span class="v">${esc(pair[3])}</span>` : ''
|
|
114
|
+
return `${indent}${bullet}<span class="key">${esc(pair[1])}</span><span class="sep">${pair[2]}</span> ${value}`
|
|
115
|
+
})
|
|
116
|
+
.join('\n')
|
|
117
|
+
return `<pre class="conf">${out}</pre>`
|
|
118
|
+
}
|
package/web/files.css
CHANGED
|
@@ -74,6 +74,28 @@ body {
|
|
|
74
74
|
width: 100%;
|
|
75
75
|
color: var(--ink);
|
|
76
76
|
}
|
|
77
|
+
/* The switch that lets the configuration through. It was a bare glyph inside the search pill and
|
|
78
|
+
nobody found it: at that size, in that place, it read as part of the field rather than as
|
|
79
|
+
something you could press. So it is a button of its own, next to the search, outlined like the
|
|
80
|
+
ones over a document, and it says what it does in a word. Switched on it fills in, so from
|
|
81
|
+
across the room you can tell whether you are looking at everything or not. */
|
|
82
|
+
.topbar .nerd {
|
|
83
|
+
flex: none;
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
gap: 5px;
|
|
87
|
+
padding: 5px 11px;
|
|
88
|
+
border: 1px solid var(--line);
|
|
89
|
+
background: #fff;
|
|
90
|
+
border-radius: 999px;
|
|
91
|
+
font: inherit;
|
|
92
|
+
font-size: .74rem;
|
|
93
|
+
color: var(--muted);
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
.topbar .nerd svg { width: 14px; height: 14px; display: block; flex: none; }
|
|
97
|
+
.topbar .nerd:hover { color: var(--ink); border-color: #bdb6a5; background: #fff; }
|
|
98
|
+
.topbar .nerd.on, .topbar .nerd.on:hover { background: var(--ink); border-color: var(--ink); color: #fff; }
|
|
77
99
|
|
|
78
100
|
/* ── The two panes ───────────────────────────────────────────────────── */
|
|
79
101
|
/* The divider is a column of the grid: drag it and the size stays. */
|
|
@@ -160,6 +182,16 @@ body {
|
|
|
160
182
|
overflow: hidden;
|
|
161
183
|
}
|
|
162
184
|
.row q mark { background: #fde68a; color: var(--ink); border-radius: 3px; padding: 0 1px; }
|
|
185
|
+
/* The letters that made this row match, in its name. Underlined and not highlighted, so the two
|
|
186
|
+
answers stay apart at a glance: yellow means the word was found inside the file, a line under
|
|
187
|
+
the letters means they are in its name. */
|
|
188
|
+
.row b u {
|
|
189
|
+
color: var(--ink);
|
|
190
|
+
text-decoration: underline;
|
|
191
|
+
text-decoration-thickness: 2px;
|
|
192
|
+
text-underline-offset: 2px;
|
|
193
|
+
text-decoration-color: var(--alert);
|
|
194
|
+
}
|
|
163
195
|
.row:hover { background: rgba(0, 0, 0, .035); }
|
|
164
196
|
.row.on { background: var(--WORKING); }
|
|
165
197
|
.row.active { box-shadow: inset 3px 0 0 var(--ink); }
|
|
@@ -215,6 +247,26 @@ body {
|
|
|
215
247
|
.from-text .x:hover { opacity: 1; text-decoration: none; }
|
|
216
248
|
.from-text .x svg { width: 15px; height: 15px; display: block; }
|
|
217
249
|
|
|
250
|
+
/* The line that says the switch is hiding what you asked for. It sits above everything, in the
|
|
251
|
+
same green as the other things that say "the listing is not showing you all of it", and it is a
|
|
252
|
+
button: the answer to "where is my .env" should be one click, not an instruction. */
|
|
253
|
+
.nudge {
|
|
254
|
+
display: block;
|
|
255
|
+
width: 100%;
|
|
256
|
+
border: 0;
|
|
257
|
+
border-bottom: 1px solid rgba(0, 0, 0, .08);
|
|
258
|
+
border-radius: 0;
|
|
259
|
+
background: var(--IDLE);
|
|
260
|
+
padding: 9px 16px;
|
|
261
|
+
font: inherit;
|
|
262
|
+
font-size: .78rem;
|
|
263
|
+
font-weight: 600;
|
|
264
|
+
color: var(--ink);
|
|
265
|
+
text-align: left;
|
|
266
|
+
cursor: pointer;
|
|
267
|
+
}
|
|
268
|
+
.nudge:hover { background: var(--IDLE); text-decoration: underline; }
|
|
269
|
+
|
|
218
270
|
/* The heading of a group that opens and closes: the guesses arrive closed. */
|
|
219
271
|
.list h2.fold { cursor: pointer; user-select: none; }
|
|
220
272
|
.list h2.fold::after {
|
|
@@ -239,6 +291,45 @@ body {
|
|
|
239
291
|
.row.fold b::after { content: ' ▸'; opacity: .6; font-weight: 400; }
|
|
240
292
|
.row.fold.open b::after { content: ' ▾'; }
|
|
241
293
|
|
|
294
|
+
/* ── Folders ──────────────────────────────────────────────────────────
|
|
295
|
+
A folder is a row like any other, and deliberately so: the arrow keys walk it, ⌘-click opens
|
|
296
|
+
it in another tab, and nothing about it has to be learnt separately. The only difference is the
|
|
297
|
+
mark in front of the name and the slash after it, which is how a folder has always been told
|
|
298
|
+
apart from a file. */
|
|
299
|
+
.row.folder b::before {
|
|
300
|
+
content: '';
|
|
301
|
+
display: inline-block;
|
|
302
|
+
width: 12px;
|
|
303
|
+
height: 9px;
|
|
304
|
+
margin-right: 7px;
|
|
305
|
+
border: 1px solid var(--muted);
|
|
306
|
+
border-radius: 1px 2px 2px 2px;
|
|
307
|
+
border-top-width: 3px;
|
|
308
|
+
vertical-align: middle;
|
|
309
|
+
opacity: .75;
|
|
310
|
+
}
|
|
311
|
+
.row.folder b::after { content: '/'; opacity: .45; }
|
|
312
|
+
|
|
313
|
+
/* Where you are, one piece of the path at a time. It sits in the same green strip as the label of
|
|
314
|
+
a pasted text: both say the listing is a slice, and they must look like the same statement. */
|
|
315
|
+
.from-text.trail { gap: 3px; overflow-x: auto; scrollbar-width: none; }
|
|
316
|
+
.from-text.trail::-webkit-scrollbar { display: none; }
|
|
317
|
+
.from-text a, .from-text b {
|
|
318
|
+
font: inherit;
|
|
319
|
+
font-size: .78rem;
|
|
320
|
+
font-weight: 600;
|
|
321
|
+
color: var(--ink);
|
|
322
|
+
text-decoration: none;
|
|
323
|
+
white-space: nowrap;
|
|
324
|
+
cursor: pointer;
|
|
325
|
+
}
|
|
326
|
+
.from-text a:hover { text-decoration: underline; }
|
|
327
|
+
/* The last piece is where you are: it does not lead anywhere, and it does not pretend to. */
|
|
328
|
+
.from-text b { cursor: default; opacity: .95; }
|
|
329
|
+
.from-text i { font-style: normal; font-size: .72rem; opacity: .4; }
|
|
330
|
+
.from-text .act { margin-left: auto; padding: 2px 7px; background: rgba(255, 255, 255, .7); }
|
|
331
|
+
.from-text .act + .x { margin-left: 4px; }
|
|
332
|
+
|
|
242
333
|
/* "7 more": it sits under the row it belongs to, off to one side. */
|
|
243
334
|
.more {
|
|
244
335
|
display: block;
|
|
@@ -328,6 +419,10 @@ body {
|
|
|
328
419
|
.crumb b { font: 600 .82rem var(--mono); color: var(--ink); }
|
|
329
420
|
.crumb i { font-style: normal; opacity: .5; }
|
|
330
421
|
.crumb em { margin-left: auto; font-style: normal; }
|
|
422
|
+
/* The path above a document is the way to its neighbours: each piece opens the folder it names,
|
|
423
|
+
in the list on the left. It stays grey — it is still the path, not a row of buttons. */
|
|
424
|
+
.crumb a.up { color: inherit; text-decoration: none; border-bottom: 1px dotted transparent; }
|
|
425
|
+
.crumb a.up:hover { color: var(--ink); border-bottom-color: var(--muted); }
|
|
331
426
|
|
|
332
427
|
/* ── What to do with it ───────────────────────────────────────────────
|
|
333
428
|
Print the document, take it away as it is, or have it laid out into a PDF. They sit here at
|
|
@@ -350,8 +445,115 @@ body {
|
|
|
350
445
|
}
|
|
351
446
|
.act:hover { color: var(--ink); border-color: #bdb6a5; }
|
|
352
447
|
.act svg { width: 14px; height: 14px; display: block; flex: none; }
|
|
448
|
+
/* A button this machine cannot honour stays where it is, faint, with the reason in its title. One
|
|
449
|
+
that vanished would leave you looking for it; one that did nothing would leave you wondering. */
|
|
450
|
+
.act[disabled] { opacity: .3; cursor: default; }
|
|
451
|
+
.act[disabled]:hover { color: var(--muted); border-color: var(--line); }
|
|
452
|
+
/* Save is the one that changes something, so it is the one that is filled in. */
|
|
453
|
+
.act.keep { background: var(--ink); border-color: var(--ink); color: #fff; font-weight: 600; }
|
|
454
|
+
.act.keep:hover { color: #fff; border-color: var(--ink); }
|
|
353
455
|
.cut { padding: 12px 32px; color: var(--muted); font-size: .82rem; font-style: italic; }
|
|
354
456
|
|
|
457
|
+
/* ── Searching inside one file ────────────────────────────────────────
|
|
458
|
+
Only where the browser's own find cannot go: a tree is mostly folded, and ⌘F says "not found"
|
|
459
|
+
for something that is three lines away behind a closed branch. */
|
|
460
|
+
.findin { display: flex; align-items: center; gap: 6px; margin-left: 12px; }
|
|
461
|
+
.findin input {
|
|
462
|
+
border: 1px solid var(--line);
|
|
463
|
+
background: #fff;
|
|
464
|
+
border-radius: 999px;
|
|
465
|
+
padding: 4px 11px;
|
|
466
|
+
font: inherit;
|
|
467
|
+
font-size: .72rem;
|
|
468
|
+
color: var(--ink);
|
|
469
|
+
outline: 0;
|
|
470
|
+
width: 170px;
|
|
471
|
+
}
|
|
472
|
+
.findin input:focus { border-color: #bdb6a5; }
|
|
473
|
+
.findin em {
|
|
474
|
+
font-style: normal;
|
|
475
|
+
font-size: .72rem;
|
|
476
|
+
color: var(--muted);
|
|
477
|
+
font-variant-numeric: tabular-nums;
|
|
478
|
+
min-width: 1.4em;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/* ── A JSON file, as a tree ───────────────────────────────────────────
|
|
482
|
+
The folding is `<details>`: the browser already knows how to do it, so there is no state here
|
|
483
|
+
and it works before a single line of script has run. The colours are the types — you learn them
|
|
484
|
+
in one glance and then never read them again, you just see them. */
|
|
485
|
+
.tree { padding: 18px 32px; font: .8rem/1.55 var(--mono); }
|
|
486
|
+
.json .kids { margin-left: 1.05rem; padding-left: .7rem; border-left: 1px solid var(--line); }
|
|
487
|
+
.json summary { cursor: pointer; list-style: none; padding: 1px 0; border-radius: 4px; }
|
|
488
|
+
.json summary::-webkit-details-marker { display: none; }
|
|
489
|
+
.json summary::before {
|
|
490
|
+
content: '▸';
|
|
491
|
+
display: inline-block;
|
|
492
|
+
width: .9em;
|
|
493
|
+
opacity: .5;
|
|
494
|
+
transition: transform .12s;
|
|
495
|
+
}
|
|
496
|
+
.json details[open] > summary::before { transform: rotate(90deg); }
|
|
497
|
+
.json summary:hover { background: rgba(0, 0, 0, .04); }
|
|
498
|
+
.json .leaf { padding: 1px 0 1px .9em; }
|
|
499
|
+
.json .key { font-weight: 600; color: #1e40af; }
|
|
500
|
+
.json .key::after { content: ':'; opacity: .45; font-weight: 400; margin-right: .35em; }
|
|
501
|
+
.json .brace { opacity: .45; margin-left: .35em; }
|
|
502
|
+
.json em { font-style: normal; margin-left: .6em; font-size: .9em; color: var(--muted); }
|
|
503
|
+
.json .v.str { color: #15803d; }
|
|
504
|
+
.json .v.str::before, .json .v.str::after { content: '"'; opacity: .4; }
|
|
505
|
+
.json .v.num { color: #b45309; }
|
|
506
|
+
.json .v.bool { color: #7c3aed; }
|
|
507
|
+
.json .v.nil, .json .v.empty { color: var(--muted); font-style: italic; }
|
|
508
|
+
.json mark { background: #fde68a; color: var(--ink); border-radius: 3px; padding: 0 1px; }
|
|
509
|
+
|
|
510
|
+
/* ── An .env file, as the table it always was ─────────────────────────
|
|
511
|
+
Two columns and one line each: the reason you open one is to compare a name with a value, and
|
|
512
|
+
as text the values start in a different place on every line. The comments stay — a comment
|
|
513
|
+
above a key is what the key is for, and a view that dropped it would be a different file. */
|
|
514
|
+
.env { width: calc(100% - 64px); margin: 20px 32px; border-collapse: collapse; font: .8rem var(--mono); }
|
|
515
|
+
.env th, .env td { padding: 6px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
|
516
|
+
.env th { width: 34%; font-weight: 600; color: #1e40af; overflow-wrap: anywhere; }
|
|
517
|
+
.env .val { color: var(--ink); overflow-wrap: anywhere; }
|
|
518
|
+
.env .val.none { color: var(--muted); font-style: italic; }
|
|
519
|
+
.env .note td { color: var(--muted); font-style: italic; border-bottom: 0; padding-top: 14px; }
|
|
520
|
+
.env .odd td { color: var(--muted); }
|
|
521
|
+
.env .gap td { padding: 0; height: 8px; border-bottom: 0; }
|
|
522
|
+
|
|
523
|
+
/* ── YAML, TOML, INI ──────────────────────────────────────────────────
|
|
524
|
+
Not a table: the nesting is the meaning, and flattening it would throw away the one thing you
|
|
525
|
+
opened the file to see. So the file keeps its shape and only gets its parts told apart. */
|
|
526
|
+
.conf {
|
|
527
|
+
margin: 0;
|
|
528
|
+
padding: 20px 32px;
|
|
529
|
+
font: .82rem/1.6 var(--mono);
|
|
530
|
+
white-space: pre;
|
|
531
|
+
overflow-x: auto;
|
|
532
|
+
}
|
|
533
|
+
.conf .key { color: #1e40af; font-weight: 600; }
|
|
534
|
+
.conf .sep, .conf .dash { opacity: .45; }
|
|
535
|
+
.conf .sec { color: #7c3aed; font-weight: 600; }
|
|
536
|
+
.conf .c { color: var(--muted); font-style: italic; }
|
|
537
|
+
|
|
538
|
+
/* ── Writing a file ───────────────────────────────────────────────────
|
|
539
|
+
In the pane where the document was, not in a window over it: a dialog is the right size for a
|
|
540
|
+
title and the wrong size for a README, and here the pane is already as tall as the file. */
|
|
541
|
+
.crumb.editing { background: var(--IDLE); }
|
|
542
|
+
.crumb.editing em { font-style: italic; }
|
|
543
|
+
.edit {
|
|
544
|
+
display: block;
|
|
545
|
+
width: 100%;
|
|
546
|
+
height: calc(100vh - 96px);
|
|
547
|
+
border: 0;
|
|
548
|
+
outline: 0;
|
|
549
|
+
resize: none;
|
|
550
|
+
padding: 20px 32px;
|
|
551
|
+
background: #fff;
|
|
552
|
+
color: var(--ink);
|
|
553
|
+
font: .82rem/1.6 var(--mono);
|
|
554
|
+
tab-size: 2;
|
|
555
|
+
}
|
|
556
|
+
|
|
355
557
|
/* ── The label ────────────────────────────────────────────────────────
|
|
356
558
|
What is written between the two rules at the top of the file. It is not text of the document —
|
|
357
559
|
it is what the document says it is — so it reads as a label: set apart, in a different colour,
|
|
@@ -511,7 +713,10 @@ body {
|
|
|
511
713
|
.split { display: block; }
|
|
512
714
|
.doc { overflow: visible; padding: 0 14mm; background: #fff; }
|
|
513
715
|
|
|
514
|
-
.md, .plain { padding: 0; font-size: 10.5pt; line-height: 1.5; }
|
|
716
|
+
.md, .plain, .tree, .conf { padding: 0; font-size: 10.5pt; line-height: 1.5; }
|
|
717
|
+
.env { width: 100%; margin: 0; }
|
|
718
|
+
/* A file half-written is not a document: there is nothing to put on paper. */
|
|
719
|
+
.edit { display: none !important; }
|
|
515
720
|
/* The label is hidden, but it is still there: the space that held it down is not. */
|
|
516
721
|
.plate + .md { padding-top: 0; }
|
|
517
722
|
.md a { color: #000; }
|
package/web/files.html
CHANGED
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
</button>
|
|
21
21
|
<input id="q" autocomplete="off" spellcheck="false" placeholder="search a file…">
|
|
22
22
|
</div>
|
|
23
|
+
<!-- The switch that lets the configuration through. Off by default: in a repository of prose
|
|
24
|
+
an `.env` is not what you came for, and on the day it is, it is the only thing you came
|
|
25
|
+
for. It sits beside the search and not inside it, and it says what it is in a word: put
|
|
26
|
+
in the pill as a bare glyph it read as part of the field's decoration, and nobody found
|
|
27
|
+
it. -->
|
|
28
|
+
<button type="button" class="nerd" id="nerd" aria-pressed="false"
|
|
29
|
+
title="Show configuration files too — .env, JSON, YAML, TOML, INI" aria-label="Show configuration files too">
|
|
30
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6l-5 6 5 6"/><path d="M16 6l5 6-5 6"/></svg>
|
|
31
|
+
config
|
|
32
|
+
</button>
|
|
23
33
|
<span class="count" id="count"></span>
|
|
24
34
|
</header>
|
|
25
35
|
|