k0-board 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,71 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0]
10
+
11
+ The file viewer stops being read-only, and stops being just a pile of documents. It now shows the
12
+ folders a repository is built from, and behind a switch it shows the configuration too — an
13
+ `.env`, a `package.json`, a workflow's YAML — laid out as the kind of thing each one is, and
14
+ editable in place with a Save button. The name search, meanwhile, turns out to have been quietly
15
+ wrong the whole time: it matched letters scattered across a whole path rather than the file you
16
+ meant, and it now looks only at the name and shows you why a row is there at all.
17
+
18
+ ### Added
19
+
20
+ - **Folders, at last.** The file viewer's listing starts with the folders, and clicking one takes
21
+ you inside it: the folders it holds first, then its files. The path is written across the top
22
+ one piece at a time and **every piece is a way back up** — from `docs/backlog/handover` you
23
+ reach `docs` in one click, and the whole repository by clicking its name. The path above an open
24
+ document does the same, so from a file you can step out into the folder it lives in. Arrow keys
25
+ walk folders, Enter goes in, Esc comes out, ⌘-click opens one in another tab, and the address
26
+ follows you so a reload puts you back where you were.
27
+ - **Open in Finder.** A button on a folder opens that folder in your file manager, and the same
28
+ button in the row above a file points the file out inside its own folder. It works on macOS,
29
+ Windows and Linux; where a system gives k0 no way to do it, the button stays where it is, greyed
30
+ out, with the reason in its tooltip rather than quietly doing nothing.
31
+ - **The `config` button beside the search shows the configuration.** The viewer lists
32
+ documents, which is right until the day you want to check a key in an `.env`. Switch it on and
33
+ `.env` (and `.env.local`, and the rest), `.json`, `.yml`, `.yaml`, `.toml` and `.ini` join the
34
+ listing, the name search and the search inside the text — **including the ones git is told to
35
+ ignore**, because an `.env` is ignored by definition and it is the file you came for. Code stays
36
+ out either way. The switch is remembered, per browser.
37
+ - **Configuration opens as the kind of thing it is.** A **JSON** file becomes a tree that folds,
38
+ coloured by type, with the first two levels open — and with **its own search box**, because the
39
+ browser's find does not look inside a closed branch: on a folded tree ⌘F says "not found" for
40
+ something three lines away. This one counts what it found and opens the branches holding it. An
41
+ **.env** becomes a table of names and values, comments kept. **YAML, TOML and INI** keep their
42
+ shape, with keys, values and comments told apart by colour.
43
+ - **Configuration and notes can be edited here, with a Save button.** The pencil in the row above
44
+ a document turns it into a text box as tall as the pane — not a small window over it, because a
45
+ README does not fit in one. ⌘S saves, Cancel goes back to what is on disk. Only `.env` and the
46
+ other configuration, `.md` and `.txt`: no code, no PDFs, no images, and no creating or deleting
47
+ files. If the file **changed on disk while you were editing it** — a session wrote it, or you
48
+ did, in another window — the save stops and says so, and what you typed stays exactly where it
49
+ is. k0 will not write over somebody else's work without telling you.
50
+ - **A search whose best answer is switched off says so.** Looking for `.env` with the switch off
51
+ used to give eighty files that merely contain those four letters somewhere in their path, and no
52
+ hint that the one you wanted was a click away. Now a line appears above the results — *3
53
+ configuration files also match — show them* — and it is the button: clicking it turns the switch
54
+ on. It appears only when a hidden file would have come **above** everything on screen, so it is
55
+ not there on the searches where it would only be noise.
56
+ - **The name search looks only inside the file's own name, and underlines what it found.** The
57
+ directory used to be searched too, scattered letters and all, which is how `.env` once turned up
58
+ `v1.0-ROADMAP.md` — three of its four letters came from `.planning/milestones/`, a folder nobody
59
+ was reading, with no way to see that from the row. Now the directory is never searched, and the
60
+ letters that matched are underlined right in the name: `aud`it-`rep`ort reads as the two words
61
+ you meant. Neighbouring letters are underlined as one stretch, not one by one. The mark is a
62
+ line and not the yellow used for the text search, so the two answers stay apart: yellow means
63
+ the word is inside the file, a line means it is in the name.
64
+
65
+ ### Changed
66
+
67
+ - **On a card, the git mark sits against the right edge** instead of hanging off the end of the
68
+ repository's name, where it landed in a different place on every note. It is now the same shape
69
+ as the heading of a column, so a row of cards has a line you can read down. A long repository
70
+ name is cut with an ellipsis rather than pushing the mark about.
71
+ - Nothing a user can see: the coverage floor moves up to 87% of lines and 85% of branches, which
72
+ is where the two new modules leave it.
73
+
9
74
  ## [0.3.0]
