k0-board 0.2.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 +116 -1
- package/README.md +179 -18
- package/bin/k0-board.js +16 -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/db.js +11 -0
- package/server/files.js +105 -14
- package/server/idle.js +56 -0
- package/server/index.js +90 -4
- package/server/launcher.js +5 -1
- package/server/paths.js +5 -4
- package/server/settings.js +121 -0
- package/web/board.css +12 -0
- package/web/board.js +22 -4
- 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/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/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
|
+
}
|