k0-board 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude/skills/k0-import/SKILL.md +83 -0
  2. package/CHANGELOG.md +57 -0
  3. package/LICENSE +21 -0
  4. package/README.md +688 -0
  5. package/bin/k0-board.js +456 -0
  6. package/bin/prompt.js +41 -0
  7. package/menubar/Info.plist +25 -0
  8. package/menubar/K0MenuBar.swift +561 -0
  9. package/menubar/build.sh +28 -0
  10. package/package.json +45 -0
  11. package/platform/contract.js +134 -0
  12. package/platform/darwin/extras.js +254 -0
  13. package/platform/darwin/index.js +24 -0
  14. package/platform/darwin/metrics.js +59 -0
  15. package/platform/darwin/power.js +133 -0
  16. package/platform/darwin/service.js +143 -0
  17. package/platform/darwin/shell.js +59 -0
  18. package/platform/darwin/terminal.js +324 -0
  19. package/platform/index.js +97 -0
  20. package/platform/linux/index.js +60 -0
  21. package/platform/linux/metrics.js +88 -0
  22. package/platform/linux/power.js +137 -0
  23. package/platform/linux/service.js +111 -0
  24. package/platform/linux/shell.js +97 -0
  25. package/platform/linux/tray.py +229 -0
  26. package/platform/shared/command.js +26 -0
  27. package/platform/shared/ps.js +40 -0
  28. package/platform/shared/run.js +99 -0
  29. package/platform/shared/tmux.js +242 -0
  30. package/platform/win32/index.js +53 -0
  31. package/platform/win32/metrics.js +72 -0
  32. package/platform/win32/power.js +109 -0
  33. package/platform/win32/powershell.js +44 -0
  34. package/platform/win32/service.js +111 -0
  35. package/platform/win32/shell.js +64 -0
  36. package/platform/win32/terminal.js +209 -0
  37. package/platform/win32/tray.ps1 +159 -0
  38. package/server/db.js +262 -0
  39. package/server/files.js +488 -0
  40. package/server/git.js +157 -0
  41. package/server/index.js +636 -0
  42. package/server/launcher.js +121 -0
  43. package/server/machine.js +213 -0
  44. package/server/mode.js +182 -0
  45. package/server/paths.js +67 -0
  46. package/server/pdf.js +110 -0
  47. package/server/projects.js +152 -0
  48. package/server/sessions.js +341 -0
  49. package/server/watcher.js +200 -0
  50. package/web/base.css +134 -0
  51. package/web/board.css +561 -0
  52. package/web/board.js +777 -0
  53. package/web/favicon.js +41 -0
  54. package/web/favicon.svg +8 -0
  55. package/web/files.css +535 -0
  56. package/web/files.html +63 -0
  57. package/web/files.js +877 -0
  58. package/web/fonts/caveat-latin-ext.woff2 +0 -0
  59. package/web/fonts/caveat-latin.woff2 +0 -0
  60. package/web/fuzzy.js +57 -0
  61. package/web/index.html +112 -0
  62. package/web/md.js +238 -0
  63. package/web/mentions.js +188 -0
  64. package/web/refs.js +176 -0
  65. package/web/title.js +26 -0
  66. package/web/units.js +33 -0
  67. package/web/view.js +248 -0
