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.
- package/.claude/skills/k0-changelog/SKILL.md +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +426 -1
- package/README.md +1021 -119
- package/bin/k0-board.js +81 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +37 -6
- package/platform/darwin/index.js +1 -0
- package/platform/darwin/shell.js +4 -1
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +3 -1
- package/platform/linux/index.js +14 -0
- package/platform/linux/shell.js +10 -1
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +6 -2
- package/platform/win32/shell.js +4 -1
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/db.js +1800 -146
- package/server/files.js +156 -26
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1286 -189
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/update.js +324 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/awake.js +62 -0
- package/web/base.css +4 -0
- package/web/board.css +404 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/conf.js +118 -0
- package/web/files.css +206 -1
- package/web/files.html +10 -0
- package/web/files.js +533 -75
- package/web/fuzzy.js +73 -21
- package/web/index.html +109 -12
- package/web/json.js +145 -0
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- 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
|
|
4
|
-
//
|
|
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
|
|
8
|
-
|
|
11
|
+
// previous one, and a letter at the start of a word.
|
|
12
|
+
|
|
13
|
+
const BOUNDARY = new Set(['-', '_', '.', ' '])
|
|
9
14
|
|
|
10
|
-
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
40
|
+
let from = 0
|
|
27
41
|
let last = -2
|
|
28
42
|
for (const ch of q) {
|
|
29
|
-
const hit = low.indexOf(ch,
|
|
30
|
-
if (hit === -1) return
|
|
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(
|
|
35
|
-
else if (
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
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 - 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-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
+
}
|