k0-board 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/k0-import/SKILL.md +83 -0
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +688 -0
- package/bin/k0-board.js +456 -0
- package/bin/prompt.js +41 -0
- package/menubar/Info.plist +25 -0
- package/menubar/K0MenuBar.swift +561 -0
- package/menubar/build.sh +28 -0
- package/package.json +45 -0
- package/platform/contract.js +134 -0
- package/platform/darwin/extras.js +254 -0
- package/platform/darwin/index.js +24 -0
- package/platform/darwin/metrics.js +59 -0
- package/platform/darwin/power.js +133 -0
- package/platform/darwin/service.js +143 -0
- package/platform/darwin/shell.js +59 -0
- package/platform/darwin/terminal.js +324 -0
- package/platform/index.js +97 -0
- package/platform/linux/index.js +60 -0
- package/platform/linux/metrics.js +88 -0
- package/platform/linux/power.js +137 -0
- package/platform/linux/service.js +111 -0
- package/platform/linux/shell.js +97 -0
- package/platform/linux/tray.py +229 -0
- package/platform/shared/command.js +26 -0
- package/platform/shared/ps.js +40 -0
- package/platform/shared/run.js +99 -0
- package/platform/shared/tmux.js +242 -0
- package/platform/win32/index.js +53 -0
- package/platform/win32/metrics.js +72 -0
- package/platform/win32/power.js +109 -0
- package/platform/win32/powershell.js +44 -0
- package/platform/win32/service.js +111 -0
- package/platform/win32/shell.js +64 -0
- package/platform/win32/terminal.js +209 -0
- package/platform/win32/tray.ps1 +159 -0
- package/server/db.js +262 -0
- package/server/files.js +488 -0
- package/server/git.js +157 -0
- package/server/index.js +636 -0
- package/server/launcher.js +121 -0
- package/server/machine.js +213 -0
- package/server/mode.js +182 -0
- package/server/paths.js +67 -0
- package/server/pdf.js +110 -0
- package/server/projects.js +152 -0
- package/server/sessions.js +341 -0
- package/server/watcher.js +200 -0
- package/web/base.css +134 -0
- package/web/board.css +561 -0
- package/web/board.js +777 -0
- package/web/favicon.js +41 -0
- package/web/favicon.svg +8 -0
- package/web/files.css +535 -0
- package/web/files.html +63 -0
- package/web/files.js +877 -0
- package/web/fonts/caveat-latin-ext.woff2 +0 -0
- package/web/fonts/caveat-latin.woff2 +0 -0
- package/web/fuzzy.js +57 -0
- package/web/index.html +112 -0
- package/web/md.js +238 -0
- package/web/mentions.js +188 -0
- package/web/refs.js +176 -0
- package/web/title.js +26 -0
- package/web/units.js +33 -0
- package/web/view.js +248 -0
package/server/files.js
ADDED
|
@@ -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 `` 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
|
+
}
|