@@ -0,0 +1,488 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { run, which } from '../platform/shared/run.js'
4
+ import { shell } from '../platform/index.js'
5
+
6
+ // ── A repository's files ──────────────────────────────────────────────────────
7
+ // The only place that reads the projects' disk, as git.js is the only one that talks to git.
8
+ // It answers two questions: what is in here, and what does this file say. No index and no
9
+ // search server: the listing is rebuilt every round and the browser does the searching, on
10
+ // the names.
11
+
12
+ const GIT = () => which('git', ['/usr/bin/git', '/usr/local/bin/git', 'C:\\Program Files\\Git\\cmd\\git.exe'])
13
+ const hasGit = () => !!GIT()
14
+
15
+ const OPTS = { timeout: 8000, maxBuffer: 1 << 24 }
16
+
17
+ const TTL = 5000 // how long a listing is worth before it is rebuilt
18
+ const MAX_FILES = 20000 // past that, the listing is cut and says so
19
+ const MAX_DEPTH = 12
20
+ const MAX_TEXT = 2 << 20 // 2 MB: above that it is no longer a document to read
21
+
22
+ // Directories that are nobody's work. Only needed where git is not: in a repository the
23
+ // listing comes from git, which already knows the .gitignore.
24
+ const SKIP = new Set([
25
+ 'node_modules', 'dist', 'build', 'out', 'coverage', 'vendor', 'target', 'Pods', '__pycache__', 'venv',
26
+ ])
27
+
28
+ // Only documents go in the listing: things you read and print. In a real repository code is
29
+ // 95% of the files, and among 2,499 `.tsx` files the document you were after is gone. This
30
+ // closes nothing off: a code file reached by a link inside a document still opens, and so do
31
+ // images — otherwise an `![](images/x.png)` would be a hole in the page.
32
+ const DOC_EXT = new Set([
33
+ '.md', '.markdown', '.mdx', '.html', '.htm', '.txt', '.rtf',
34
+ '.pdf', '.docx', '.doc', '.odt', '.pages',
35
+ ])
36
+
37
+ export const isDoc = (p) => DOC_EXT.has(path.extname(p).toLowerCase())
38
+
39
+ // ── What kind of file is this ─────────────────────────────────────────────────
40
+ const MARKDOWN = new Set(['.md', '.markdown', '.mdx'])
41
+
42
+ const IMAGE = {
43
+ '.png': 'image/png',
44
+ '.jpg': 'image/jpeg',
45
+ '.jpeg': 'image/jpeg',
46
+ '.gif': 'image/gif',
47
+ '.webp': 'image/webp',
48
+ '.svg': 'image/svg+xml',
49
+ '.avif': 'image/avif',
50
+ '.bmp': 'image/bmp',
51
+ '.ico': 'image/x-icon',
52
+ }
53
+
54
+ // A page is made to be looked at, not read as source: like a PDF, it opens inside the page in
55
+ // a frame of its own. You want to see the source when you read a `.js`; of an `.html` you want
56
+ // to see the page.
57
+ const WEB_PAGE = new Set(['.html', '.htm'])
58
+
59
+ const TEXT_EXT = new Set([
60
+ '.txt', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json', '.jsonl', '.css', '.scss', '.xml',
61
+ '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf', '.sh', '.zsh', '.bash', '.py', '.rb', '.go', '.rs',
62
+ '.swift', '.java', '.kt', '.c', '.h', '.cpp', '.hpp', '.sql', '.csv', '.tsv', '.env', '.log', '.gitignore',
63
+ ])
64
+
65
+ // The ones already recognisable by name that are not worth tasting. Word and Excel documents
66
+ // belong here: they are zip files in disguise, and showing them would show mush.
67
+ const BINARY_EXT = new Set([
68
+ '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt', '.pages', '.numbers', '.key', '.zip', '.gz', '.tgz',
69
+ '.tar', '.dmg', '.pkg', '.db', '.sqlite', '.woff', '.woff2', '.ttf', '.otf', '.mp4', '.mov', '.mp3',
70
+ '.wav', '.heic', '.psd', '.ai', '.eps',
71
+ ])
72
+
73
+ /**
74
+ * The type is decided by the extension, and only when the extension says nothing are the
75
+ * first bytes tasted: a `Makefile` or a `Dockerfile` is text you read, and without the taste
76
+ * test they would end up among the binaries.
77
+ */
78
+ export function kindOf(file, head) {
79
+ const ext = path.extname(file).toLowerCase()
80
+ if (MARKDOWN.has(ext)) return 'markdown'
81
+ if (IMAGE[ext]) return 'image'
82
+ if (ext === '.pdf') return 'pdf'
83
+ if (WEB_PAGE.has(ext)) return 'html'
84
+ if (TEXT_EXT.has(ext)) return 'text'
85
+ if (BINARY_EXT.has(ext)) return 'binary'
86
+ return looksLikeText(head) ? 'text' : 'binary'
87
+ }
88
+
89
+ // What a page drags along with it: if the type is not right the browser does not apply the
90
+ // stylesheet and does not run anything, and the page comes out naked.
91
+ const ASSET = {
92
+ '.html': 'text/html; charset=utf-8',
93
+ '.htm': 'text/html; charset=utf-8',
94
+ '.css': 'text/css; charset=utf-8',
95
+ '.js': 'text/javascript; charset=utf-8',
96
+ '.mjs': 'text/javascript; charset=utf-8',
97
+ '.json': 'application/json; charset=utf-8',
98
+ '.xml': 'application/xml; charset=utf-8',
99
+ '.txt': 'text/plain; charset=utf-8',
100
+ '.csv': 'text/csv; charset=utf-8',
101
+ '.woff': 'font/woff',
102
+ '.woff2': 'font/woff2',
103
+ '.ttf': 'font/ttf',
104
+ '.otf': 'font/otf',
105
+ '.mp4': 'video/mp4',
106
+ '.mp3': 'audio/mpeg',
107
+ '.wav': 'audio/wav',
108
+ }
109
+
110
+ export function mimeOf(file) {
111
+ const ext = path.extname(file).toLowerCase()
112
+ if (IMAGE[ext]) return IMAGE[ext]
113
+ if (ext === '.pdf') return 'application/pdf'
114
+ if (ASSET[ext]) return ASSET[ext]
115
+ return 'application/octet-stream'
116
+ }
117
+
118
+ /** A real page, one the browser would draw instead of showing its text. */
119
+ export const isPage = (file) => mimeOf(file).startsWith('text/html')
120
+
121
+ /**
122
+ * The header that tells the browser "do not look at this, download it — and call it this".
123
+ *
124
+ * The name goes in twice, and that is not a mistake: the simple form for old browsers, which
125
+ * only holds ASCII, and the `filename*` form for everything else, which carries accents
126
+ * intact. Slashes never go in — a name with a path inside it would write the file somewhere
127
+ * else — and neither do newlines, which would split the header in two.
128
+ */
129
+ export function attachment(name) {
130
+ const clean = String(name).replace(/[/\\]+/g, '-').replace(/[\r\n"]+/g, '').trim() || 'file'
131
+ const plainName = clean.replace(/[^\x20-\x7e]+/g, '_')
132
+ return `attachment; filename="${plainName}"; filename*=UTF-8''${encodeURIComponent(clean)}`
133
+ }
134
+
135
+ /** A zero byte does not belong in a text file, and neither does broken UTF-8. */
136
+ function looksLikeText(head) {
137
+ if (!head?.length) return true // empty file: empty text, not a binary
138
+ if (head.includes(0)) return false
139
+ return !head.toString('utf8').includes('\uFFFD')
140
+ }
141
+
142
+ // ── The path guard ────────────────────────────────────────────────────────────
143
+ const realpath = (p) => {
144
+ try {
145
+ return fs.realpathSync(p)
146
+ } catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ const inside = (base, p) => p === base || p.startsWith(base + path.sep)
152
+
153
+ /**
154
+ * The real path of `rel` inside `root`, or null if it tries to get out.
155
+ * It is checked twice: first the path as written — that is where `../../.ssh/id_rsa` lives —
156
+ * and then where it really points, which is where the symbolic link planted to lead out
157
+ * lives. That the root is one of the allowed ones is the caller's job: here it is assumed.
158
+ */
159
+ export function safePath(root, rel) {
160
+ if (typeof root !== 'string' || typeof rel !== 'string' || rel.includes('\0')) return null
161
+ const base = realpath(root)
162
+ if (!base) return null
163
+ const target = path.resolve(base, rel)
164
+ if (!inside(base, target)) return null
165
+ const real = realpath(target)
166
+ if (real && !inside(base, real)) return null
167
+ return target
168
+ }
169
+
170
+ /**
171
+ * Which of these paths really exist and are things the viewer can show.
172
+ *
173
+ * It exists for one thing: documents name files that are not in the listing. The `SKIP`
174
+ * directories are there so the listing is not full of generated things, but in a directory of
175
+ * documents `out/` holds the real PDFs, the ones you print. When it is a document naming them,
176
+ * that file counts: the document said so, not the directory.
177
+ *
178
+ * It is not a shortcut for reading the disk: `safePath` keeps everything inside the
179
+ * repository, `isDoc` keeps code out, and hidden paths do not pass — `.claude/` and friends
180
+ * are configuration, not things to read.
181
+ */
182
+ export function exist(root, paths) {
183
+ const out = []
184
+ for (const rel of Array.isArray(paths) ? paths.slice(0, MAX_ASK) : []) {
185
+ if (typeof rel !== 'string' || !isDoc(rel)) continue
186
+ if (rel.split('/').some((s) => s.startsWith('.'))) continue
187
+ const abs = safePath(root, rel)
188
+ if (!abs) continue
189
+ try {
190
+ if (fs.statSync(abs).isFile()) out.push(rel)
191
+ } catch {
192
+ /* not there: it simply does not come back */
193
+ }
194
+ }
195
+ return out
196
+ }
197
+
198
+ /** How many paths are accepted in a single question. */
199
+ const MAX_ASK = 60
200
+
201
+ // ── The listing ───────────────────────────────────────────────────────────────
202
+ /** path → { at, value, promise } — same shape as the cache in git.js */
203
+ const cache = new Map()
204
+
205
+ /**
206
+ * Every file in the repository, with when it was last touched.
207
+ *
208
+ * Where there is git, git provides the listing — `ls-files -co --exclude-standard` costs one
209
+ * process and gives `.gitignore` support for free, which by hand would be a second
210
+ * implementation to keep in step. Where there is no git we walk the disk skipping everything
211
+ * that starts with a dot: one rule that in a single stroke removes `.git`, the caches, the
212
+ * virtual environments and k0's own worktrees.
213
+ */
214
+ async function scan(root) {
215
+ const names = hasGit() && fs.existsSync(path.join(root, '.git')) ? await tracked(root) : null
216
+ return { git: !!names, ...(names ? withStats(root, names) : walk(root)) }
217
+ }
218
+
219
+ const tracked = (root) =>
220
+ run(GIT(), ['--no-optional-locks', '-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], OPTS)
221
+ .then((out) => out.split('\0').filter(Boolean))
222
+ .catch(() => null) // broken repository: fall back to the walk
223
+
224
+ /** The names git gave, with a date and a size on them. Anything gone is dropped. */
225
+ function withStats(root, names) {
226
+ const files = []
227
+ let truncated = false
228
+ for (const p of names) {
229
+ if (!isDoc(p)) continue
230
+ if (files.length >= MAX_FILES) {
231
+ truncated = true
232
+ break
233
+ }
234
+ try {
235
+ const s = fs.statSync(path.join(root, p))
236
+ if (s.isFile()) files.push({ p, m: Math.floor(s.mtimeMs), s: s.size })
237
+ } catch {
238
+ /* deleted between the listing and now */
239
+ }
240
+ }
241
+ return { files, truncated }
242
+ }
243
+
244
+ function walk(root) {
245
+ const files = []
246
+ let truncated = false
247
+
248
+ const rec = (dir, rel, depth) => {
249
+ if (truncated || depth > MAX_DEPTH) return
250
+ let entries
251
+ try {
252
+ entries = fs.readdirSync(dir, { withFileTypes: true })
253
+ } catch {
254
+ return // unreadable directory: carry on
255
+ }
256
+ for (const e of entries) {
257
+ if (files.length >= MAX_FILES) {
258
+ truncated = true
259
+ return
260
+ }
261
+ if (e.name.startsWith('.')) continue
262
+ const child = path.join(dir, e.name)
263
+ const childRel = rel ? `${rel}/${e.name}` : e.name
264
+ if (e.isDirectory()) {
265
+ if (!SKIP.has(e.name)) rec(child, childRel, depth + 1)
266
+ continue
267
+ }
268
+ // No symbolic links: a loop would send the walk round forever.
269
+ if (!e.isFile() || !isDoc(e.name)) continue
270
+ try {
271
+ const s = fs.statSync(child)
272
+ files.push({ p: childRel, m: Math.floor(s.mtimeMs), s: s.size })
273
+ } catch {
274
+ /* gone in the meantime */
275
+ }
276
+ }
277
+ }
278
+
279
+ rec(root, '', 0)
280
+ return { files, truncated }
281
+ }
282
+
283
+ /**
284
+ * This directory's listing. Whoever asks gets what is there while the re-read starts for the
285
+ * next round, the way git.js does it. Only the first time is there a real wait: without it,
286
+ * the page would open empty.
287
+ */
288
+ export function list(root) {
289
+ const e = cache.get(root) ?? { at: 0, value: null, promise: null }
290
+ if (!e.promise && Date.now() - e.at >= TTL) {
291
+ e.promise = scan(root)
292
+ .catch(() => ({ git: false, files: [], truncated: false }))
293
+ .then((value) => {
294
+ cache.set(root, { at: Date.now(), value, promise: null })
295
+ return value
296
+ })
297
+ cache.set(root, e)
298
+ }
299
+ return e.value ?? e.promise
300
+ }
301
+
302
+ // ── Is there anything to read in here? ────────────────────────────────────────
303
+ // Under your home there are also directories that are only installed software. They have no
304
+ // `.git`, Claude has never worked in them, and above all they contain not one document — while
305
+ // a directory of notes has dozens. That is the difference that matters: the repository list
306
+ // exists so you can open documents, so the directories that have some are the ones that belong
307
+ // in it.
308
+ //
309
+ // It looks at the surface and stops at the first hit: two levels, few entries, and the answer
310
+ // is kept for five minutes. A directory does not change its nature every second.
311
+ const PROBE_DEPTH = 2
312
+ const PROBE_MAX = 400
313
+ const PROBE_TTL = 300000
314
+
315
+ const probed = new Map() // path → { at, value }
316
+
317
+ export function hasDocs(dir) {
318
+ const e = probed.get(dir)
319
+ if (e && Date.now() - e.at < PROBE_TTL) return e.value
320
+ const value = look(dir, 0, { seen: 0 })
321
+ probed.set(dir, { at: Date.now(), value })
322
+ return value
323
+ }
324
+
325
+ function look(dir, depth, budget) {
326
+ let entries
327
+ try {
328
+ entries = fs.readdirSync(dir, { withFileTypes: true })
329
+ } catch {
330
+ return false
331
+ }
332
+ const sub = []
333
+ for (const e of entries) {
334
+ if (++budget.seen > PROBE_MAX) return false
335
+ if (e.name.startsWith('.') || SKIP.has(e.name)) continue
336
+ if (e.isFile()) {
337
+ if (isDoc(e.name)) return true
338
+ } else if (e.isDirectory() && depth < PROBE_DEPTH) sub.push(e.name)
339
+ }
340
+ // Files before directories: a document at the surface settles the question immediately.
341
+ for (const name of sub) if (look(path.join(dir, name), depth + 1, budget)) return true
342
+ return false
343
+ }
344
+
345
+ // ── What has changed ──────────────────────────────────────────────────────────
346
+ /**
347
+ * The paths from the lines of `status --porcelain=v2 -z`. The fields before the path are a
348
+ * fixed number for each kind of line; with `-z` a rename takes two records, and the second is
349
+ * the old name, which is no longer on disk.
350
+ */
351
+ export function parseChanged(out) {
352
+ const rec = String(out ?? '').split('\0')
353
+ const paths = []
354
+ for (let i = 0; i < rec.length; i++) {
355
+ const r = rec[i]
356
+ if (!r) continue
357
+ if (r[0] === '1') paths.push(after(r, 8))
358
+ else if (r[0] === '2') {
359
+ paths.push(after(r, 9))
360
+ i++ // the next record is the name it was renamed from: it no longer exists
361
+ } else if (r[0] === 'u') paths.push(after(r, 10))
362
+ else if (r[0] === '?') paths.push(after(r, 1))
363
+ }
364
+ return paths.filter(Boolean)
365
+ }
366
+
367
+ /** What comes after the nth space: the path can contain spaces of its own. */
368
+ function after(line, n) {
369
+ let i = -1
370
+ for (let k = 0; k < n; k++) {
371
+ i = line.indexOf(' ', i + 1)
372
+ if (i === -1) return ''
373
+ }
374
+ return line.slice(i + 1)
375
+ }
376
+
377
+ /**
378
+ * The files this session has touched: the ones still hanging in the working tree, plus the
379
+ * ones inside commits made since it started. The working tree belongs to everybody and not
380
+ * only to it — the same limit the git mark on the card already has — but it remains the right
381
+ * answer to the question "what has changed here, now".
382
+ */
383
+ export async function changed(root, headAtStart) {
384
+ if (!hasGit() || !fs.existsSync(path.join(root, '.git'))) return []
385
+ const [now, mine] = await Promise.all([
386
+ run(GIT(), ['--no-optional-locks', '-C', root, 'status', '--porcelain=v2', '-z'], OPTS)
387
+ .then((out) => parseChanged(out))
388
+ .catch(() => []),
389
+ headAtStart
390
+ ? run(GIT(), ['--no-optional-locks', '-C', root, 'diff', '--name-only', '-z', headAtStart, 'HEAD'], OPTS)
391
+ .then((out) => out.split('\0').filter(Boolean))
392
+ .catch(() => []) // the starting commit may not be there any more
393
+ : [],
394
+ ])
395
+ // Documents only here too: a "Changed 40" made of `.tsx` files would say there are forty
396
+ // things to read, when there is nothing to read at all.
397
+ return [...new Set([...now, ...mine])].filter(isDoc)
398
+ }
399
+
400
+ // ── Searching inside the files ────────────────────────────────────────────────
401
+ // A document's name is almost never what makes you remember it. What stays with you about a
402
+ // letter is "the one where the strap broke", and the file is called `2026-07-28_reply.md`:
403
+ // searching the names does not find it, and it looks like it does not exist.
404
+ //
405
+ // No index: the documents are read and looked inside, every time. They are a few hundred files
406
+ // of a few KB, all already in the operating system's cache — it costs less than keeping an
407
+ // index up to date, and an index goes stale.
408
+ const TEXTY = new Set(['.md', '.markdown', '.mdx', '.txt', '.html', '.htm'])
409
+ const GREP_FILES = 80 // how many results are enough: past that nobody scrolls anyway
410
+ const GREP_BYTES = 1 << 20
411
+ const SNIPPET = 200
412
+
413
+ /**
414
+ * Lower case and without accents, but **letter by letter**: `é` becomes `e` and stays a single
415
+ * character. It matters because the positions found here also have to hold on the real text —
416
+ * that is how the page knows what to highlight — and a normalisation that lengthened the
417
+ * string would knock every one of them out of step.
418
+ */
419
+ export const plain = (s) =>
420
+ String(s ?? '')
421
+ .toLowerCase()
422
+ .replace(/[\u00C0-\u024F]/g, (c) => c.normalize('NFD')[0])
423
+
424
+ /** The documents that contain all the words searched for, with the line they appear on. */
425
+ export async function grep(root, query) {
426
+ const words = plain(query).split(/\s+/).filter(Boolean)
427
+ if (!words.length) return []
428
+
429
+ const { files } = await list(root)
430
+ const hits = []
431
+ for (const f of files) {
432
+ if (hits.length >= GREP_FILES) break
433
+ if (!TEXTY.has(path.extname(f.p).toLowerCase()) || f.s > GREP_BYTES) continue
434
+ let text
435
+ try {
436
+ text = fs.readFileSync(path.join(root, f.p), 'utf8')
437
+ } catch {
438
+ continue
439
+ }
440
+ // All the words in the file, not necessarily on the same line: "strap broke" has to find
441
+ // the letter even if the two words are three paragraphs apart.
442
+ const flat = plain(text)
443
+ if (!words.every((w) => flat.includes(w))) continue
444
+ hits.push({ p: f.p, m: f.m, s: f.s, ...quote(text, words[0]) })
445
+ }
446
+ return hits
447
+ }
448
+
449
+ /** The line the word appears on, cut to size, and where it sits inside that piece. */
450
+ function quote(text, word) {
451
+ for (const row of text.split('\n')) {
452
+ const i = plain(row).indexOf(word)
453
+ if (i === -1) continue
454
+ const from = Math.max(0, i - 60)
455
+ const cut = row.slice(from, from + SNIPPET)
456
+ const lead = cut.length - cut.trimStart().length
457
+ return { line: cut.trim(), at: i - from - lead, len: word.length }
458
+ }
459
+ return { line: '', at: 0, len: 0 }
460
+ }
461
+
462
+ // ── Reading a file ────────────────────────────────────────────────────────────
463
+ /** The contents, or as much of them as can be shown. `abs` has already passed the guard. */
464
+ export function read(abs) {
465
+ const s = fs.statSync(abs)
466
+ if (!s.isFile()) throw new Error('Not a file')
467
+ const meta = { size: s.size, mtime: Math.floor(s.mtimeMs) }
468
+
469
+ const head = Buffer.alloc(Math.min(4096, s.size))
470
+ if (head.length) {
471
+ const fd = fs.openSync(abs, 'r')
472
+ try {
473
+ fs.readSync(fd, head, 0, head.length, 0)
474
+ } finally {
475
+ fs.closeSync(fd)
476
+ }
477
+ }
478
+
479
+ const kind = kindOf(abs, head)
480
+ if (kind !== 'markdown' && kind !== 'text') return { kind, ...meta }
481
+ // Cut, not quietly truncated: the page says so.
482
+ const truncated = s.size > MAX_TEXT
483
+ const text = truncated ? fs.readFileSync(abs).subarray(0, MAX_TEXT).toString('utf8') : fs.readFileSync(abs, 'utf8')
484
+ return { kind, ...meta, text, truncated }
485
+ }
486
+
487
+ /** Show it in the file manager: the only way to open what cannot be shown here. */
488
+ export const reveal = (abs) => shell.revealInFileManager(abs)
package/server/git.js ADDED
@@ -0,0 +1,157 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { run, which } from '../platform/shared/run.js'
4
+
5
+ // ── Git ───────────────────────────────────────────────────────────────────────
6
+ // The only place that talks to git. It exists to answer one question: is what is in here
7
+ // safe, or is it still hanging somewhere?
8
+ //
9
+ // k0 does NOT fetch. It only looks at what the local git already knows about the remotes: for
10
+ // "did I push it?" that is exact and instant — pushing updates the remote reference by itself
11
+ // — and in exchange it never touches the network, your credentials, or your patience.
12
+
13
+ const GIT = () => which('git', ['/usr/bin/git', '/usr/local/bin/git', 'C:\\Program Files\\Git\\cmd\\git.exe'])
14
+ const available = () => !!GIT()
15
+
16
+ const OPTS = { timeout: 4000, maxBuffer: 1 << 20 }
17
+
18
+ const TTL = 5000 // how long a reading is worth before it is taken again
19
+ // The last column of the board lists every repository, which is dozens of them and none of
20
+ // them working: those are looked at rarely and a few at a time. At five seconds it would be
21
+ // eighty git processes every round, on a machine that is already struggling.
22
+ const SLOW_TTL = 60000
23
+ const SLOW_BATCH = 4
24
+ const MAX_UNPUSHED = 200 // past that there is no point knowing: the big number is the alarm
25
+
26
+ /** path → { at, running, value } */
27
+ const cache = new Map()
28
+ /** Remotes never change: read once per directory and kept. */
29
+ const remotes = new Map()
30
+
31
+ // ── Reading ───────────────────────────────────────────────────────────────────
32
+ /**
33
+ * The branch and how many files are touched, from `status --porcelain=v2 --branch`.
34
+ * Lines starting with `#` are branch information; everything else — `1`, `2`, `u`, `?` — is a
35
+ * file: modified, conflicted, or never seen before.
36
+ */
37
+ export function parseStatus(out) {
38
+ let branch = null
39
+ let dirty = 0
40
+ for (const line of String(out ?? '').split('\n')) {
41
+ if (!line) continue
42
+ if (line[0] === '#') {
43
+ const m = /^# branch\.head (.+)$/.exec(line)
44
+ if (m && m[1] !== '(detached)') branch = m[1]
45
+ continue
46
+ }
47
+ dirty++
48
+ }
49
+ return { branch, dirty }
50
+ }
51
+
52
+ /**
53
+ * How many of those unpushed commits arrived AFTER the session started. The list is
54
+ * newest-first, so they are the ones before `headAtStart`. If `headAtStart` is not in the list
55
+ * it was already pushed, and all of them are the session's. Returns null when it cannot be
56
+ * known, and then the card says nothing about it.
57
+ */
58
+ export function sessionShare(shas, headAtStart) {
59
+ if (!Array.isArray(shas) || !shas.length) return 0
60
+ if (!headAtStart) return null
61
+ const i = shas.indexOf(headAtStart)
62
+ return i === -1 ? shas.length : i
63
+ }
64
+
65
+ async function hasRemote(dir) {
66
+ if (remotes.has(dir)) return remotes.get(dir)
67
+ const yes = await run(GIT(), ['-C', dir, 'remote'], OPTS)
68
+ .then((out) => !!out.trim())
69
+ .catch(() => false)
70
+ remotes.set(dir, yes)
71
+ return yes
72
+ }
73
+
74
+ async function read(dir) {
75
+ // No `.git` here: either it is not a repository, or it is a worktree that has been
76
+ // destroyed. Costs nothing and saves launching git for nothing every five seconds.
77
+ if (!fs.existsSync(path.join(dir, '.git'))) return null
78
+
79
+ // The three in parallel: two processes in a queue would double the wait for nothing. After
80
+ // the first round `hasRemote` answers from memory and it is back to two processes.
81
+ const [st, list, remote] = await Promise.all([
82
+ run(GIT(), ['--no-optional-locks', '-C', dir, 'status', '--porcelain=v2', '--branch'], OPTS).catch(() => null),
83
+ run(
84
+ GIT(),
85
+ ['--no-optional-locks', '-C', dir, 'rev-list', `--max-count=${MAX_UNPUSHED}`, 'HEAD', '--not', '--remotes'],
86
+ OPTS
87
+ )
88
+ .then((out) => out.split('\n').filter(Boolean))
89
+ .catch(() => []), // a repository without a single commit
90
+ hasRemote(dir),
91
+ ])
92
+ if (!st) return null
93
+
94
+ // With no remote configured `--not --remotes` returns the whole history: that count means
95
+ // nothing, and there is no question of pushing at all.
96
+ const shas = remote ? list : []
97
+ return { ...parseStatus(st), remote, unpushed: remote ? shas.length : null, shas }
98
+ }
99
+
100
+ // ── Cache ─────────────────────────────────────────────────────────────────────
101
+ function schedule(dir) {
102
+ const e = cache.get(dir) ?? { at: 0, running: false, value: null }
103
+ e.running = true
104
+ cache.set(dir, e)
105
+ read(dir)
106
+ .then(
107
+ (value) => (e.value = value),
108
+ () => (e.value = null)
109
+ )
110
+ .finally(() => {
111
+ e.at = Date.now()
112
+ e.running = false
113
+ })
114
+ }
115
+
116
+ /**
117
+ * The directories that matter right now. Refreshes the stale ones and forgets the ones no
118
+ * longer needed — a destroyed worktree disappears by itself. Waits for nothing: whoever asks
119
+ * for the state gets what is there, and the board never stalls on git.
120
+ */
121
+ export function watch(dirs, slow = []) {
122
+ if (!available()) return
123
+ const hot = new Set(dirs.filter(Boolean))
124
+ const cold = new Set(slow.filter(Boolean).filter((d) => !hot.has(d)))
125
+ const wanted = new Set([...hot, ...cold])
126
+ for (const dir of [...cache.keys()]) if (!wanted.has(dir)) cache.delete(dir)
127
+
128
+ for (const dir of hot) due(dir, TTL)
129
+ // The repositories that are only in the list: a few per round, so within ten seconds they
130
+ // are all done and then it stays quiet for a minute.
131
+ let budget = SLOW_BATCH
132
+ for (const dir of cold) {
133
+ if (budget <= 0) break
134
+ if (due(dir, SLOW_TTL)) budget--
135
+ }
136
+ }
137
+
138
+ /** Reads again if it is time. Returns whether it really started something, to count the budget. */
139
+ function due(dir, ttl) {
140
+ const e = cache.get(dir)
141
+ if (e?.running || (e && Date.now() - e.at < ttl)) return false
142
+ schedule(dir)
143
+ return true
144
+ }
145
+
146
+ /** What is known about this directory right now, or null if nothing is known yet. */
147
+ export function stateOf(dir) {
148
+ return (dir && cache.get(dir)?.value) || null
149
+ }
150
+
151
+ /** Where HEAD is at this moment. Needed once, when a session starts. */
152
+ export async function head(dir) {
153
+ if (!available()) return null
154
+ return run(GIT(), ['--no-optional-locks', '-C', dir, 'rev-parse', 'HEAD'], OPTS)
155
+ .then((out) => out.trim() || null)
156
+ .catch(() => null)
157
+ }