k0-board 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +426 -1
  21. package/README.md +1021 -119
  22. package/bin/k0-board.js +81 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +37 -6
  26. package/platform/darwin/index.js +1 -0
  27. package/platform/darwin/shell.js +4 -1
  28. package/platform/darwin/terminal.js +230 -56
  29. package/platform/index.js +3 -1
  30. package/platform/linux/index.js +14 -0
  31. package/platform/linux/shell.js +10 -1
  32. package/platform/linux/tray.py +10 -10
  33. package/platform/shared/command.js +19 -3
  34. package/platform/shared/tmux.js +32 -14
  35. package/platform/win32/index.js +6 -2
  36. package/platform/win32/shell.js +4 -1
  37. package/platform/win32/terminal.js +24 -8
  38. package/platform/win32/tray.ps1 +13 -13
  39. package/server/backlog.js +618 -0
  40. package/server/changelog.js +13 -13
  41. package/server/db.js +1800 -146
  42. package/server/files.js +156 -26
  43. package/server/git.js +53 -14
  44. package/server/idle.js +15 -13
  45. package/server/index.js +1286 -189
  46. package/server/launcher.js +122 -14
  47. package/server/machine.js +10 -1
  48. package/server/mirror.js +1048 -0
  49. package/server/prices.js +59 -0
  50. package/server/projects.js +57 -12
  51. package/server/servers.js +22 -1
  52. package/server/sessions.js +5 -5
  53. package/server/settings.js +25 -2
  54. package/server/update.js +324 -0
  55. package/server/usage.js +642 -0
  56. package/server/watcher.js +67 -13
  57. package/server/worktree.js +600 -0
  58. package/server/writer.js +98 -26
  59. package/web/awake.js +62 -0
  60. package/web/base.css +4 -0
  61. package/web/board.css +404 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/conf.js +118 -0
  65. package/web/files.css +206 -1
  66. package/web/files.html +10 -0
  67. package/web/files.js +533 -75
  68. package/web/fuzzy.js +73 -21
  69. package/web/index.html +109 -12
  70. package/web/json.js +145 -0
  71. package/web/list.css +536 -0
  72. package/web/list.js +1015 -0
  73. package/web/mentions.js +27 -9
  74. package/web/recency.js +10 -10
  75. package/web/units.js +1 -1
  76. package/web/usage.css +169 -0
  77. package/web/usage.html +78 -0
  78. package/web/usage.js +447 -0
  79. package/web/view.js +22 -3
  80. package/web/whatsnew.css +211 -0
  81. package/web/whatsnew.html +49 -0
  82. package/web/whatsnew.js +339 -0
package/web/fuzzy.js CHANGED
@@ -1,47 +1,99 @@
1
1
  // ── Searching the names ───────────────────────────────────────────────────────
2
2
  // It does not look for the exact word: it looks for the letters you typed, in the order you
3
- // typed them, scattered through the path. So `audrep` finds `docs/audit-report.md` and `bapl`
4
- // finds `docs/backlog/plans/`.
3
+ // typed them, scattered through the file's **name**. So `audrep` finds `docs/audit-report.md`.
4
+ //
5
+ // The directory is never searched. It was, once: `.env` scattered its four letters across
6
+ // `.planning/milestones/` and turned up `v1.0-ROADMAP.md`, a file that has nothing to do with
7
+ // what was typed and gives no way to see why it is there — three of the four letters are in a
8
+ // folder name nobody is looking at. A path is not a sentence you meant to write; a file name is.
5
9
  //
6
10
  // The score exists to put what you actually meant at the top. Worth more: a letter next to the
7
- // previous one, a letter at the start of a word, and anything in the file's name rather than in
8
- // the directory — because you usually reach a file through its name, not through where it is.
11
+ // previous one, and a letter at the start of a word.
12
+
13
+ const BOUNDARY = new Set(['-', '_', '.', ' '])
9
14
 
10
- const BOUNDARY = new Set(['/', '-', '_', '.', ' '])
15
+ /** What is really searched for: the spaces mean nothing here, and neither does the case. */
16
+ const wanted = (query) => String(query ?? '').trim().toLowerCase().replace(/\s+/g, '')
11
17
 