10
75
 
11
76
  k0 could already tell you what each session was costing you in memory, and `Close` on a card was
package/README.md CHANGED
@@ -398,12 +398,12 @@ yet — a **lens** takes its place, so every card and every column still leads i
398
398
 
399
399
  Files on the left, what they say on the right, and a divider in between that you can drag.
400
400
 
401
- **Only documents are listed** — markdown, html, text, PDF, Word: things you read and print. In a
402
- real repository code is 95% of the files, and among two thousand `.tsx` files the document you
401
+ **Documents are what is listed** — markdown, html, text, PDF, Word: things you read and print. In
402
+ a real repository code is 95% of the files, and among two thousand `.tsx` files the document you
403
403
  were after cannot be found. This closes nothing off: a code file reached by a link inside a
404
- document still opens, and so do images.
404
+ document still opens, and so do images — and the configuration is one switch away, below.
405
405
 
406
- The listing is flat, in three groups:
406
+ At the top come the **folders**, then the files, in three groups:
407
407
 
408
408
  - **Changed** — what this session has touched: files still hanging in the working tree plus the
409
409
  ones inside commits made since it started.
@@ -411,19 +411,91 @@ The listing is flat, in three groups:
411
411
  no git.
412
412
  - **All files** — everything else, in path order.
413
413
 
414
+ ### Searching
415
+
414
416
  The search at the top looks in two places and keeps them apart:
415
417
 
416
- - **Names** — not the exact word, but the letters you type in the order you type them: `audrep`
417
- finds `docs/audit-report.md`. It filters as you type, asking the server nothing.
418
+ - **Names** — not the exact word, but the letters you type in the order you type them, **inside
419
+ the file's own name**: `audrep` finds `docs/audit-report.md`. It filters as you type, asking the
420
+ server nothing. The directory is never searched — it used to be, and `.env` would turn up a
421
+ `v1.0-ROADMAP.md` that merely had those four letters scattered across its folder, three of them
422
+ in a name nobody was reading. The letters that matched are **underlined**, so every row says why
423
+ it is there: `aud`it-`rep`ort reads as the two words you meant.
418
424
  - **In the text** — inside the documents, with the line the word appears on and the word
419
- highlighted. Because what you remember about a file is what was in it, not what it was called.
420
- Accents do not need typing — `cafe` finds `café`.
425
+ highlighted in yellow. Because what you remember about a file is what was in it, not what it was
426
+ called. Accents do not need typing — `cafe` finds `café`.
427
+
428
+ The two marks are different on purpose: a line under the letters means they are in the file's
429
+ name, yellow means the word was found inside the file.
421
430
 
422
431
  There is no index. The documents are read every time: a few hundred files of a few KB, all
423
432
  already in the operating system's cache. An index would cost more and go stale.
424
433
 
425
434
  `/` puts the cursor in the search, arrows scroll, Enter opens, Esc clears.
426
435
 
