@nanocollective/roster 0.1.0-alpha.54 → 0.1.0-alpha.56
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/docs/portal.md +13 -0
- package/package.json +20 -18
- package/templates/ops/.github/workflows/session.yaml +4 -2
- package/templates/portal/css/search.css +48 -0
- package/templates/portal/index.html +5 -0
- package/templates/portal/js/app.js +15 -1
- package/templates/portal/js/icons.js +4 -0
- package/templates/portal/js/search.js +366 -0
- package/templates/portal/js/views/home.js +1 -1
package/docs/portal.md
CHANGED
|
@@ -18,6 +18,19 @@ Keep the repos checked out beside each other, in the same shape the runner uses.
|
|
|
18
18
|
|
|
19
19
|
## The sidebar
|
|
20
20
|
|
|
21
|
+
**Search everything** with ⌘K (Ctrl+K off a Mac), from anywhere, or the Search field at the
|
|
22
|
+
top of the sidebar, which opens the same dialog. It searches as you type, across:
|
|
23
|
+
|
|
24
|
+
- issues and pull requests, open and recently closed, which open in the side sheet
|
|
25
|
+
- staff, every fact in every memory, and every note and file in every brain
|
|
26
|
+
- the org files, the docs (every page, not only titles), and runs from the last 30 days, which
|
|
27
|
+
open their log on GitHub
|
|
28
|
+
- the screens themselves, so it doubles as a way to get anywhere
|
|
29
|
+
|
|
30
|
+
Every word has to match; a title that starts with one ranks first. Arrow keys move, Enter opens,
|
|
31
|
+
Escape closes. Everything except the docs is searched in the page, from what the portal has
|
|
32
|
+
already read.
|
|
33
|
+
|
|
21
34
|
Anything that asks before it acts (a merge, a push, a hire, a retire, a paid run) asks in the
|
|
22
35
|
page's own dialog, never the browser's `confirm()`, which blocks the whole tab.
|
|
23
36
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanocollective/roster",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.56",
|
|
4
4
|
"description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,23 @@
|
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=20"
|
|
18
18
|
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/cli.ts --format esm --target node20 --clean",
|
|
21
|
+
"dev": "tsx src/cli.ts",
|
|
22
|
+
"test": "tsx --test test/*.test.ts",
|
|
23
|
+
"test:all": "pnpm test:format && pnpm test:lint && pnpm test:types && pnpm test:knip && pnpm test",
|
|
24
|
+
"test:ava:coverage": "c8 --reporter=text --reporter=json-summary tsx --test test/*.test.ts",
|
|
25
|
+
"test:format": "biome check --no-errors-on-unmatched .",
|
|
26
|
+
"test:lint": "biome lint .",
|
|
27
|
+
"test:lint:fix": "biome check --write .",
|
|
28
|
+
"test:types": "tsc --noEmit",
|
|
29
|
+
"test:knip": "knip",
|
|
30
|
+
"test:audit": "pnpm audit --audit-level=high",
|
|
31
|
+
"test:security": "semgrep scan --config auto --error",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"format": "biome check --write .",
|
|
34
|
+
"prepublishOnly": "pnpm test:all && pnpm build"
|
|
35
|
+
},
|
|
19
36
|
"devDependencies": {
|
|
20
37
|
"@biomejs/biome": "^2.5.12",
|
|
21
38
|
"@types/node": "^22.10.2",
|
|
@@ -35,24 +52,9 @@
|
|
|
35
52
|
"url": "https://github.com/Nano-Collective/roster/issues"
|
|
36
53
|
},
|
|
37
54
|
"author": "Nano Collective",
|
|
55
|
+
"packageManager": "pnpm@11.0.9",
|
|
38
56
|
"publishConfig": {
|
|
39
57
|
"access": "public",
|
|
40
58
|
"tag": "latest"
|
|
41
|
-
},
|
|
42
|
-
"scripts": {
|
|
43
|
-
"build": "tsup src/cli.ts --format esm --target node20 --clean",
|
|
44
|
-
"dev": "tsx src/cli.ts",
|
|
45
|
-
"test": "tsx --test test/*.test.ts",
|
|
46
|
-
"test:all": "pnpm test:format && pnpm test:lint && pnpm test:types && pnpm test:knip && pnpm test",
|
|
47
|
-
"test:ava:coverage": "c8 --reporter=text --reporter=json-summary tsx --test test/*.test.ts",
|
|
48
|
-
"test:format": "biome check --no-errors-on-unmatched .",
|
|
49
|
-
"test:lint": "biome lint .",
|
|
50
|
-
"test:lint:fix": "biome check --write .",
|
|
51
|
-
"test:types": "tsc --noEmit",
|
|
52
|
-
"test:knip": "knip",
|
|
53
|
-
"test:audit": "pnpm audit --audit-level=high",
|
|
54
|
-
"test:security": "semgrep scan --config auto --error",
|
|
55
|
-
"typecheck": "tsc --noEmit",
|
|
56
|
-
"format": "biome check --write ."
|
|
57
59
|
}
|
|
58
|
-
}
|
|
60
|
+
}
|
|
@@ -360,8 +360,10 @@ jobs:
|
|
|
360
360
|
# The action refuses to run for a bot unless it is named here. A peer's ask is opened
|
|
361
361
|
# by that peer's App, so on a peer run, and only then, the one bot that opened it is
|
|
362
362
|
# allowed. The caller has already checked it is an App, not this staff member's own,
|
|
363
|
-
# and that the issue carries a from- label.
|
|
364
|
-
|
|
363
|
+
# and that the issue carries a from- label. A follow-on run is dispatched by the job token
|
|
364
|
+
# of the run before it, so its actor is github-actions[bot], and only that bot is allowed.
|
|
365
|
+
# Every other run allows no bots.
|
|
366
|
+
allowed_bots: ${{ inputs.trigger == 'peer' && github.actor || inputs.trigger == 'follow-on' && 'github-actions[bot]' || '' }}
|
|
365
367
|
# These repos are private and single-user, so the usual reason to hide a run's output does
|
|
366
368
|
# not apply, and hiding it turns any failure into "is_error: true" with no message.
|
|
367
369
|
# Registered secrets are still masked by the runner.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Search everything: the ⌘K dialog and the sidebar field that opens it. */
|
|
2
|
+
|
|
3
|
+
.sidesearch{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;
|
|
4
|
+
margin:0 0 10px;padding:6px 10px;border-radius:8px;border:1px solid var(--line);
|
|
5
|
+
background:var(--bg-elev);color:var(--ink-faint);font:13px var(--sans);cursor:pointer;text-align:left}
|
|
6
|
+
.sidesearch:hover{border-color:var(--border-strong);color:var(--ink-dim)}
|
|
7
|
+
.sidesearch:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
|
8
|
+
.sidesearch .ic{--ic:14px;flex:none}
|
|
9
|
+
.sidesearch .t{flex:1}
|
|
10
|
+
.sidesearch kbd{font:500 10.5px var(--mono);border:1px solid var(--border-strong);border-radius:5px;
|
|
11
|
+
padding:0 5px;color:var(--ink-faint)}
|
|
12
|
+
|
|
13
|
+
dialog.palette{position:fixed;margin:12vh auto auto;padding:0;width:min(640px,calc(100vw - 32px));
|
|
14
|
+
max-height:min(560px,76vh);display:flex;flex-direction:column;overflow:hidden;
|
|
15
|
+
border:1px solid var(--border-strong);border-radius:var(--r-lg);background:var(--bg-elev);
|
|
16
|
+
color:var(--ink);box-shadow:var(--shadow-lg)}
|
|
17
|
+
dialog.palette:not([open]){display:none}
|
|
18
|
+
dialog.palette[open]{animation:dlgin .16s var(--ease)}
|
|
19
|
+
dialog.palette::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
|
|
20
|
+
@media (prefers-reduced-motion:reduce){dialog.palette[open]{animation:none}}
|
|
21
|
+
|
|
22
|
+
.palhead{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line)}
|
|
23
|
+
.palhead .ic{--ic:18px;color:var(--ink-faint);flex:none}
|
|
24
|
+
/* Scoped under the dialog to outrank the portal's own input[type=search] rules, which draw a
|
|
25
|
+
box and a focus ring that belong in a form, not in a search bar that is the whole header. */
|
|
26
|
+
dialog.palette .palinput,dialog.palette .palinput:hover,dialog.palette .palinput:focus{
|
|
27
|
+
flex:1;width:auto;border:0;outline:none;box-shadow:none;background:none;padding:0;
|
|
28
|
+
color:var(--ink);font:16px/1.4 var(--sans)}
|
|
29
|
+
.palinput::-webkit-search-cancel-button{display:none}
|
|
30
|
+
|
|
31
|
+
.palresults{overflow-y:auto;padding:6px;flex:1}
|
|
32
|
+
.palgroup{font:600 10.5px var(--sans);letter-spacing:.06em;text-transform:uppercase;
|
|
33
|
+
color:var(--ink-faint);padding:10px 10px 4px}
|
|
34
|
+
.palrow{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:10px;width:100%;
|
|
35
|
+
padding:8px 10px;border-radius:8px;cursor:pointer}
|
|
36
|
+
.palrow[aria-selected="true"]{background:var(--sel)}
|
|
37
|
+
.palrow .ic{--ic:15px;color:var(--ink-dim);flex:none}
|
|
38
|
+
.paltext{display:flex;flex-direction:column;min-width:0}
|
|
39
|
+
.paltitle{font:500 13.5px/1.35 var(--sans);color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
40
|
+
.paltitle mark{background:var(--accent-soft);color:inherit;border-radius:3px;padding:0 1px}
|
|
41
|
+
.palsub{font:12px/1.4 var(--sans);color:var(--ink-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
42
|
+
.palempty{color:var(--ink-faint);font-size:13px;padding:22px 12px;margin:0;text-align:center}
|
|
43
|
+
|
|
44
|
+
.palfoot{display:flex;gap:16px;padding:8px 14px;border-top:1px solid var(--line);
|
|
45
|
+
font:11.5px var(--sans);color:var(--ink-faint)}
|
|
46
|
+
.palfoot kbd{font:500 10.5px var(--mono);border:1px solid var(--border-strong);border-radius:4px;
|
|
47
|
+
padding:0 4px;margin-right:3px}
|
|
48
|
+
@media (max-width:640px){.palfoot{display:none}dialog.palette{margin-top:8vh}}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<link rel="stylesheet" href="/assets/css/brain.css">
|
|
15
15
|
<link rel="stylesheet" href="/assets/css/inbox.css">
|
|
16
16
|
<link rel="stylesheet" href="/assets/css/home.css">
|
|
17
|
+
<link rel="stylesheet" href="/assets/css/search.css">
|
|
17
18
|
<link rel="stylesheet" href="/assets/css/graph.css">
|
|
18
19
|
<link rel="stylesheet" href="/assets/css/diff.css">
|
|
19
20
|
<link rel="stylesheet" href="/assets/css/health.css">
|
|
@@ -28,6 +29,10 @@
|
|
|
28
29
|
<span class="sync" data-icon="refresh"></span><span id="loaded">data now</span>
|
|
29
30
|
</button>
|
|
30
31
|
|
|
32
|
+
<button class="sidesearch" id="sidesearch" title="Search everything (⌘K)">
|
|
33
|
+
<span class="ic" data-icon="search"></span><span class="t">Search</span><kbd id="searchkey">⌘K</kbd>
|
|
34
|
+
</button>
|
|
35
|
+
|
|
31
36
|
<!-- Only while something is left to set up; app.js decides. -->
|
|
32
37
|
<button class="nav top" id="setupnav" data-view="setup" hidden>
|
|
33
38
|
<span class="ic" data-icon="task-done"></span> Getting started
|
|
@@ -20,6 +20,7 @@ import { viewChanged } from "./views/changed.js";
|
|
|
20
20
|
import { viewDocs } from "./views/docs.js";
|
|
21
21
|
import { viewGraph } from "./views/graph.js";
|
|
22
22
|
import { viewHealth } from "./views/health.js";
|
|
23
|
+
import { openSearch } from "./search.js";
|
|
23
24
|
import { viewHome } from "./views/home.js";
|
|
24
25
|
import { viewInbox, viewPrs } from "./views/inbox.js";
|
|
25
26
|
import { viewOrg } from "./views/org.js";
|
|
@@ -102,7 +103,7 @@ export async function boot() {
|
|
|
102
103
|
/* Every one of these reads a tenant, so during setup they answer 409 and the sidebar is a
|
|
103
104
|
row of four dead ends beside an empty Staff heading. There is one thing to do on this
|
|
104
105
|
screen; the navigation comes back with the org. */
|
|
105
|
-
for (const el of document.querySelectorAll("#homenav, #inboxnav, #runsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
|
|
106
|
+
for (const el of document.querySelectorAll("#sidesearch, #homenav, #inboxnav, #runsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
|
|
106
107
|
el.hidden = true;
|
|
107
108
|
}
|
|
108
109
|
for (const slot of document.querySelectorAll("[data-icon]")) {
|
|
@@ -144,6 +145,19 @@ export async function boot() {
|
|
|
144
145
|
addEventListener("focus", () => {
|
|
145
146
|
if (S.loadedAt && Date.now() - S.loadedAt > 60000) refreshQuietly();
|
|
146
147
|
});
|
|
148
|
+
/* ⌘K (Ctrl+K off a Mac) opens search from anywhere, even from inside a text box: it is the
|
|
149
|
+
one shortcut that should never be swallowed by whatever has focus. The sidebar's field
|
|
150
|
+
opens the same dialog. */
|
|
151
|
+
addEventListener("keydown", (e) => {
|
|
152
|
+
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
openSearch();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
const side = $("#sidesearch");
|
|
158
|
+
if (side) side.onclick = () => openSearch();
|
|
159
|
+
const key = $("#searchkey");
|
|
160
|
+
if (key && !/Mac|iPhone|iPad/.test(navigator.platform ?? "")) key.textContent = "Ctrl K";
|
|
147
161
|
addEventListener("keydown", (e) => {
|
|
148
162
|
if (e.key === "r" && !e.metaKey && !e.ctrlKey &&
|
|
149
163
|
!/^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName ?? "")) {
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const PATHS = {
|
|
16
|
+
"search":
|
|
17
|
+
"<circle cx=\"11\" cy=\"11\" r=\"8\" /> <path d=\"m21 21-4.3-4.3\" />",
|
|
18
|
+
"lightbulb":
|
|
19
|
+
"<path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\" /> <path d=\"M9 18h6\" /> <path d=\"M10 22h4\" />",
|
|
16
20
|
"code":
|
|
17
21
|
"<polyline points=\"16 18 22 12 16 6\" /> <polyline points=\"8 6 2 12 8 18\" />",
|
|
18
22
|
"megaphone":
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/* Search everything: ⌘K, or the search field in the sidebar.
|
|
2
|
+
*
|
|
3
|
+
* One dialog over the whole portal: issues and pull requests, staff, every fact and file in
|
|
4
|
+
* every brain, the org files, the docs, runs, and the screens themselves. All of it except the
|
|
5
|
+
* docs is already in the page, so it is searched here as you type; the docs are searched on the
|
|
6
|
+
* server, which reads every page rather than only titles.
|
|
7
|
+
*
|
|
8
|
+
* `index()` and `rank()` are plain functions over plain data, so what is found, and in which
|
|
9
|
+
* order, can be tested without a page. */
|
|
10
|
+
|
|
11
|
+
import { getOrgLayer, getRuns, searchDocs } from "./api.js";
|
|
12
|
+
import { ago, el } from "./dom.js";
|
|
13
|
+
import { icon, staffIcon } from "./icons.js";
|
|
14
|
+
import { ensureInbox } from "./refresh.js";
|
|
15
|
+
import { go } from "./router.js";
|
|
16
|
+
import { S } from "./state.js";
|
|
17
|
+
import { openThread } from "./views/home.js";
|
|
18
|
+
|
|
19
|
+
/** The groups, in the order they are shown. */
|
|
20
|
+
const GROUPS = ["Screens", "Staff", "Issues", "Pull requests", "Memory", "Files", "Org", "Runs", "Docs"];
|
|
21
|
+
/** How many of each a query shows; an empty query shows only screens and staff. */
|
|
22
|
+
const PER_GROUP = 5;
|
|
23
|
+
|
|
24
|
+
const SCREENS = [
|
|
25
|
+
["home", "Home", "What needs you, and who is working"],
|
|
26
|
+
["inbox", "Trackers", "Every open issue on each tracker"],
|
|
27
|
+
["runs", "Runs", "What ran, and what it cost"],
|
|
28
|
+
["org", "Org", "The files every staff member reads"],
|
|
29
|
+
["staff", "Staff", "Hire, set up, retire"],
|
|
30
|
+
["docs", "Docs", "How roster works"],
|
|
31
|
+
];
|
|
32
|
+
const STAFF_SCREENS = [
|
|
33
|
+
["brain", "Brain"],
|
|
34
|
+
["prompt", "Prompt"],
|
|
35
|
+
["graph", "Graph"],
|
|
36
|
+
["changed", "What changed"],
|
|
37
|
+
["health", "Health"],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every searchable thing, as `{ group, title, sub, text, icon, open }`. `text` is what is
|
|
42
|
+
* matched beyond the title; `open` is what choosing it does.
|
|
43
|
+
*/
|
|
44
|
+
export function index(state) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const staff = state.data?.staff ?? [];
|
|
47
|
+
|
|
48
|
+
for (const [view, title, sub] of SCREENS) {
|
|
49
|
+
out.push({ group: "Screens", title, sub, text: view, icon: "square", open: () => go({ view }) });
|
|
50
|
+
}
|
|
51
|
+
for (const s of staff) {
|
|
52
|
+
for (const [view, label] of STAFF_SCREENS) {
|
|
53
|
+
out.push({
|
|
54
|
+
group: "Screens",
|
|
55
|
+
title: `${s.name} · ${label}`,
|
|
56
|
+
sub: s.handle,
|
|
57
|
+
text: `${s.handle} ${label}`,
|
|
58
|
+
icon: staffIcon(s),
|
|
59
|
+
open: () => go({ view, staffHandle: s.handle }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const s of staff) {
|
|
65
|
+
out.push({
|
|
66
|
+
group: "Staff",
|
|
67
|
+
title: s.name,
|
|
68
|
+
sub: [s.handle, s.brain].filter(Boolean).join(" · "),
|
|
69
|
+
text: `${s.handle} ${s.brain ?? ""} ${s.mention ?? ""}`,
|
|
70
|
+
icon: staffIcon(s),
|
|
71
|
+
open: () => go({ view: "brain", staffHandle: s.handle, openFile: "mem:*", fileQuery: "" }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
for (const f of s.facts ?? []) {
|
|
75
|
+
out.push({
|
|
76
|
+
group: "Memory",
|
|
77
|
+
title: f.statement,
|
|
78
|
+
sub: `${s.name} · ${f.section} · ${f.slug}`,
|
|
79
|
+
text: `${f.slug} ${f.consequence ?? ""} ${f.section}`,
|
|
80
|
+
icon: "lightbulb",
|
|
81
|
+
open: () =>
|
|
82
|
+
go({ view: "brain", staffHandle: s.handle, openFile: "fact:" + f.slug, fileQuery: "" }),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const surface of s.surfaces ?? []) {
|
|
87
|
+
for (const file of surface.files ?? []) {
|
|
88
|
+
const note = /(^|\/)notes\//.test(file.path);
|
|
89
|
+
out.push({
|
|
90
|
+
group: note ? "Memory" : "Files",
|
|
91
|
+
title: file.path.split("/").pop(),
|
|
92
|
+
sub: `${s.name} · ${file.path}`,
|
|
93
|
+
text: file.path,
|
|
94
|
+
icon: "file",
|
|
95
|
+
open: () => go({ view: "brain", staffHandle: s.handle, openFile: file.path, fileQuery: "" }),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const brains = new Map(staff.filter((s) => s.brain).map((s) => [s.brain, s]));
|
|
102
|
+
for (const item of state.inbox?.items ?? []) {
|
|
103
|
+
const owner = brains.get(item.repo);
|
|
104
|
+
const repo = item.repo.split("/")[1] ?? item.repo;
|
|
105
|
+
const where = owner ? owner.name : repo;
|
|
106
|
+
const open = item.state === "OPEN";
|
|
107
|
+
out.push({
|
|
108
|
+
group: item.kind === "pr" ? "Pull requests" : "Issues",
|
|
109
|
+
title: item.title,
|
|
110
|
+
sub: `${where} · #${item.number} · ${open ? "open" : item.state.toLowerCase()} · ${ago(item.updatedAt)}`,
|
|
111
|
+
text: `#${item.number} ${repo}#${item.number} ${(item.labels ?? []).join(" ")} ${item.author}`,
|
|
112
|
+
icon: item.kind === "pr" ? (item.state === "MERGED" ? "merged" : "review") : open ? "issue-open" : "issue-closed",
|
|
113
|
+
// Open work first among equal matches: it is usually what is being looked for.
|
|
114
|
+
weight: open ? 1 : 0,
|
|
115
|
+
open: () => openThread(item),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const f of state.orgFiles ?? []) {
|
|
120
|
+
out.push({
|
|
121
|
+
group: "Org",
|
|
122
|
+
title: f.path.split("/").pop(),
|
|
123
|
+
sub: f.path,
|
|
124
|
+
text: f.path,
|
|
125
|
+
icon: "file",
|
|
126
|
+
open: () => go({ view: "org", orgOpen: f.path }),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const s of state.runs?.staff ?? []) {
|
|
131
|
+
for (const r of s.runs ?? []) {
|
|
132
|
+
const word = r.record?.outcome ?? r.conclusion ?? r.status;
|
|
133
|
+
out.push({
|
|
134
|
+
group: "Runs",
|
|
135
|
+
title: `${s.name} · ${r.kind} run · ${word}`,
|
|
136
|
+
sub: `${ago(r.createdAt)} · opens the log on GitHub`,
|
|
137
|
+
text: `${s.handle} ${r.kind} ${word} ${r.id}`,
|
|
138
|
+
icon: "play",
|
|
139
|
+
open: () => window.open(r.url, "_blank", "noopener"),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The best matches for a query, grouped. Every word has to appear somewhere; a match in the
|
|
149
|
+
* title outranks one elsewhere, and the start of the title outranks the middle of it.
|
|
150
|
+
*/
|
|
151
|
+
export function rank(entries, query) {
|
|
152
|
+
const words = String(query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
153
|
+
const groups = new Map(GROUPS.map((g) => [g, []]));
|
|
154
|
+
if (!words.length) {
|
|
155
|
+
for (const e of entries) {
|
|
156
|
+
if (e.group === "Screens" || e.group === "Staff") groups.get(e.group).push({ e, score: 0 });
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
for (const e of entries) {
|
|
160
|
+
const title = e.title.toLowerCase();
|
|
161
|
+
const all = `${title} ${String(e.sub ?? "").toLowerCase()} ${String(e.text ?? "").toLowerCase()}`;
|
|
162
|
+
if (!words.every((w) => all.includes(w))) continue;
|
|
163
|
+
let score = e.weight ?? 0;
|
|
164
|
+
for (const w of words) {
|
|
165
|
+
if (title.startsWith(w)) score += 6;
|
|
166
|
+
else if (new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(title)) score += 4;
|
|
167
|
+
else if (title.includes(w)) score += 2;
|
|
168
|
+
}
|
|
169
|
+
groups.get(e.group)?.push({ e, score });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const [group, list] of groups) {
|
|
174
|
+
if (!list.length) continue;
|
|
175
|
+
list.sort((a, b) => b.score - a.score);
|
|
176
|
+
const shown = list.slice(0, words.length ? PER_GROUP : 40);
|
|
177
|
+
out.push({ group, entries: shown.map((x) => x.e), more: list.length - shown.length });
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* --------------------------------- the dialog --------------------------------- */
|
|
183
|
+
|
|
184
|
+
let current = null;
|
|
185
|
+
|
|
186
|
+
/** Open the search dialog, or focus it when it is already open. */
|
|
187
|
+
export function openSearch() {
|
|
188
|
+
if (current) {
|
|
189
|
+
current.input.focus();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const box = document.createElement("dialog");
|
|
193
|
+
if (typeof box.showModal !== "function") return;
|
|
194
|
+
box.className = "palette";
|
|
195
|
+
|
|
196
|
+
const input = el("input", {
|
|
197
|
+
type: "search",
|
|
198
|
+
className: "palinput",
|
|
199
|
+
placeholder: "Search issues, pull requests, memory, files, runs and docs",
|
|
200
|
+
autocomplete: "off",
|
|
201
|
+
spellcheck: false,
|
|
202
|
+
});
|
|
203
|
+
const results = el("div", { className: "palresults", role: "listbox" });
|
|
204
|
+
const foot = el("div", { className: "palfoot" }, [
|
|
205
|
+
el("span", { innerHTML: "<kbd>↑</kbd><kbd>↓</kbd> move" }),
|
|
206
|
+
el("span", { innerHTML: "<kbd>↵</kbd> open" }),
|
|
207
|
+
el("span", { innerHTML: "<kbd>esc</kbd> close" }),
|
|
208
|
+
]);
|
|
209
|
+
box.append(el("div", { className: "palhead" }, [icon("search", "ic"), input]), results, foot);
|
|
210
|
+
document.body.append(box);
|
|
211
|
+
box.showModal();
|
|
212
|
+
input.focus();
|
|
213
|
+
|
|
214
|
+
let entries = index(S);
|
|
215
|
+
let flat = [];
|
|
216
|
+
let at = 0;
|
|
217
|
+
let docs = [];
|
|
218
|
+
let docsFor = "";
|
|
219
|
+
let docTimer = null;
|
|
220
|
+
|
|
221
|
+
const close = () => {
|
|
222
|
+
if (!current) return;
|
|
223
|
+
current = null;
|
|
224
|
+
clearTimeout(docTimer);
|
|
225
|
+
box.close();
|
|
226
|
+
box.remove();
|
|
227
|
+
};
|
|
228
|
+
current = { input, close };
|
|
229
|
+
box.addEventListener("close", () => {
|
|
230
|
+
current = null;
|
|
231
|
+
box.remove();
|
|
232
|
+
});
|
|
233
|
+
box.addEventListener("click", (e) => {
|
|
234
|
+
if (e.target === box) close();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const choose = (e) => {
|
|
238
|
+
close();
|
|
239
|
+
e.open();
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const paint = () => {
|
|
243
|
+
const q = input.value.trim();
|
|
244
|
+
const grouped = rank(entries, q);
|
|
245
|
+
if (q && docsFor === q && docs.length) {
|
|
246
|
+
grouped.push({
|
|
247
|
+
group: "Docs",
|
|
248
|
+
entries: docs.slice(0, PER_GROUP).map((h) => ({
|
|
249
|
+
title: h.title,
|
|
250
|
+
sub: h.matches?.[0]?.text ?? h.file,
|
|
251
|
+
icon: "book-open",
|
|
252
|
+
open: () => go({ view: "docs", openDoc: h.file, docQuery: q }),
|
|
253
|
+
})),
|
|
254
|
+
more: Math.max(0, docs.length - PER_GROUP),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
flat = grouped.flatMap((g) => g.entries);
|
|
258
|
+
at = Math.min(at, Math.max(0, flat.length - 1));
|
|
259
|
+
results.replaceChildren();
|
|
260
|
+
if (!flat.length) {
|
|
261
|
+
results.append(el("p", { className: "palempty", textContent: q ? "Nothing matches “" + q + "”." : "" }));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
let i = 0;
|
|
265
|
+
for (const g of grouped) {
|
|
266
|
+
results.append(
|
|
267
|
+
el("div", { className: "palgroup", textContent: g.group + (g.more ? ` · ${g.more} more` : "") }),
|
|
268
|
+
);
|
|
269
|
+
for (const e of g.entries) {
|
|
270
|
+
const n = i++;
|
|
271
|
+
const row = el("button", { className: "palrow", role: "option" }, [
|
|
272
|
+
icon(e.icon ?? "file", "ic"),
|
|
273
|
+
el("span", { className: "paltext" }, [
|
|
274
|
+
el("span", { className: "paltitle", innerHTML: highlight(e.title, q) }),
|
|
275
|
+
el("span", { className: "palsub", textContent: e.sub ?? "" }),
|
|
276
|
+
]),
|
|
277
|
+
]);
|
|
278
|
+
row.setAttribute("aria-selected", String(n === at));
|
|
279
|
+
row.onmouseenter = () => {
|
|
280
|
+
at = n;
|
|
281
|
+
mark();
|
|
282
|
+
};
|
|
283
|
+
row.onclick = () => choose(e);
|
|
284
|
+
results.append(row);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
mark();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const mark = () => {
|
|
291
|
+
const rows = results.querySelectorAll(".palrow");
|
|
292
|
+
rows.forEach((r, n) => r.setAttribute("aria-selected", String(n === at)));
|
|
293
|
+
rows[at]?.scrollIntoView({ block: "nearest" });
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
input.addEventListener("input", () => {
|
|
297
|
+
at = 0;
|
|
298
|
+
paint();
|
|
299
|
+
const q = input.value.trim();
|
|
300
|
+
clearTimeout(docTimer);
|
|
301
|
+
if (q.length < 2) return;
|
|
302
|
+
docTimer = setTimeout(() => {
|
|
303
|
+
searchDocs(q)
|
|
304
|
+
.then((r) => {
|
|
305
|
+
docs = r.hits ?? [];
|
|
306
|
+
docsFor = q;
|
|
307
|
+
if (current && input.value.trim() === q) paint();
|
|
308
|
+
})
|
|
309
|
+
.catch(() => {});
|
|
310
|
+
}, 150);
|
|
311
|
+
});
|
|
312
|
+
input.addEventListener("keydown", (e) => {
|
|
313
|
+
if (e.key === "ArrowDown") {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
at = Math.min(at + 1, flat.length - 1);
|
|
316
|
+
mark();
|
|
317
|
+
} else if (e.key === "ArrowUp") {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
at = Math.max(at - 1, 0);
|
|
320
|
+
mark();
|
|
321
|
+
} else if (e.key === "Enter") {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
if (flat[at]) choose(flat[at]);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
paint();
|
|
328
|
+
|
|
329
|
+
/* What is not in the page yet is fetched once and folded in as it lands: the issues, the org
|
|
330
|
+
files and the runs. The dialog is usable straight away with whatever is already here. */
|
|
331
|
+
const refill = () => {
|
|
332
|
+
if (!current) return;
|
|
333
|
+
entries = index(S);
|
|
334
|
+
paint();
|
|
335
|
+
};
|
|
336
|
+
if (!S.inbox) ensureInbox(false).then(refill).catch(() => {});
|
|
337
|
+
if (!S.orgFiles) {
|
|
338
|
+
getOrgLayer()
|
|
339
|
+
.then((r) => {
|
|
340
|
+
S.orgFiles = r.files ?? [];
|
|
341
|
+
refill();
|
|
342
|
+
})
|
|
343
|
+
.catch(() => {});
|
|
344
|
+
}
|
|
345
|
+
if (!S.runs) {
|
|
346
|
+
getRuns(false)
|
|
347
|
+
.then((r) => {
|
|
348
|
+
S.runs = r;
|
|
349
|
+
refill();
|
|
350
|
+
})
|
|
351
|
+
.catch(() => {});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The title with each query word marked, escaped first. */
|
|
356
|
+
function highlight(title, q) {
|
|
357
|
+
let html = String(title ?? "")
|
|
358
|
+
.replace(/&/g, "&")
|
|
359
|
+
.replace(/</g, "<")
|
|
360
|
+
.replace(/>/g, ">");
|
|
361
|
+
for (const w of String(q ?? "").toLowerCase().split(/\s+/).filter((x) => x.length > 1)) {
|
|
362
|
+
const re = new RegExp(`(${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "ig");
|
|
363
|
+
html = html.replace(re, "<mark>$1</mark>");
|
|
364
|
+
}
|
|
365
|
+
return html;
|
|
366
|
+
}
|
|
@@ -541,7 +541,7 @@ function repaintInPlace() {
|
|
|
541
541
|
* repaints Home, since a reply or a close there changes what belongs where. Where there is no
|
|
542
542
|
* <dialog> (the test shim), it falls back to the thread on Trackers.
|
|
543
543
|
*/
|
|
544
|
-
function openThread(item) {
|
|
544
|
+
export function openThread(item) {
|
|
545
545
|
const ref = { repo: item.repo, number: item.number, kind: item.kind };
|
|
546
546
|
const body = el("div");
|
|
547
547
|
const x = el("button", { className: "iconbtn", title: "Close (Esc)", ariaLabel: "Close" });
|