12
18
  /**
13
- * How much `text` looks like `query`: the higher, the more alike. `-1` when the letters simply
14
- * are not there. With an empty search it is 0 for everybody, and the caller decides the order.
19
+ * The one walk through the file's name, which produces both numbers this file exists for: how
20
+ * alike the two are, and **which letters** made it so.
21
+ *
22
+ * They come out of the same walk on purpose. Underlining letters that a second, separate pass had
23
+ * chosen would sooner or later underline something the ranking had not used — and a row that says
24
+ * "this is why I am here" has to be telling the truth.
25
+ *
26
+ * `at` comes back as positions inside the whole `text`, not just the name: the caller was handed
27
+ * a path, and a position only means something next to the path it was taken from.
28
+ *
29
+ * Returns `null` when the letters are not there at all.
15
30
  */
16
- export function score(text, query) {
17
- const q = String(query ?? '').trim().toLowerCase().replace(/\s+/g, '')
18
- if (!q) return 0
31
+ function trace(text, q) {
19
32
  const s = String(text ?? '')
20
- const low = s.toLowerCase()
21
-
22
- // Where the file name starts: from there on every hit is worth more.
33
+ // Where the file name starts. Everything before this is the directory, and it is not searched.
23
34
  const nameAt = s.lastIndexOf('/') + 1
35
+ const name = s.slice(nameAt)
36
+ const low = name.toLowerCase()
24
37
 
38
+ const at = []
25
39
  let total = 0
26
- let at = 0
40
+ let from = 0
27
41
  let last = -2
28
42
  for (const ch of q) {
29
- const hit = low.indexOf(ch, at)
30
- if (hit === -1) return -1
43
+ const hit = low.indexOf(ch, from)
44
+ if (hit === -1) return null
31
45
 
32
46
  let point = 1
33
47
  if (hit === last + 1) point += 4 // next to the previous one
34
- if (hit === 0 || BOUNDARY.has(s[hit - 1])) point += 3 // the start of a word
35
- else if (s[hit] >= 'A' && s[hit] <= 'Z') point += 2 // the start of a word in camelCase
36
- if (hit >= nameAt) point += 2 // in the file name, not the directory
48
+ if (hit === 0 || BOUNDARY.has(name[hit - 1])) point += 3 // the start of a word
49
+ else if (name[hit] >= 'A' && name[hit] <= 'Z') point += 2 // the start of a word in camelCase
37
50
 
38
51
  total += point
52
+ at.push(hit + nameAt)
39
53
  last = hit
40
- at = hit + 1
54
+ from = hit + 1
41
55
  }
42
56
 
43
57
  // On equal hits the shorter name wins: there is less around what you were looking for.
44
- return total - s.length * 0.01
58
+ return { total: total - name.length * 0.01, at }
59
+ }
60
+
61
+ /**
62
+ * How much `text` looks like `query`: the higher, the more alike. `-1` when the letters simply
63
+ * are not there. With an empty search it is 0 for everybody, and the caller decides the order.
64
+ */
65
+ export function score(text, query) {
66
+ const q = wanted(query)
67
+ if (!q) return 0
68
+ return trace(text, q)?.total ?? -1
69
+ }
70
+
71
+ /**
72
+ * Where in `text` the letters of `query` were found, in order — the positions the score was built
73
+ * from, so a row can underline them and show why it matched. `null` when there was no match, or
74
+ * nothing to search for.
75
+ */
76
+ export function positions(text, query) {
77
+ const q = wanted(query)
78
+ if (!q) return null
79
+ return trace(text, q)?.at ?? null
80
+ }
81
+
82
+ /**
83
+ * The same positions gathered into stretches — `[[from, to], …]`, the end not included.
84
+ *
85
+ * Neighbouring letters belong together: `audrep` against `audit-report.md` found two pieces of
86
+ * two words, and that is what it should look like. Marked one letter at a time it looks like six
87
+ * separate coincidences, which is the opposite of what happened.
88
+ */
89
+ export function runs(at) {
90
+ const out = []
91
+ for (const i of at ?? []) {
92
+ const last = out[out.length - 1]
93
+ if (last && last[1] === i) last[1] = i + 1
94
+ else out.push([i, i + 1])
95
+ }
96
+ return out
45
97
  }
46
98
 
47
99
  /** The rows that match, most alike first. `key` says where to look. */
package/web/index.html CHANGED
@@ -10,18 +10,61 @@
10
10
  <body>
11
11
  <header class="topbar">
12
12
  <h1>k0</h1>
13
- <button class="new" id="new-card" title="New card" aria-label="New card">
14
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
15
- </button>
13
+ <!-- The one mark on this bar that appears by itself, and only for one of two reasons: k0
14
+ moved on under you while you were not looking, or npm says there is a newer version than
15
+ the one installed here. It never opens the page — it sits next to the name and waits. -->
16
+ <a class="whatsnew" id="whatsnew" href="/whatsnew.html" hidden>
17
+ <span id="whatsnew-dot" aria-hidden="true"></span>
18
+ </a>
19
+ <!-- The "+" is a menu now, because there are two things it can make and they are made in
20
+ two different ways: a story is typed here, an epic is told to Claude in a terminal. With
21
+ the backlog off there is only one of them and the menu never opens — the plus is the
22
+ plus it has always been. -->
23
+ <div class="new-wrap">
24
+ <button class="new" id="new-story" title="New story" aria-label="New story"
25
+ aria-haspopup="true" aria-expanded="false">
26
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
27
+ </button>
28
+ <div class="menu" id="new-menu" role="menu" hidden>
29
+ <button type="button" role="menuitem" data-new="story">Story<em>a post-it, written here</em></button>
30
+ <button type="button" role="menuitem" data-new="epic">Epic<em>talked through in a terminal</em></button>
31
+ </div>
32
+ </div>
16
33
  <!-- Next to the plus, because they are the two ends of the same thing: one starts a piece
17
34
  of work, the other tells you how the ones before it went. A plain link, and a quiet
18
35
  one — no dot, no badge. It is there when you want it. -->
19
36
  <a class="log" href="/changelog.html" title="What you have been doing" aria-label="What you have been doing">
20
37
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h11M4 17h7"/></svg>
21
38
  </a>
22
- <div class="filters" id="filters"></div>
39
+ <!-- Its neighbour, and just as quiet: what those sessions consumed, a week at a time. -->
40
+ <a class="log" href="/usage.html" title="What your sessions consumed" aria-label="What your sessions consumed">
41
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 20V11M12 20V5M19 20v-6"/></svg>
42
+ </a>
43
+ <!-- The state pills, the repository and the shape of the view: one group, because they are
44
+ one question. The pills and the repository say WHAT you are looking at; Kanban or List
45
+ says what shape it comes in. Switching shape never changes what is in scope. -->
46
+ <div class="filterbar">
47
+ <div class="filters" id="filters"></div>
48
+ <!-- Two answers and no third: the board as it has always been, or the same stories as rows.
49
+ It is only here when there is a backlog behind the board — with the feature off there is
50
+ one answer, and a switch offering it would be a switch that does nothing. -->
51
+ <div class="columns" id="columns" role="radiogroup" aria-label="How to look at it" hidden>
52
+ <button type="button" role="radio" data-columns="kanban" aria-checked="true">Kanban</button>
53
+ <button type="button" role="radio" data-columns="list" aria-checked="false">List</button>
54
+ </div>
55
+ <!-- Which repository, for both views. Empty means all of them. -->
56
+ <div class="picker repo-filter" id="repo-filter" hidden>
57
+ <button type="button" id="repo-filter-btn" aria-haspopup="listbox" aria-expanded="false"
58
+ aria-label="Which repository">All repositories</button>
59
+ <div class="picker-list" id="repo-filter-list" hidden>
60
+ <input id="repo-filter-q" autocomplete="off" spellcheck="false" placeholder="search a repository…"
61
+ aria-label="Search a repository">
62
+ <div id="repo-filter-rows" role="listbox"></div>
63
+ </div>
64
+ </div>
65
+ </div>
23
66
 
24
- <!-- How the computer is doing. Always visible; clicking it switches off the weights on the cards. -->
67
+ <!-- How the computer is doing. Always visible; clicking it switches off the weights on the stories. -->
25
68
  <button class="gauge" id="gauge" type="button" hidden></button>
26
69
 
27
70
  <!-- The four modes: how awake the machine has to stay. It is a scale, not four switches —
@@ -44,9 +87,22 @@
44
87
  </div>
45
88
  </header>
46
89
 
90
+ <!-- Inside an epic. The board is that epic and nothing else, and this strip is the only thing
91
+ that says so: which one, how far it has got, and the way back out. It is not on the page
92
+ until you have gone in — an epic you are not in has nothing to say up here. -->
93
+ <div class="lane" id="epic-lane" hidden>
94
+ <span class="key" id="epic-key"></span>
95
+ <span class="name" id="epic-name"></span>
96
+ <span class="repo" id="epic-repo"></span>
97
+ <span class="bar"><i id="epic-fill"></i></span>
98
+ <span class="count" id="epic-count"></span>
99
+ <span class="spacer"></span>
100
+ <button type="button" id="epic-out">Leave the epic</button>
101
+ </div>
102
+
47
103
  <main id="viewport" class="viewport">
48
104
  <div id="board" class="board"></div>
49
- <p class="empty" id="empty">The dashboard is empty. Add your first card.</p>
105
+ <p class="empty" id="empty">The dashboard is empty. Add your first story.</p>
50
106
 
51
107
  <!-- Where the board continues past the edge: with no scrollbars, it is the only way to tell. -->
52
108
  <div class="edges" id="edges" aria-hidden="true"><i class="l"></i><i class="r"></i><i class="t"></i><i class="b"></i></div>
@@ -68,7 +124,7 @@
68
124
  <dialog id="editor">
69
125
  <form id="editor-form">
70
126
  <div class="head">
71
- <h2 id="editor-title">New card</h2>
127
+ <h2 id="editor-title">New story</h2>
72
128
  <button type="button" id="f-close" class="x" title="Close" aria-label="Close">
73
129
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
74
130
  </button>
@@ -81,20 +137,61 @@
81
137
  </div>
82
138
  </label>
83
139
 
84
- <label>Title
140
+ <label>Title <small class="aside">(this is also the session name)</small>
85
141
  <input id="f-title" required autocomplete="off" spellcheck="false" placeholder="Fix-Login-Bug">
86
- <small>this is also the session name</small>
87
142
  </label>
88
143
 
89
- <label>What Claude should do when it starts
90
- <textarea id="f-prompt" rows="6" placeholder="you can leave this empty and type straight into Claude Code"></textarea>
144
+ <!-- Notes, and only notes: they stay on the note and on the post-it, and nothing ever sends
145
+ them to a terminal. A new story does not have them at all — at that moment there is
146
+ nothing to note down yet, and a box asking to be filled in is a box that delays the
147
+ only two things worth doing with a new story, which are the two buttons at the bottom. -->
148
+ <label id="f-notes-row" hidden>Notes
149
+ <textarea id="f-notes" rows="6" placeholder="- what you want to remember about it&#10;- one per line"></textarea>
150
+ <small>they sit on the post-it for you to read — they are never sent to Claude Code</small>
91
151
  </label>
92
152
 
153
+ <!-- The epic this story belongs to. A list of the ones this repository already has, or a
154
+ name typed in that is made on the spot: an epic somebody wanted while writing a story
155
+ should not cost a trip to another screen. Only here when there is a backlog. -->
156
+ <label id="f-epic-row" hidden>Epic
157
+ <input id="f-epic" list="f-epics" autocomplete="off" placeholder="none — or type a new one">
158
+ <datalist id="f-epics"></datalist>
159
+ </label>
160
+
161
+
93
162
  <footer>
94
163
  <button type="button" id="f-delete" class="danger">Delete</button>
95
164
  <span class="spacer"></span>
96
165
  <button type="submit" id="f-save">Save</button>
97
- <button type="button" id="f-start" class="primary">Start</button>
166
+ <button type="button" id="f-discuss">Discuss</button>
167
+ <button type="button" id="f-start" class="primary">Quick Start</button>
168
+ </footer>
169
+ </form>
170
+ </dialog>
171
+
172
+ <!-- A new epic. It asks for the repository and nothing else, then opens a terminal: an epic is
173
+ born by being told, not by being typed into a form. -->
174
+ <dialog id="epic-new">
175
+ <form id="epic-form">
176
+ <div class="head">
177
+ <h2>New epic</h2>
178
+ <button type="button" id="e-close" class="x" title="Close" aria-label="Close">
179
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
180
+ </button>
181
+ </div>
182
+
183
+ <label>Repository
184
+ <select id="e-project" required></select>
185
+ </label>
186
+
187
+ <p class="note">k0 opens a terminal and <code>/k0-epic</code> asks what this is, a round of
188
+ questions at a time, until nothing is left that would change the work. It writes the epic
189
+ down itself, and the stories under it, once it knows.</p>
190
+
191
+ <footer>
192
+ <span class="spacer"></span>
193
+ <button type="button" id="e-cancel">Cancel</button>
194
+ <button type="submit" class="primary">Start the discussion</button>
98
195
  </footer>
99
196
  </form>
100
197
  </dialog>
package/web/json.js ADDED
@@ -0,0 +1,145 @@
1
+ // ── A JSON file, laid out ─────────────────────────────────────────────────────
2
+ // A `package.json` shown as text is a wall you read with your finger. What you actually want to
3
+ // know is what is under `scripts`, and everything else can stay shut until you ask for it.
4
+ //
5
+ // So: a tree that folds. The folding is `<details>`, which the browser already knows how to do —
6
+ // no state to keep, nothing to wire up, and it works before any script has run.
7
+ //
8
+ // And with the folding comes the reason this file also does the searching. The browser's own find
9
+ // does not look inside a closed `<details>`: on a folded tree ⌘F says "not found" for something
10
+ // that is right there. So the search is done here, on the data, and the branches that hold a
11
+ // result are handed back already open.
12
+ //
13
+ // It knows nothing about the page: text in, a string of HTML out. That is what makes it testable.
14
+
15
+ /**
16
+ * Written again rather than imported: this module is pulled in by the tests, which run in Node,
17
+ * where the browser's absolute `/md.js` does not resolve. Four lines are cheaper than making
18
+ * every pure module share a build.
19
+ */
20
+ const esc = (s) =>
21
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
22
+
23
+ /** How deep it arrives open. Two levels is the shape of the file; three is already its contents. */
24
+ const OPEN_DEPTH = 2
25
+
26
+ /**
27
+ * Past this it is not a document any more, it is a dump — and the tree would be a megabyte of
28
+ * HTML that no browser lays out quickly. The page falls back to showing the text as it is.
29
+ */
30
+ const MAX_NODES = 20000
31
+
32
+ /**
33
+ * The word found inside a piece of text, wrapped so it can be seen, and how many times it is
34
+ * there. The count is what the page shows next to the box: without it a search that finds
35
+ * nothing looks exactly like a search that has not run.
36
+ */
37
+ export function mark(value, query) {
38
+ const text = String(value ?? '')
39
+ if (!query) return { html: esc(text), n: 0 }
40
+ const hay = text.toLowerCase()
41
+ const needle = query.toLowerCase()
42
+ let out = ''
43
+ let at = 0
44
+ let n = 0
45
+ for (let i = hay.indexOf(needle, at); i !== -1; i = hay.indexOf(needle, at)) {
46
+ out += `${esc(text.slice(at, i))}<mark>${esc(text.slice(i, i + needle.length))}</mark>`
47
+ at = i + needle.length
48
+ n += 1
49
+ }
50
+ return { html: out + esc(text.slice(at)), n }
51
+ }
52
+
53
+ /** How a value is written, and what colour it is: the type is the colour. */
54
+ function leafValue(value) {
55
+ if (value === null) return { text: 'null', cls: 'nil' }
56
+ if (typeof value === 'string') return { text: value, cls: 'str' }
57
+ if (typeof value === 'number') return { text: String(value), cls: 'num' }
58
+ if (typeof value === 'boolean') return { text: String(value), cls: 'bool' }
59
+ return { text: String(value), cls: 'str' }
60
+ }
61
+
62
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
63
+
64
+ /**
65
+ * One entry of the tree, and everything under it.
66
+ *
67
+ * The number of results comes back up from the leaves rather than being counted again at the
68
+ * top, because it is the same walk: whoever found a result also knows which branch has to be
69
+ * open for it to be visible.
70
+ */
71
+ function node(key, value, depth, query, budget) {
72
+ if (budget.left <= 0) return { html: '', n: 0 }
73
+ budget.left -= 1
74
+
75
+ const label = key === null ? null : mark(key, query)
76
+ const name = label ? `<b class="key">${label.html}</b>` : ''
77
+
78
+ if (value !== null && typeof value === 'object') {
79
+ const entries = Array.isArray(value)
80
+ ? value.map((v, i) => [String(i), v])
81
+ : Object.entries(value)
82
+ const brace = Array.isArray(value) ? ['[', ']'] : ['{', '}']
83
+ const size = Array.isArray(value)
84
+ ? plural(entries.length, 'item', 'items')
85
+ : plural(entries.length, 'key', 'keys')
86
+
87
+ // An empty object has nothing to open: a summary with no contents is a fold that lies.
88
+ if (!entries.length) {
89
+ return {
90
+ html: `<div class="leaf">${name}<span class="v empty">${brace[0]}${brace[1]}</span></div>`,
91
+ n: label?.n ?? 0,
92
+ }
93
+ }
94
+
95
+ let inner = ''
96
+ let n = label?.n ?? 0
97
+ for (const [k, v] of entries) {
98
+ const child = node(k, v, depth + 1, query, budget)
99
+ inner += child.html
100
+ n += child.n
101
+ }
102
+ // With something searched for, only the branches holding a result open — that is the whole
103
+ // point of doing the search here. With nothing searched for, the first levels open, which is
104
+ // the shape of the file without its contents.
105
+ const open = query ? n > 0 : depth < OPEN_DEPTH
106
+ return {
107
+ html:
108
+ `<details class="node"${open ? ' open' : ''}><summary>${name}` +
109
+ `<span class="brace">${brace[0]}${brace[1]}</span><em>${size}</em></summary>` +
110
+ `<div class="kids">${inner}</div></details>`,
111
+ n,
112
+ }
113
+ }
114
+
115
+ const { text, cls } = leafValue(value)
116
+ const hit = mark(text, query)
117
+ return {
118
+ html: `<div class="leaf">${name}<span class="v ${cls}">${hit.html}</span></div>`,
119
+ n: (label?.n ?? 0) + hit.n,
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The whole file as a tree, with `query` picked out inside it.
125
+ *
126
+ * When it does not parse, nothing is invented: the reason comes back and the page shows the text
127
+ * exactly as it is on disk, which is the only honest thing to show for a file that is broken —
128
+ * and usually the reason you opened it.
129
+ *
130
+ * Returns `{ html, matches, error }`.
131
+ */
132
+ export function tree(text, query = '') {
133
+ let data
134
+ try {
135
+ data = JSON.parse(text)
136
+ } catch (e) {
137
+ return { html: '', matches: 0, error: String(e.message || e) }
138
+ }
139
+ const budget = { left: MAX_NODES }
140
+ const out = node(null, data, 0, String(query || '').trim(), budget)
141
+ if (budget.left <= 0) {
142
+ return { html: '', matches: 0, error: 'This file is too big to lay out as a tree.' }
143
+ }
144
+ return { html: `<div class="json">${out.html}</div>`, matches: out.n, error: null }
145
+ }