436
+ ### Going into a folder
437
+
438
+ Click a folder and the listing narrows to it: the folders inside it first, then its files. At the
439
+ top the path is written out one piece at a time, and **every piece is a way back up** — from
440
+ `docs/backlog/handover` you get to `docs` in one click, and to the whole repository by clicking
441
+ the repository's name. The path above an open document works the same way, so from a file you can
442
+ step into the folder it lives in.
443
+
444
+ The arrows walk folders like anything else and Enter goes in; Esc comes back out. A folder is a
445
+ real link, so ⌘/Ctrl-click opens it in another tab, and the address follows you: reload it and
446
+ you are still where you were.
447
+
448
+ Next to it is a button that hands the folder to your **file manager** — the Finder on a Mac,
449
+ Explorer on Windows, whatever your desktop uses on Linux. Every file has the same button in the
450
+ row above it, and there it points the file out inside its folder rather than opening it. On a
451
+ system where k0 has no way to do that, the button stays where it is, greyed out, with the reason
452
+ in its tooltip.
453
+
454
+ ### The `config` switch, beside the search
455
+
456
+ The viewer lists documents. The one thing that was missing from that is **configuration**: an
457
+ `.env` you want to check a key in, the `package.json`, a workflow's YAML. They are not documents,
458
+ and in a repository of prose they would be noise — so they live behind a switch, the **`config`**
459
+ button to the right of the search box.
460
+
461
+ Switch it on and `.env` (and `.env.local`, and the rest of them), `.json`, `.yml`, `.yaml`,
462
+ `.toml` and `.ini` join the listing, the name search and the search inside the text. **Including
463
+ the ones git is told to ignore** — an `.env` is ignored by definition, and it is the file you came
464
+ for. Code does not join: `.js`, `.py`, `.css` stay out either way.
465
+
466
+ The switch is remembered, per browser. It is not a search, it is a way of working.
467
+
468
+ And if you forget it is off, the search says so: when a hidden configuration file would have come
469
+ **above** everything on screen — searching `.env` in a repository that has one — a line appears at
470
+ the top of the results saying how many, and clicking it turns the switch on. Only then: a hidden
471
+ file that merely matches somewhere does not raise it, or the line would be there on every search
472
+ and you would stop seeing it.
473
+
474
+ Each of them opens as the kind of thing it is:
475
+
476
+ - a **JSON** file becomes a tree that folds, coloured by type, with the first two levels open;
477
+ - an **.env** becomes a table of names and values, comments and all;
478
+ - **YAML, TOML and INI** keep their shape, with keys, values and comments told apart by colour.
479
+
480
+ A JSON tree has its **own search box**, and that is not a duplicate of the browser's: the
481
+ browser's find does not look inside a branch that is closed, so on a folded tree ⌘F says "not
482
+ found" for something three lines away. This one counts what it found and opens the branches
483
+ holding it.
484
+
485
+ ### Editing a file
486
+
487
+ Configuration and notes can be changed from here: `.env` and the rest of the configuration above,
488
+ plus `.md` and `.txt`. Click the **pencil** in the row above the document and the file becomes a
489
+ text box as tall as the pane — not a small window over it, because a README does not fit in one.
490
+ **Save** (or ⌘/Ctrl+S) writes it; **Cancel** goes back to what is on disk.
491
+
492
+ Nothing else is editable: no code, no PDFs, no images, and no creating or deleting files. That is
493
+ what you opened the repository with Claude for.
494
+
495
+ If the file **changed on disk** while you were editing — a session wrote it, or you did, in
496
+ another window — the save stops and says so, and what you typed stays where it is. k0 will not
497
+ quietly write over somebody else's work.
498
+
427
499
  ### Pasting a chunk of chat
428
500
 
429
501
  There is a way to reach files that does not go through searching for them. When a session ends,
@@ -837,7 +909,8 @@ server/
837
909
  mode.js the four modes: how awake to keep the machine, and whether the text goes large
838
910
  projects.js the repositories, in the order you last used them
839
911
  sessions.js digs already-lived sessions out of the transcripts, to import as cards
840
- files.js the only one that reads the projects' disk: what is there, what changed, what it says
912
+ files.js the only one that reads the projects' disk — and the only one that writes back
913
+ into it: what is there, what changed, what it says, and the one small write
841
914
  machine.js the only one that looks at processes and memory: what it all costs, and who is costing it
842
915
  pdf.js the document on paper, printed by a headless browser
843
916
  web/ the three pages (html, css, js served exactly as they are)
@@ -853,6 +926,8 @@ web/ the three pages (html, css, js served exactly as they are)
853
926
  fuzzy.js searching the names: the letters you type, in the order you type them
854
927
  mentions.js which of a repository's files a piece of text names
855
928
  refs.js what a name written inside a document points at, and which of the nine READMEs
929
+ json.js a JSON file as a tree that folds, and the search that opens the right branches
930
+ conf.js an .env as the table it always was, and YAML/TOML/INI with their parts told apart
856
931
  .claude/skills/
857
932
  k0-import/ the skill that fills the board with sessions you have already had
858
933
  k0-changelog/ the one that turns the facts of a stretch of work into something readable. k0
@@ -871,6 +946,12 @@ test/ npm test — Node's own runner, no framework. See docs/testing.
871
946
 
872
947
  ### What it reads, and what it never does
873
948
 
949
+ k0 reads your repositories. It writes into one of them in exactly one place: the pencil in the
950
+ file viewer, on a configuration file or a note that is already there. That write is deliberately
951
+ narrow — no code, no new files, no deletions — it carries the file's own permissions across, it
952
+ lands as a rename so an interrupted save leaves the old file whole, and it refuses outright if the
953
+ file has changed on disk since the page read it.
954
+
874
955
  k0 does not emulate a terminal and installs no hooks. It reads two things Claude Code already
875
956
  writes for itself:
