k0-board 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +116 -1
- package/README.md +179 -18
- package/bin/k0-board.js +16 -0
- package/package.json +1 -1
- package/platform/contract.js +7 -1
- package/platform/darwin/index.js +1 -0
- package/platform/darwin/shell.js +4 -1
- package/platform/index.js +1 -0
- package/platform/linux/index.js +11 -0
- package/platform/linux/shell.js +10 -1
- package/platform/win32/index.js +1 -0
- package/platform/win32/shell.js +4 -1
- package/server/db.js +11 -0
- package/server/files.js +105 -14
- package/server/idle.js +56 -0
- package/server/index.js +90 -4
- package/server/launcher.js +5 -1
- package/server/paths.js +5 -4
- package/server/settings.js +121 -0
- package/web/board.css +12 -0
- package/web/board.js +22 -4
- package/web/conf.js +118 -0
- package/web/files.css +206 -1
- package/web/files.html +10 -0
- package/web/files.js +463 -62
- package/web/fuzzy.js +73 -21
- package/web/json.js +145 -0
package/server/files.js
CHANGED
|
@@ -36,6 +36,29 @@ const DOC_EXT = new Set([
|
|
|
36
36
|
|
|
37
37
|
export const isDoc = (p) => DOC_EXT.has(path.extname(p).toLowerCase())
|
|
38
38
|
|
|
39
|
+
// The other half of what is worth listing: the files that say how a project is set up. They are
|
|
40
|
+
// not documents — nobody prints an `.env` — but they are the ones you go looking for by hand, and
|
|
41
|
+
// until now the viewer was the only place in k0 you could not reach them from. They stay behind a
|
|
42
|
+
// switch in the page, because in a repository of prose they would only be noise.
|
|
43
|
+
const CONFIG_EXT = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
|
|
44
|
+
|
|
45
|
+
// `.env.local` has extension `.local`, and `.env` has no extension at all: this rule reads the
|
|
46
|
+
// name, not the end of it, or the commonest file of the lot would be the one that got away.
|
|
47
|
+
const ENV_NAME = /^\.env(\..+)?$/
|
|
48
|
+
|
|
49
|
+
export const isConfig = (p) =>
|
|
50
|
+
CONFIG_EXT.has(path.extname(p).toLowerCase()) || ENV_NAME.test(path.basename(p))
|
|
51
|
+
|
|
52
|
+
/** Everything the listing carries: documents to read, configuration to check. */
|
|
53
|
+
export const isListed = (p) => isDoc(p) || isConfig(p)
|
|
54
|
+
|
|
55
|
+
// What can be written back from the page. Configuration, and the notes kept next to it — never
|
|
56
|
+
// code: k0 is not an editor, and a `.js` changed in a browser tab with no undo and no syntax
|
|
57
|
+
// check is a good way to break a project without noticing.
|
|
58
|
+
const NOTE_EXT = new Set(['.md', '.markdown', '.mdx', '.txt'])
|
|
59
|
+
|
|
60
|
+
export const isEditable = (p) => NOTE_EXT.has(path.extname(p).toLowerCase()) || isConfig(p)
|
|
61
|
+
|
|
39
62
|
// ── What kind of file is this ─────────────────────────────────────────────────
|
|
40
63
|
const MARKDOWN = new Set(['.md', '.markdown', '.mdx'])
|
|
41
64
|
|
|
@@ -213,7 +236,17 @@ const cache = new Map()
|
|
|
213
236
|
*/
|
|
214
237
|
async function scan(root) {
|
|
215
238
|
const names = hasGit() && fs.existsSync(path.join(root, '.git')) ? await tracked(root) : null
|
|
216
|
-
return { git:
|
|
239
|
+
if (!names) return { git: false, ...walk(root, isListed) }
|
|
240
|
+
|
|
241
|
+
// git's listing is missing exactly the file all this was built for: an `.env` is ignored by
|
|
242
|
+
// definition, so `ls-files -co --exclude-standard` will never name it. So the disk is walked a
|
|
243
|
+
// second time for configuration alone — a handful of names, under the same bounds as the other
|
|
244
|
+
// walk — and what git already gave is kept as it is.
|
|
245
|
+
const fromGit = withStats(root, names)
|
|
246
|
+
const seen = new Set(fromGit.files.map((f) => f.p))
|
|
247
|
+
const extra = walk(root, isConfig)
|
|
248
|
+
for (const f of extra.files) if (!seen.has(f.p)) fromGit.files.push(f)
|
|
249
|
+
return { git: true, files: fromGit.files, truncated: fromGit.truncated || extra.truncated }
|
|
217
250
|
}
|
|
218
251
|
|
|
219
252
|
const tracked = (root) =>
|
|
@@ -226,14 +259,14 @@ function withStats(root, names) {
|
|
|
226
259
|
const files = []
|
|
227
260
|
let truncated = false
|
|
228
261
|
for (const p of names) {
|
|
229
|
-
if (!
|
|
262
|
+
if (!isListed(p)) continue
|
|
230
263
|
if (files.length >= MAX_FILES) {
|
|
231
264
|
truncated = true
|
|
232
265
|
break
|
|
233
266
|
}
|
|
234
267
|
try {
|
|
235
268
|
const s = fs.statSync(path.join(root, p))
|
|
236
|
-
if (s.isFile()) files.push(
|
|
269
|
+
if (s.isFile()) files.push(entry(p, s))
|
|
237
270
|
} catch {
|
|
238
271
|
/* deleted between the listing and now */
|
|
239
272
|
}
|
|
@@ -241,7 +274,18 @@ function withStats(root, names) {
|
|
|
241
274
|
return { files, truncated }
|
|
242
275
|
}
|
|
243
276
|
|
|
244
|
-
|
|
277
|
+
/**
|
|
278
|
+
* One row of the listing. `c` marks a configuration file, so the page can leave it out without
|
|
279
|
+
* having to ask a second time: one byte on the wire, one switch on the other side.
|
|
280
|
+
*/
|
|
281
|
+
const entry = (p, s) => {
|
|
282
|
+
const row = { p, m: Math.floor(s.mtimeMs), s: s.size }
|
|
283
|
+
if (isConfig(p)) row.c = 1
|
|
284
|
+
return row
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Everything under `root` that `pick` accepts. */
|
|
288
|
+
function walk(root, pick) {
|
|
245
289
|
const files = []
|
|
246
290
|
let truncated = false
|
|
247
291
|
|
|
@@ -258,18 +302,20 @@ function walk(root) {
|
|
|
258
302
|
truncated = true
|
|
259
303
|
return
|
|
260
304
|
}
|
|
261
|
-
|
|
305
|
+
// A directory whose name starts with a dot is skipped whole: that one rule removes .git,
|
|
306
|
+
// the caches and the virtual environments in a single stroke. A dotted *file* is not —
|
|
307
|
+
// `.env` is a dotted name, and it is the one this walk was taught to find.
|
|
262
308
|
const child = path.join(dir, e.name)
|
|
263
309
|
const childRel = rel ? `${rel}/${e.name}` : e.name
|
|
264
310
|
if (e.isDirectory()) {
|
|
265
|
-
if (!SKIP.has(e.name)) rec(child, childRel, depth + 1)
|
|
311
|
+
if (!e.name.startsWith('.') && !SKIP.has(e.name)) rec(child, childRel, depth + 1)
|
|
266
312
|
continue
|
|
267
313
|
}
|
|
268
314
|
// No symbolic links: a loop would send the walk round forever.
|
|
269
|
-
if (!e.isFile() || !
|
|
315
|
+
if (!e.isFile() || !pick(e.name)) continue
|
|
270
316
|
try {
|
|
271
317
|
const s = fs.statSync(child)
|
|
272
|
-
files.push(
|
|
318
|
+
files.push(entry(childRel, s))
|
|
273
319
|
} catch {
|
|
274
320
|
/* gone in the meantime */
|
|
275
321
|
}
|
|
@@ -392,9 +438,9 @@ export async function changed(root, headAtStart) {
|
|
|
392
438
|
.catch(() => []) // the starting commit may not be there any more
|
|
393
439
|
: [],
|
|
394
440
|
])
|
|
395
|
-
//
|
|
396
|
-
// things to read, when there is nothing to read at all.
|
|
397
|
-
return [...new Set([...now, ...mine])].filter(
|
|
441
|
+
// What the listing carries, here too: a "Changed 40" made of `.tsx` files would say there are
|
|
442
|
+
// forty things to read, when there is nothing to read at all.
|
|
443
|
+
return [...new Set([...now, ...mine])].filter(isListed)
|
|
398
444
|
}
|
|
399
445
|
|
|
400
446
|
// ── Searching inside the files ────────────────────────────────────────────────
|
|
@@ -406,6 +452,8 @@ export async function changed(root, headAtStart) {
|
|
|
406
452
|
// of a few KB, all already in the operating system's cache — it costs less than keeping an
|
|
407
453
|
// index up to date, and an index goes stale.
|
|
408
454
|
const TEXTY = new Set(['.md', '.markdown', '.mdx', '.txt', '.html', '.htm'])
|
|
455
|
+
/** Read only when the page asks for them: they are hidden by default, and so are their lines. */
|
|
456
|
+
const TEXTY_CONFIG = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
|
|
409
457
|
const GREP_FILES = 80 // how many results are enough: past that nobody scrolls anyway
|
|
410
458
|
const GREP_BYTES = 1 << 20
|
|
411
459
|
const SNIPPET = 200
|
|
@@ -421,8 +469,13 @@ export const plain = (s) =>
|
|
|
421
469
|
.toLowerCase()
|
|
422
470
|
.replace(/[\u00C0-\u024F]/g, (c) => c.normalize('NFD')[0])
|
|
423
471
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
472
|
+
/**
|
|
473
|
+
* The documents that contain all the words searched for, with the line they appear on.
|
|
474
|
+
*
|
|
475
|
+
* With `nerd` the configuration files are looked inside as well. It has to follow what the page
|
|
476
|
+
* is showing: finding a line in a file the listing is hiding would be a result you cannot open.
|
|
477
|
+
*/
|
|
478
|
+
export async function grep(root, query, nerd = false) {
|
|
426
479
|
const words = plain(query).split(/\s+/).filter(Boolean)
|
|
427
480
|
if (!words.length) return []
|
|
428
481
|
|
|
@@ -430,7 +483,10 @@ export async function grep(root, query) {
|
|
|
430
483
|
const hits = []
|
|
431
484
|
for (const f of files) {
|
|
432
485
|
if (hits.length >= GREP_FILES) break
|
|
433
|
-
if (
|
|
486
|
+
if (f.c && !nerd) continue
|
|
487
|
+
const ext = path.extname(f.p).toLowerCase()
|
|
488
|
+
const texty = TEXTY.has(ext) || (nerd && (TEXTY_CONFIG.has(ext) || ENV_NAME.test(path.basename(f.p))))
|
|
489
|
+
if (!texty || f.s > GREP_BYTES) continue
|
|
434
490
|
let text
|
|
435
491
|
try {
|
|
436
492
|
text = fs.readFileSync(path.join(root, f.p), 'utf8')
|
|
@@ -484,5 +540,40 @@ export function read(abs) {
|
|
|
484
540
|
return { kind, ...meta, text, truncated }
|
|
485
541
|
}
|
|
486
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Writes the file back, or refuses.
|
|
545
|
+
*
|
|
546
|
+
* `was` is the modification time the page was handed when it opened the file. If the file on disk
|
|
547
|
+
* has moved on since, the write does not happen: somebody else — a session, an editor, a script —
|
|
548
|
+
* has written it in the meantime, and overwriting silently would throw their work away. The page
|
|
549
|
+
* is told, and it still has what you typed.
|
|
550
|
+
*
|
|
551
|
+
* The bytes go to a neighbouring name and are then renamed over the original, which on the same
|
|
552
|
+
* filesystem is one indivisible step: an interrupted save leaves the old file whole rather than
|
|
553
|
+
* half the new one. The mode is carried over, or a `.env` at 600 would come back readable by all.
|
|
554
|
+
*/
|
|
555
|
+
export function write(abs, text, was) {
|
|
556
|
+
const s = fs.statSync(abs)
|
|
557
|
+
if (!s.isFile()) throw new Error('Not a file')
|
|
558
|
+
if (Math.floor(s.mtimeMs) !== was) {
|
|
559
|
+
const clash = new Error('This file changed on disk while you were editing it. Nothing was saved.')
|
|
560
|
+
clash.conflict = true
|
|
561
|
+
throw clash
|
|
562
|
+
}
|
|
563
|
+
const tmp = `${abs}.k0-tmp`
|
|
564
|
+
try {
|
|
565
|
+
fs.writeFileSync(tmp, text, { mode: s.mode & 0o777 })
|
|
566
|
+
fs.renameSync(tmp, abs)
|
|
567
|
+
} catch (e) {
|
|
568
|
+
fs.rmSync(tmp, { force: true })
|
|
569
|
+
throw e
|
|
570
|
+
}
|
|
571
|
+
const now = fs.statSync(abs)
|
|
572
|
+
return { size: now.size, mtime: Math.floor(now.mtimeMs) }
|
|
573
|
+
}
|
|
574
|
+
|
|
487
575
|
/** Show it in the file manager: the only way to open what cannot be shown here. */
|
|
488
576
|
export const reveal = (abs) => shell.revealInFileManager(abs)
|
|
577
|
+
|
|
578
|
+
/** A folder is opened rather than pointed at: what you want is to be inside it. */
|
|
579
|
+
export const openFolder = (abs) => shell.openInFileManager(abs)
|
package/server/idle.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which terminals have been sitting there long enough to be worth giving back.
|
|
3
|
+
*
|
|
4
|
+
* A session left open costs memory whether or not anybody is looking at it: the `claude` process,
|
|
5
|
+
* the MCP servers it started, the browsers those opened. `Close` on a card has always been the
|
|
6
|
+
* cure — it stops the session and shuts its window, leaves the card exactly where it is, and
|
|
7
|
+
* `Resume` picks the conversation up where it was. This is that same gesture, remembered for you.
|
|
8
|
+
*
|
|
9
|
+
* Only the deciding lives here. Nothing in this file kills anything, opens anything or reads a
|
|
10
|
+
* disk, which is what lets every rule below be proved without a machine to prove it on.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const HOUR = 3600000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The cards whose terminal should go, most of the rules being about what to leave alone.
|
|
17
|
+
*
|
|
18
|
+
* Yellow only — `IDLE`, the ball in your court. Not because the others are less forgotten but
|
|
19
|
+
* because of what closing them would destroy: a question and a finished plan are drawn by the
|
|
20
|
+
* terminal and are not in the transcript yet, so shutting that window throws away the very thing
|
|
21
|
+
* it was showing you. `WORKING` and `PLANNING` are somebody mid-thought. And a session Claude
|
|
22
|
+
* Code reports as `shell` is a shell you dropped into, which may well have a command of yours
|
|
23
|
+
* running in it — k0 does not know what, so k0 does not touch it.
|
|
24
|
+
*/
|
|
25
|
+
export function dueForClose({ cards, live, hours, now = Date.now() }) {
|
|
26
|
+
if (!(hours > 0)) return [] // switched off, and nothing to think about
|
|
27
|
+
const deadline = now - hours * HOUR
|
|
28
|
+
return cards.filter((card) => {
|
|
29
|
+
if (card.completed_at || !card.session_id || !card.session_alive) return false
|
|
30
|
+
if (card.status !== 'IDLE') return false
|
|
31
|
+
const session = live.get(card.session_id)
|
|
32
|
+
if (!session || session.status !== 'idle') return false
|
|
33
|
+
return lastSignOfLife(card, session) <= deadline
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The most recent sign that something happened here, from every source that keeps one, and the
|
|
39
|
+
* newest of them wins.
|
|
40
|
+
*
|
|
41
|
+
* Taking the maximum is the whole safety of this feature. Being wrong in that direction leaves a
|
|
42
|
+
* window open and costs a few hundred megabytes until tomorrow; being wrong the other way closes
|
|
43
|
+
* a terminal somebody was about to go back to. So a single stale clock can never be the one that
|
|
44
|
+
* decides.
|
|
45
|
+
*
|
|
46
|
+
* `updatedAt` and `statusUpdatedAt` come out of Claude Code's own `~/.claude/sessions/<pid>.json`,
|
|
47
|
+
* which k0 already reads every second and until now threw away. They move when the session really
|
|
48
|
+
* changes state and are not a heartbeat — a session that had been busy for twenty-three minutes
|
|
49
|
+
* was carrying timestamps twenty-three minutes old — so they say what they appear to say.
|
|
50
|
+
*
|
|
51
|
+
* `updated_at` matters for the case none of the others cover: resuming a card leaves the status at
|
|
52
|
+
* `IDLE`, so no new event is written and `status_since` stays as old as it was.
|
|
53
|
+
*/
|
|
54
|
+
export function lastSignOfLife(card, session) {
|
|
55
|
+
return Math.max(card.updated_at || 0, card.status_since || 0, session.updatedAt || 0, session.statusUpdatedAt || 0)
|
|
56
|
+
}
|
package/server/index.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
relayoutWindows,
|
|
17
17
|
} from './launcher.js'
|
|
18
18
|
import * as mode from './mode.js'
|
|
19
|
+
import * as settings from './settings.js'
|
|
20
|
+
import { dueForClose } from './idle.js'
|
|
19
21
|
import { listProjects, onDisk, projectName } from './projects.js'
|
|
20
22
|
import { scanSessions } from './sessions.js'
|
|
21
23
|
import * as git from './git.js'
|
|
@@ -25,7 +27,7 @@ import * as files from './files.js'
|
|
|
25
27
|
import * as pdf from './pdf.js'
|
|
26
28
|
import * as machine from './machine.js'
|
|
27
29
|
import * as servers from './servers.js'
|
|
28
|
-
import { report as platformReport } from '../platform/index.js'
|
|
30
|
+
import { report as platformReport, capabilities, why } from '../platform/index.js'
|
|
29
31
|
// The same front-matter reader the viewer uses: the PDF has to be called what the page is
|
|
30
32
|
// called, and two different readings of the same header would give two different names.
|
|
31
33
|
import { matter, titleOf } from '../web/md.js'
|
|
@@ -65,7 +67,50 @@ function tick() {
|
|
|
65
67
|
// The power levers are checked once a minute rather than every round: reading the power
|
|
66
68
|
// state costs processes, and the things it has to follow — the mains plugged in or out, the
|
|
67
69
|
// battery going down — do not change from one second to the next.
|
|
68
|
-
if (rounds
|
|
70
|
+
if (rounds % 60 === 0) mode.guard()
|
|
71
|
+
// The forgotten terminals, on the same cadence but half a minute off it, so the two jobs that
|
|
72
|
+
// only run once a minute never land in the same second as each other.
|
|
73
|
+
if (rounds % 60 === 30) sweepIdle(live)
|
|
74
|
+
rounds++
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Gives back the memory of the windows you have stopped using.
|
|
79
|
+
*
|
|
80
|
+
* It does exactly what the `Close` link on a card does, and on purpose the same three moves and
|
|
81
|
+
* not a path of its own: stop the session, shut the window, forget the window. The card stays
|
|
82
|
+
* where it is with the status it had, `session_id` is untouched, and `Resume` picks the
|
|
83
|
+
* conversation up where it was — a closed terminal is not a lost session.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately not awaited by `tick`: closing a terminal waits up to two seconds for the process
|
|
86
|
+
* to go, and the watching loop must not be held up by it. Hence the latch, the same one
|
|
87
|
+
* `machine.js` and `servers.js` keep for their own sampling — `setInterval` will happily start a
|
|
88
|
+
* second round on top of the first.
|
|
89
|
+
*/
|
|
90
|
+
let sweeping = false
|
|
91
|
+
async function sweepIdle(live) {
|
|
92
|
+
if (sweeping) return
|
|
93
|
+
const hours = settings.read().closeIdleTerminalsAfterHours
|
|
94
|
+
const due = dueForClose({ cards: db.listCards(), live, hours })
|
|
95
|
+
if (!due.length) return
|
|
96
|
+
sweeping = true
|
|
97
|
+
try {
|
|
98
|
+
for (const card of due) {
|
|
99
|
+
try {
|
|
100
|
+
await closeTerminal({ winId: card.terminal_window_id, pid: live.get(card.session_id)?.pid })
|
|
101
|
+
db.setTerminalWindow(card.id, null) // that window is gone; Resume opens a new one
|
|
102
|
+
db.setAutoClosed(card.id, true)
|
|
103
|
+
console.log(`k0 — "${card.title}" sat still for ${hours}h: its terminal is closed, Resume picks it up`)
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// One window that refuses to go must not take the others with it, and must certainly not
|
|
106
|
+
// take the server: nothing is awaiting this, so a rejection escaping here would end the
|
|
107
|
+
// process outright. It runs unattended — the next round is the retry.
|
|
108
|
+
console.error(`k0 — could not close the terminal of "${card.title}": ${String(err?.message || err)}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
sweeping = false
|
|
113
|
+
}
|
|
69
114
|
}
|
|
70
115
|
|
|
71
116
|
/** When somebody last looked at the board. */
|
|
@@ -186,6 +231,10 @@ function board() {
|
|
|
186
231
|
mode: mode.current(),
|
|
187
232
|
lid: mode.lid(),
|
|
188
233
|
reason: mode.reason(),
|
|
234
|
+
// After how long a forgotten terminal is closed, so the machine chip can say that it is
|
|
235
|
+
// watching and after how long. Zero means it is switched off, and then the chip says nothing
|
|
236
|
+
// rather than saying "0h" — a number that would read as "closes immediately".
|
|
237
|
+
closeIdleAfterHours: settings.read().closeIdleTerminalsAfterHours,
|
|
189
238
|
// What this machine can and cannot do, so the board greys out the rest and says why
|
|
190
239
|
// instead of offering a button that quietly does nothing.
|
|
191
240
|
platform: platformReport(),
|
|
@@ -292,6 +341,11 @@ function site(req, res, seg) {
|
|
|
292
341
|
// is a much smaller loss than that, so the header is gone.
|
|
293
342
|
const SANDBOX = 'sandbox'
|
|
294
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
|
+
|
|
295
349
|
// ── The recap of a window ────────────────────────────────────────────────────
|
|
296
350
|
// Held for a minute, and not as a saved result: the page asks for the facts, then asks for the
|
|
297
351
|
// write-up to start, then asks every second whether it is done. Reading every repository's git
|
|
@@ -491,8 +545,11 @@ async function api(req, res, url) {
|
|
|
491
545
|
const card = Number.isInteger(cardId) && cardId > 0 ? db.getCard(cardId) : null
|
|
492
546
|
// Searching inside the files: the page asks for it as you type, and it needs neither the
|
|
493
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'
|
|
494
551
|
if (url.searchParams.get('only') === 'text') {
|
|
495
|
-
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) })
|
|
496
553
|
}
|
|
497
554
|
const changed = await files.changed(root, card?.head_at_start)
|
|
498
555
|
if (url.searchParams.get('only') === 'changed') return send(res, 200, { changed, now: Date.now() })
|
|
@@ -505,6 +562,13 @@ async function api(req, res, url) {
|
|
|
505
562
|
truncated: all.truncated,
|
|
506
563
|
changed,
|
|
507
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
|
+
},
|
|
508
572
|
now: Date.now(),
|
|
509
573
|
})
|
|
510
574
|
}
|
|
@@ -560,10 +624,27 @@ async function api(req, res, url) {
|
|
|
560
624
|
return res.end(out)
|
|
561
625
|
}
|
|
562
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.
|
|
563
630
|
if (idRaw === 'reveal' && req.method === 'POST') {
|
|
564
|
-
await files.
|
|
631
|
+
if (fs.statSync(abs).isDirectory()) await files.openFolder(abs)
|
|
632
|
+
else await files.reveal(abs)
|
|
565
633
|
return send(res, 200, { ok: true })
|
|
566
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
|
+
}
|
|
567
648
|
if (!idRaw && req.method === 'GET') return send(res, 200, { path: rel, ...files.read(abs) })
|
|
568
649
|
}
|
|
569
650
|
|
|
@@ -623,6 +704,7 @@ async function api(req, res, url) {
|
|
|
623
704
|
const pid = readLiveSessions().get(card.session_id)?.pid
|
|
624
705
|
const out = await closeTerminal({ winId: card.terminal_window_id, pid })
|
|
625
706
|
db.setTerminalWindow(id, null) // that window is gone; Resume opens a new one
|
|
707
|
+
db.setAutoClosed(id, false) // this one was you: the card must not say otherwise
|
|
626
708
|
tick() // so the answer already carries the closed card, without waiting for the loop
|
|
627
709
|
return send(res, 200, { ...out, card: db.getCard(id) })
|
|
628
710
|
}
|
|
@@ -642,6 +724,7 @@ async function api(req, res, url) {
|
|
|
642
724
|
try {
|
|
643
725
|
const out = await launch({ card: db.getCard(id), sessionId, mode: resuming ? 'resume' : 'start' })
|
|
644
726
|
db.setTerminalWindow(id, out.winId)
|
|
727
|
+
db.setAutoClosed(id, false) // there is a window again: whoever shut the last one is history
|
|
645
728
|
tick()
|
|
646
729
|
return send(res, 200, { ...out, card: db.getCard(id) })
|
|
647
730
|
} catch (err) {
|
|
@@ -694,6 +777,9 @@ http
|
|
|
694
777
|
}
|
|
695
778
|
})
|
|
696
779
|
.listen(PORT, '127.0.0.1', () => {
|
|
780
|
+
// The settings file, written out with everything in it the first time k0 runs: it is the only
|
|
781
|
+
// list of what can be changed, so it has to exist before anybody goes looking for it.
|
|
782
|
+
settings.ensure()
|
|
697
783
|
tick()
|
|
698
784
|
// The mode is remembered, and at startup it puts the machine back as it was: the sleep
|
|
699
785
|
// levers and, where needed, the font size of terminals left open. The server restarts often
|
package/server/launcher.js
CHANGED
|
@@ -105,7 +105,11 @@ export async function launch({ card, sessionId, mode = 'start' }) {
|
|
|
105
105
|
const up = await waitForSession(sessionId)
|
|
106
106
|
|
|
107
107
|
if (autoSend) return { name, up, winId, pasted: true, autoSent: true }
|
|
108
|
-
|
|
108
|
+
// The prompt belongs to the beginning. Resuming picks up a conversation that answered it hours
|
|
109
|
+
// ago, and putting it back under the cursor is at best a stale instruction in the way — at
|
|
110
|
+
// worst it is sent, because where pasting is unavailable the fallback below types it. This
|
|
111
|
+
// matters more now that k0 closes forgotten terminals by itself: a Resume is no longer rare.
|
|
112
|
+
if (resume || !up || !card.prompt?.trim()) return { name, up, winId, pasted: false }
|
|
109
113
|
|
|
110
114
|
await waitForPrompt(winId)
|
|
111
115
|
|
package/server/paths.js
CHANGED
|
@@ -12,10 +12,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
12
12
|
* there is on borrowed time. Data now lives in a directory owned by the user, and the
|
|
13
13
|
* installed copy of the app is disposable.
|
|
14
14
|
*
|
|
15
|
-
* ~/.k0/k0.db
|
|
16
|
-
* ~/.k0/
|
|
17
|
-
* ~/.k0/
|
|
18
|
-
* ~/.k0/
|
|
15
|
+
* ~/.k0/k0.db the board
|
|
16
|
+
* ~/.k0/config.json the settings, written out with everything in it on the first run
|
|
17
|
+
* ~/.k0/app/ the installed copy of k0 (written by `k0-board install`)
|
|
18
|
+
* ~/.k0/logs/ service stdout/stderr
|
|
19
|
+
* ~/.k0/cache/ pasted images, the Chrome profile used for PDF export
|
|
19
20
|
*
|
|
20
21
|
* On Windows the same tree goes under %LOCALAPPDATA%\k0, which is the local-only,
|
|
21
22
|
* not-roamed location Windows expects for this kind of state.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { HOME } from './paths.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The few things about k0 you are allowed to change, and the file they live in.
|
|
7
|
+
*
|
|
8
|
+
* k0 has never had one of these, and this is deliberately not the first room of a settings
|
|
9
|
+
* panel. The board shows what changes during a working day; a number you set once and then never
|
|
10
|
+
* look at again is the opposite of that, and putting it on screen would cost the one page that
|
|
11
|
+
* has to stay readable in exchange for nothing.
|
|
12
|
+
*
|
|
13
|
+
* So it is a file, and the file IS the list: k0 writes it out with every setting already in it at
|
|
14
|
+
* its default, so that opening it is how you find out what there is to change. `k0-board doctor`
|
|
15
|
+
* prints the same thing, for anyone who would rather ask than look.
|
|
16
|
+
*
|
|
17
|
+
* It lives beside the database and not inside the app, because the app directory is disposable —
|
|
18
|
+
* `npx` unpacks into a cache npm may wipe, and `k0-board install` overwrites it — and a setting
|
|
19
|
+
* an update throws away is not a setting. And it is read again whenever it changes, so editing
|
|
20
|
+
* it takes effect on the next round rather than after a restart.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** `K0_CONFIG` is how the tests stay away from the real file, the way `K0_DB` does for the board. */
|
|
24
|
+
export const PATH = process.env.K0_CONFIG || path.join(HOME, 'config.json')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every setting there is, and what it is worth if you never touch the file. No default is written
|
|
28
|
+
* down anywhere else: this object is the whole answer to "and if I leave it alone?".
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULTS = {
|
|
31
|
+
closeIdleTerminalsAfterHours: 12,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The shortest timeout that is still a timeout. The sweep goes round once a minute, and a
|
|
36
|
+
* terminal you were typing into ten minutes ago is not one you have forgotten: below an hour this
|
|
37
|
+
* stops being a tidy-up and becomes a machine closing windows while you are using them. A number
|
|
38
|
+
* under it is raised rather than refused — the intent was clearly "be aggressive about it", and an
|
|
39
|
+
* hour is as aggressive as this is willing to be.
|
|
40
|
+
*/
|
|
41
|
+
export const MIN_HOURS = 1
|
|
42
|
+
|
|
43
|
+
/** The line k0 leaves at the top of the file, since JSON has nowhere else to put a word. */
|
|
44
|
+
const NOTE =
|
|
45
|
+
'k0 wrote this file and reads it again by itself whenever it changes — nothing to restart. ' +
|
|
46
|
+
'Delete a line to go back to its default. Hours at 0 switch that closing off entirely. ' +
|
|
47
|
+
'See Settings in the README.'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How long a terminal may sit there. Anything that is not a number falls back to the default
|
|
51
|
+
* rather than blowing up: this arrives from a file somebody edits by hand, and from there
|
|
52
|
+
* anything can arrive. Zero and below mean off, which is how the whole thing is switched off
|
|
53
|
+
* without a second switch to be kept in step with the first.
|
|
54
|
+
*/
|
|
55
|
+
export function idleHours(raw) {
|
|
56
|
+
if (raw === undefined || raw === null || raw === '') return DEFAULTS.closeIdleTerminalsAfterHours
|
|
57
|
+
const n = Number(raw)
|
|
58
|
+
if (!Number.isFinite(n)) return DEFAULTS.closeIdleTerminalsAfterHours
|
|
59
|
+
if (n <= 0) return 0
|
|
60
|
+
return Math.max(MIN_HOURS, n)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A file's worth of anything, turned into settings that can be trusted. Pure: the tests live here. */
|
|
64
|
+
export function normalise(raw) {
|
|
65
|
+
const o = raw && typeof raw === 'object' ? raw : {}
|
|
66
|
+
return { closeIdleTerminalsAfterHours: idleHours(o.closeIdleTerminalsAfterHours) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cached = null
|
|
70
|
+
let stamp = null // the mtime the cache was built from, or null for "there was no file"
|
|
71
|
+
let broken = false
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The settings in force. The file is only re-read when its mtime moves, so this can be called
|
|
75
|
+
* from the watching loop without costing a read a second, and changing the file still lands
|
|
76
|
+
* without a restart.
|
|
77
|
+
*/
|
|
78
|
+
export function read() {
|
|
79
|
+
let mtime = null
|
|
80
|
+
try {
|
|
81
|
+
mtime = fs.statSync(PATH).mtimeMs
|
|
82
|
+
} catch {
|
|
83
|
+
/* no file: the defaults hold, and there is nothing to remember */
|
|
84
|
+
}
|
|
85
|
+
if (cached && stamp === mtime) return cached
|
|
86
|
+
stamp = mtime
|
|
87
|
+
if (mtime === null) broken = false // a file that is not there is not a file that is broken
|
|
88
|
+
cached = normalise(mtime === null ? null : parse())
|
|
89
|
+
return cached
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parse() {
|
|
93
|
+
try {
|
|
94
|
+
broken = false
|
|
95
|
+
return JSON.parse(fs.readFileSync(PATH, 'utf8'))
|
|
96
|
+
} catch {
|
|
97
|
+
// Half-saved by an editor, or one comma too many. k0 goes on with the defaults rather than
|
|
98
|
+
// refusing to start, and `doctor` is where you find out the file is being ignored — silence
|
|
99
|
+
// would be the worst of both: a setting you believe is in force and is not.
|
|
100
|
+
broken = true
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Writes the file out, with everything in it, if it is not there yet. Called explicitly at
|
|
107
|
+
* startup rather than from an import: a module that writes to your disk merely because somebody
|
|
108
|
+
* mentioned it is a module nobody can reason about.
|
|
109
|
+
*/
|
|
110
|
+
export function ensure() {
|
|
111
|
+
if (fs.existsSync(PATH)) return false
|
|
112
|
+
fs.mkdirSync(path.dirname(PATH), { recursive: true })
|
|
113
|
+
fs.writeFileSync(PATH, JSON.stringify({ '//': NOTE, ...DEFAULTS }, null, 2) + '\n')
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What the doctor prints: where the file is, whether it could be read, and what is in force. */
|
|
118
|
+
export function status() {
|
|
119
|
+
const values = read()
|
|
120
|
+
return { path: PATH, exists: stamp !== null, broken, values }
|
|
121
|
+
}
|
package/web/board.css
CHANGED
|
@@ -88,6 +88,10 @@ body {
|
|
|
88
88
|
flex: none;
|
|
89
89
|
background: var(--calm);
|
|
90
90
|
}
|
|
91
|
+
/* That k0 is looking after the memory for you is worth knowing once and then forgetting: it sits
|
|
92
|
+
on the same chip, lighter than the numbers beside it, because the numbers are what you came to
|
|
93
|
+
read and this is only there so the closing is never a surprise. */
|
|
94
|
+
.gauge .idle { font-weight: 500; opacity: .6; margin-left: -2px; white-space: nowrap; }
|
|
91
95
|
.gauge.warn i { background: var(--warn); }
|
|
92
96
|
.gauge.bad i { background: var(--alert); }
|
|
93
97
|
/* Solid red only when it is critical: the one moment it is allowed to interrupt. */
|
|
@@ -383,10 +387,18 @@ body {
|
|
|
383
387
|
gap: 7px;
|
|
384
388
|
margin-bottom: 3px;
|
|
385
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. */
|
|
386
394
|
.postit .tag {
|
|
395
|
+
flex: 1;
|
|
387
396
|
font: 500 1.05rem/1 var(--hand);
|
|
388
397
|
color: rgba(0, 0, 0, .5);
|
|
389
398
|
min-width: 0;
|
|
399
|
+
overflow: hidden;
|
|
400
|
+
text-overflow: ellipsis;
|
|
401
|
+
white-space: nowrap;
|
|
390
402
|
}
|
|
391
403
|
/* The name is the session's: it is in monospace like the k0 mark at the top.
|
|
392
404
|
Monospace is heavier at the same size, which is why it drops to .94rem. */
|