k0-board 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +96 -14
- package/bin/k0-board.js +2 -0
- package/package.json +1 -1
- package/platform/contract.js +7 -1
- package/platform/darwin/index.js +1 -0
- package/platform/darwin/shell.js +4 -1
- package/platform/index.js +1 -0
- package/platform/linux/index.js +11 -0
- package/platform/linux/shell.js +10 -1
- package/platform/win32/index.js +1 -0
- package/platform/win32/shell.js +4 -1
- package/server/files.js +105 -14
- package/server/index.js +35 -3
- package/web/board.css +8 -0
- package/web/conf.js +118 -0
- package/web/files.css +206 -1
- package/web/files.html +10 -0
- package/web/files.js +463 -62
- package/web/fuzzy.js +73 -21
- package/web/json.js +145 -0
package/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
|
-
**
|
|
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
|
-
|
|
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
|
|
417
|
-
finds `docs/audit-report.md`. It filters as you type, asking the
|
|
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
|
|
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
|
|
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
|
|
988
|
-
from a link, but it is not browsable and the text search does not look at it.
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
package/platform/contract.js
CHANGED
|
@@ -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
|
package/platform/darwin/index.js
CHANGED
package/platform/darwin/shell.js
CHANGED
|
@@ -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
package/platform/linux/index.js
CHANGED
|
@@ -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
|
: {
|
package/platform/linux/shell.js
CHANGED
|
@@ -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() })
|
package/platform/win32/index.js
CHANGED
package/platform/win32/shell.js
CHANGED
|
@@ -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:
|
|
239
|
+
if (!names) return { git: false, ...walk(root, isListed) }
|
|
240
|
+
|
|
241
|
+
// git's listing is missing exactly the file all this was built for: an `.env` is ignored by
|
|
242
|
+
// definition, so `ls-files -co --exclude-standard` will never name it. So the disk is walked a
|
|
243
|
+
// second time for configuration alone — a handful of names, under the same bounds as the other
|
|
244
|
+
// walk — and what git already gave is kept as it is.
|
|
245
|
+
const fromGit = withStats(root, names)
|
|
246
|
+
const seen = new Set(fromGit.files.map((f) => f.p))
|
|
247
|
+
const extra = walk(root, isConfig)
|
|
248
|
+
for (const f of extra.files) if (!seen.has(f.p)) fromGit.files.push(f)
|
|
249
|
+
return { git: true, files: fromGit.files, truncated: fromGit.truncated || extra.truncated }
|
|
217
250
|
}
|
|
218
251
|
|
|
219
252
|
const tracked = (root) =>
|
|
@@ -226,14 +259,14 @@ function withStats(root, names) {
|
|
|
226
259
|
const files = []
|
|
227
260
|
let truncated = false
|
|
228
261
|
for (const p of names) {
|
|
229
|
-
if (!
|
|
262
|
+
if (!isListed(p)) continue
|
|
230
263
|
if (files.length >= MAX_FILES) {
|
|
231
264
|
truncated = true
|
|
232
265
|
break
|
|
233
266
|
}
|
|
234
267
|
try {
|
|
235
268
|
const s = fs.statSync(path.join(root, p))
|
|
236
|
-
if (s.isFile()) files.push(
|
|
269
|
+
if (s.isFile()) files.push(entry(p, s))
|
|
237
270
|
} catch {
|
|
238
271
|
/* deleted between the listing and now */
|
|
239
272
|
}
|
|
@@ -241,7 +274,18 @@ function withStats(root, names) {
|
|
|
241
274
|
return { files, truncated }
|
|
242
275
|
}
|
|
243
276
|
|
|
244
|
-
|
|
277
|
+
/**
|
|
278
|
+
* One row of the listing. `c` marks a configuration file, so the page can leave it out without
|
|
279
|
+
* having to ask a second time: one byte on the wire, one switch on the other side.
|
|
280
|
+
*/
|
|
281
|
+
const entry = (p, s) => {
|
|
282
|
+
const row = { p, m: Math.floor(s.mtimeMs), s: s.size }
|
|
283
|
+
if (isConfig(p)) row.c = 1
|
|
284
|
+
return row
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Everything under `root` that `pick` accepts. */
|
|
288
|
+
function walk(root, pick) {
|
|
245
289
|
const files = []
|
|
246
290
|
let truncated = false
|
|
247
291
|
|
|
@@ -258,18 +302,20 @@ function walk(root) {
|
|
|
258
302
|
truncated = true
|
|
259
303
|
return
|
|
260
304
|
}
|
|
261
|
-
|
|
305
|
+
// A directory whose name starts with a dot is skipped whole: that one rule removes .git,
|
|
306
|
+
// the caches and the virtual environments in a single stroke. A dotted *file* is not —
|
|
307
|
+
// `.env` is a dotted name, and it is the one this walk was taught to find.
|
|
262
308
|
const child = path.join(dir, e.name)
|
|
263
309
|
const childRel = rel ? `${rel}/${e.name}` : e.name
|
|
264
310
|
if (e.isDirectory()) {
|
|
265
|
-
if (!SKIP.has(e.name)) rec(child, childRel, depth + 1)
|
|
311
|
+
if (!e.name.startsWith('.') && !SKIP.has(e.name)) rec(child, childRel, depth + 1)
|
|
266
312
|
continue
|
|
267
313
|
}
|
|
268
314
|
// No symbolic links: a loop would send the walk round forever.
|
|
269
|
-
if (!e.isFile() || !
|
|
315
|
+
if (!e.isFile() || !pick(e.name)) continue
|
|
270
316
|
try {
|
|
271
317
|
const s = fs.statSync(child)
|
|
272
|
-
files.push(
|
|
318
|
+
files.push(entry(childRel, s))
|
|
273
319
|
} catch {
|
|
274
320
|
/* gone in the meantime */
|
|
275
321
|
}
|
|
@@ -392,9 +438,9 @@ export async function changed(root, headAtStart) {
|
|
|
392
438
|
.catch(() => []) // the starting commit may not be there any more
|
|
393
439
|
: [],
|
|
394
440
|
])
|
|
395
|
-
//
|
|
396
|
-
// things to read, when there is nothing to read at all.
|
|
397
|
-
return [...new Set([...now, ...mine])].filter(
|
|
441
|
+
// What the listing carries, here too: a "Changed 40" made of `.tsx` files would say there are
|
|
442
|
+
// forty things to read, when there is nothing to read at all.
|
|
443
|
+
return [...new Set([...now, ...mine])].filter(isListed)
|
|
398
444
|
}
|
|
399
445
|
|
|
400
446
|
// ── Searching inside the files ────────────────────────────────────────────────
|
|
@@ -406,6 +452,8 @@ export async function changed(root, headAtStart) {
|
|
|
406
452
|
// of a few KB, all already in the operating system's cache — it costs less than keeping an
|
|
407
453
|
// index up to date, and an index goes stale.
|
|
408
454
|
const TEXTY = new Set(['.md', '.markdown', '.mdx', '.txt', '.html', '.htm'])
|
|
455
|
+
/** Read only when the page asks for them: they are hidden by default, and so are their lines. */
|
|
456
|
+
const TEXTY_CONFIG = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
|
|
409
457
|
const GREP_FILES = 80 // how many results are enough: past that nobody scrolls anyway
|
|
410
458
|
const GREP_BYTES = 1 << 20
|
|
411
459
|
const SNIPPET = 200
|
|
@@ -421,8 +469,13 @@ export const plain = (s) =>
|
|
|
421
469
|
.toLowerCase()
|
|
422
470
|
.replace(/[\u00C0-\u024F]/g, (c) => c.normalize('NFD')[0])
|
|
423
471
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
472
|
+
/**
|
|
473
|
+
* The documents that contain all the words searched for, with the line they appear on.
|
|
474
|
+
*
|
|
475
|
+
* With `nerd` the configuration files are looked inside as well. It has to follow what the page
|
|
476
|
+
* is showing: finding a line in a file the listing is hiding would be a result you cannot open.
|
|
477
|
+
*/
|
|
478
|
+
export async function grep(root, query, nerd = false) {
|
|
426
479
|
const words = plain(query).split(/\s+/).filter(Boolean)
|
|
427
480
|
if (!words.length) return []
|
|
428
481
|
|
|
@@ -430,7 +483,10 @@ export async function grep(root, query) {
|
|
|
430
483
|
const hits = []
|
|
431
484
|
for (const f of files) {
|
|
432
485
|
if (hits.length >= GREP_FILES) break
|
|
433
|
-
if (
|
|
486
|
+
if (f.c && !nerd) continue
|
|
487
|
+
const ext = path.extname(f.p).toLowerCase()
|
|
488
|
+
const texty = TEXTY.has(ext) || (nerd && (TEXTY_CONFIG.has(ext) || ENV_NAME.test(path.basename(f.p))))
|
|
489
|
+
if (!texty || f.s > GREP_BYTES) continue
|
|
434
490
|
let text
|
|
435
491
|
try {
|
|
436
492
|
text = fs.readFileSync(path.join(root, f.p), 'utf8')
|
|
@@ -484,5 +540,40 @@ export function read(abs) {
|
|
|
484
540
|
return { kind, ...meta, text, truncated }
|
|
485
541
|
}
|
|
486
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Writes the file back, or refuses.
|
|
545
|
+
*
|
|
546
|
+
* `was` is the modification time the page was handed when it opened the file. If the file on disk
|
|
547
|
+
* has moved on since, the write does not happen: somebody else — a session, an editor, a script —
|
|
548
|
+
* has written it in the meantime, and overwriting silently would throw their work away. The page
|
|
549
|
+
* is told, and it still has what you typed.
|
|
550
|
+
*
|
|
551
|
+
* The bytes go to a neighbouring name and are then renamed over the original, which on the same
|
|
552
|
+
* filesystem is one indivisible step: an interrupted save leaves the old file whole rather than
|
|
553
|
+
* half the new one. The mode is carried over, or a `.env` at 600 would come back readable by all.
|
|
554
|
+
*/
|
|
555
|
+
export function write(abs, text, was) {
|
|
556
|
+
const s = fs.statSync(abs)
|
|
557
|
+
if (!s.isFile()) throw new Error('Not a file')
|
|
558
|
+
if (Math.floor(s.mtimeMs) !== was) {
|
|
559
|
+
const clash = new Error('This file changed on disk while you were editing it. Nothing was saved.')
|
|
560
|
+
clash.conflict = true
|
|
561
|
+
throw clash
|
|
562
|
+
}
|
|
563
|
+
const tmp = `${abs}.k0-tmp`
|
|
564
|
+
try {
|
|
565
|
+
fs.writeFileSync(tmp, text, { mode: s.mode & 0o777 })
|
|
566
|
+
fs.renameSync(tmp, abs)
|
|
567
|
+
} catch (e) {
|
|
568
|
+
fs.rmSync(tmp, { force: true })
|
|
569
|
+
throw e
|
|
570
|
+
}
|
|
571
|
+
const now = fs.statSync(abs)
|
|
572
|
+
return { size: now.size, mtime: Math.floor(now.mtimeMs) }
|
|
573
|
+
}
|
|
574
|
+
|
|
487
575
|
/** Show it in the file manager: the only way to open what cannot be shown here. */
|
|
488
576
|
export const reveal = (abs) => shell.revealInFileManager(abs)
|
|
577
|
+
|
|
578
|
+
/** A folder is opened rather than pointed at: what you want is to be inside it. */
|
|
579
|
+
export const openFolder = (abs) => shell.openInFileManager(abs)
|