876
957
 
@@ -984,11 +1065,12 @@ The full picture, and how to report a problem privately, is in [SECURITY.md](SEC
984
1065
 
985
1066
  ## What is not here yet
986
1067
 
987
- Checkboxes inside a card. In the viewer only documents are listed: code opens if you arrive at it
988
- from a link, but it is not browsable and the text search does not look at it. Files are read only:
989
- they are not edited, not diffed, and you cannot talk to them — for that you open the repository
990
- with Claude. Sessions opened by hand outside k0 are picked up with `/k0-import`, on request:
991
- nothing notices them by itself yet.
1068
+ Checkboxes inside a card. In the viewer the listing is documents and configuration: code opens if
1069
+ you arrive at it from a link, but it is not browsable and the text search does not look at it.
1070
+ Only configuration and notes can be edited, one file at a time — nothing is diffed, no file is
1071
+ created or deleted, and you cannot talk to them; for that you open the repository with Claude.
1072
+ Sessions opened by hand outside k0 are picked up with `/k0-import`, on request: nothing notices
1073
+ them by itself yet.
992
1074
 
993
1075
  ## Contributing
994
1076
 
package/bin/k0-board.js CHANGED
@@ -418,6 +418,8 @@ async function doctor() {
418
418
  ['read the kernel memory-pressure verdict', capabilities.metrics.pressure],
419
419
  ['start at login', capabilities.service.autostart],
420
420
  ['show a tray icon', capabilities.tray],
421
+ ['show a file in the file manager', capabilities.revealInFileManager],
422
+ ['open a folder in the file manager', capabilities.openInFileManager],
421
423
  ]
422
424
  for (const [what, yes] of rows) (yes ? ok : warn)(what)
423
425
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k0-board",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A board of sticky notes that drives your Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -40,7 +40,8 @@
40
40
  * @property {object} service
41
41
  * @property {boolean} service.autostart can register a service that starts at login
42
42
  * @property {boolean} tray a menu bar / system tray icon is available
43
- * @property {boolean} revealInFileManager
43
+ * @property {boolean} revealInFileManager can show a file selected in the file manager
44
+ * @property {boolean} openInFileManager can open a directory in the file manager
44
45
  */
45
46
 
46
47
  /**
@@ -55,6 +56,7 @@ export const NO_CAPABILITIES = {
55
56
  service: { autostart: false },
56
57
  tray: false,
57
58
  revealInFileManager: false,
59
+ openInFileManager: false,
58
60
  }
59
61
 
60
62
  /**
@@ -133,6 +135,10 @@ export const NO_CAPABILITIES = {
133
135
  /**
134
136
  * @typedef {object} ShellAdapter
135
137
  * @property {(absPath: string) => Promise<unknown>} revealInFileManager
138
+ * Shows the file in its own folder, selected. A folder handed to this gets selected in its
139
+ * parent, which is the neighbouring thing and not the same one.
140
+ * @property {(absDir: string) => Promise<unknown>} openInFileManager
141
+ * Opens the directory itself, so you are looking at what is inside it.
136
142
  * @property {(url: string) => Promise<unknown>} openBrowser
137
143
  * @property {() => string|null} findChrome a Chromium-family browser able to print
138
144
  * @property {() => Set<string>} homeSkipList directories under $HOME that are never projects
@@ -18,6 +18,7 @@ export const capabilities = {
18
18
  service: service.capabilities,
19
19
  tray: true,
20
20
  revealInFileManager: true,
21
+ openInFileManager: true,
21
22
  }
22
23
 
23
24
  /**
@@ -7,6 +7,9 @@ const OPEN = () => which('open', ['/usr/bin/open'])
7
7
 
8
8
  export const revealInFileManager = (abs) => run(OPEN(), ['-R', abs], { timeout: 4000 })
9
9
 
10
+ /** Without `-R` the Finder opens the directory instead of picking it out in the one above. */
11
+ export const openInFileManager = (abs) => run(OPEN(), [abs], { timeout: 4000 })
12
+
10
13
  export const openBrowser = (url) => run(OPEN(), [url], { timeout: 4000 })
11
14
 
12
15
  /**
@@ -56,4 +59,4 @@ export function findClaude() {
56
59
  return claudeBin
57
60
  }
58
61
 
59
- export const capabilities = { revealInFileManager: true }
62
+ export const capabilities = { revealInFileManager: true, openInFileManager: true }
package/platform/index.js CHANGED
@@ -65,6 +65,7 @@ const unsupported = {
65
65
  },
66
66
  shell: {
67
67
  revealInFileManager: async () => {},
68
+ openInFileManager: async () => {},
68
69
  openBrowser: async () => {},
69
70
  findChrome: () => null,
70
71
  homeSkipList: () => new Set(),
@@ -24,6 +24,7 @@ export const capabilities = {
24
24
  service: service.capabilities(),
25
25
  tray: !!which('python3'),
26
26
  revealInFileManager: shell.capabilities().revealInFileManager,
27
+ openInFileManager: shell.capabilities().openInFileManager,
27
28
  }
28
29
 
29
30
  export const notes = {
@@ -61,6 +62,16 @@ export const notes = {
61
62
  'Neither ss nor lsof was found, so k0 cannot see which port a dev server is listening ' +
62
63
  'on. Install iproute2 (for ss) and the globe will know where to send you.',
63
64
  }),
65
+ ...(capabilities.openInFileManager
66
+ ? {}
67
+ : {
68
+ revealInFileManager:
69
+ 'xdg-open was not found, so k0 has no way to hand a file or a folder to your file ' +
70
+ 'manager. Install xdg-utils and the button will work.',
71
+ openInFileManager:
72
+ 'xdg-open was not found, so k0 has no way to hand a file or a folder to your file ' +
73
+ 'manager. Install xdg-utils and the button will work.',
74
+ }),
64
75
  ...(capabilities.servers.adopt
65
76
  ? {}
66
77
  : {
@@ -31,6 +31,15 @@ export async function revealInFileManager(abs) {
31
31
  if (open) await runQuiet(open, [path.dirname(abs)])
32
32
  }
33
33
 
34
+ /**
35
+ * Opening a directory needs none of the dance above: every desktop that has a file manager at
36
+ * all hands a directory to it through xdg-open.
37
+ */
38
+ export async function openInFileManager(abs) {
39
+ const open = XDG_OPEN()
40
+ if (open) await runQuiet(open, [abs])
41
+ }
42
+
34
43
  export const openBrowser = (url) => run(XDG_OPEN(), [url], { timeout: 4000 })
35
44
 
36
45
  const CHROME_CANDIDATES = [
@@ -94,4 +103,4 @@ export function findClaude() {
94
103
  return claudeBin
95
104
  }
96
105
 
97
- export const capabilities = () => ({ revealInFileManager: !!XDG_OPEN() })
106
+ export const capabilities = () => ({ revealInFileManager: !!XDG_OPEN(), openInFileManager: !!XDG_OPEN() })
@@ -24,6 +24,7 @@ export const capabilities = {
24
24
  service: service.capabilities(),
25
25
  tray: hasPowerShell(),
26
26
  revealInFileManager: true,
27
+ openInFileManager: true,
27
28
  }
28
29
 
29
30
  export const notes = {
@@ -8,6 +8,9 @@ const EXPLORER = () => which('explorer') || 'explorer.exe'
8
8
  /** `explorer /select,` opens the folder with the file already highlighted. */
9
9
  export const revealInFileManager = (abs) => runQuiet(EXPLORER(), [`/select,${path.normalize(abs)}`])
10
10
 
11
+ /** The same command without `/select,` opens the folder itself. */
12
+ export const openInFileManager = (abs) => runQuiet(EXPLORER(), [path.normalize(abs)])
13
+
11
14
  export const openBrowser = (url) => runQuiet('cmd', ['/c', 'start', '', url])
12
15
 
13
16
  const programFiles = [
@@ -61,4 +64,4 @@ export function findClaude() {
61
64
  return claudeBin
62
65
  }
63
66
 
64
- export const capabilities = () => ({ revealInFileManager: true })
67
+ export const capabilities = () => ({ revealInFileManager: true, openInFileManager: true })
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: !!names, ...(names ? withStats(root, names) : walk(root)) }
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 (!isDoc(p)) continue
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({ p, m: Math.floor(s.mtimeMs), s: s.size })
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
- function walk(root) {
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
- if (e.name.startsWith('.')) continue
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() || !isDoc(e.name)) continue
315
+ if (!e.isFile() || !pick(e.name)) continue
270
316
  try {
271
317
  const s = fs.statSync(child)
272
- files.push({ p: childRel, m: Math.floor(s.mtimeMs), s: s.size })
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
- // 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)
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
- /** The documents that contain all the words searched for, with the line they appear on. */
425
- export async function grep(root, query) {
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 (!TEXTY.has(path.extname(f.p).toLowerCase()) || f.s > GREP_BYTES) continue
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)