proctolog 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/README.md +178 -0
- data/app/controllers/proctolog/acceptances_controller.rb +29 -0
- data/app/controllers/proctolog/application_controller.rb +32 -0
- data/app/controllers/proctolog/bursts_controller.rb +34 -0
- data/app/controllers/proctolog/choices_controller.rb +30 -0
- data/app/controllers/proctolog/code_reviews_controller.rb +26 -0
- data/app/controllers/proctolog/decisions_controller.rb +33 -0
- data/app/controllers/proctolog/entries_controller.rb +14 -0
- data/app/controllers/proctolog/evidence_controller.rb +23 -0
- data/app/controllers/proctolog/freshness_controller.rb +27 -0
- data/app/controllers/proctolog/good_to_go_controller.rb +29 -0
- data/app/controllers/proctolog/memberships_controller.rb +20 -0
- data/app/controllers/proctolog/mentions_controller.rb +16 -0
- data/app/controllers/proctolog/moments_controller.rb +26 -0
- data/app/controllers/proctolog/note_params.rb +24 -0
- data/app/controllers/proctolog/notes_controller.rb +56 -0
- data/app/controllers/proctolog/open_pull_requests_controller.rb +59 -0
- data/app/controllers/proctolog/pictures_controller.rb +27 -0
- data/app/controllers/proctolog/prompts_controller.rb +28 -0
- data/app/controllers/proctolog/pull_request_scoped.rb +21 -0
- data/app/controllers/proctolog/pull_requests_controller.rb +34 -0
- data/app/controllers/proctolog/put_away_controller.rb +19 -0
- data/app/controllers/proctolog/recordings_controller.rb +28 -0
- data/app/controllers/proctolog/rendered_pieces_controller.rb +11 -0
- data/app/controllers/proctolog/review_data_controller.rb +12 -0
- data/app/controllers/proctolog/rules_controller.rb +20 -0
- data/app/controllers/proctolog/scenario_notes_controller.rb +26 -0
- data/app/controllers/proctolog/scenario_scoped.rb +21 -0
- data/app/controllers/proctolog/scenarios_controller.rb +67 -0
- data/app/controllers/proctolog/sitting_servers_controller.rb +30 -0
- data/app/controllers/proctolog/waiting_controller.rb +14 -0
- data/app/controllers/proctolog/with_decisions.rb +28 -0
- data/app/helpers/proctolog/bursts_helper.rb +52 -0
- data/app/helpers/proctolog/decisions_helper.rb +102 -0
- data/app/helpers/proctolog/desk_helper.rb +61 -0
- data/app/helpers/proctolog/mentions_helper.rb +27 -0
- data/app/helpers/proctolog/open_pull_requests_helper.rb +31 -0
- data/app/helpers/proctolog/prompts_helper.rb +21 -0
- data/app/helpers/proctolog/pull_requests_helper.rb +67 -0
- data/app/helpers/proctolog/recordings_helper.rb +30 -0
- data/app/helpers/proctolog/rounds_helper.rb +27 -0
- data/app/helpers/proctolog/scenarios_helper.rb +123 -0
- data/app/helpers/proctolog/standings_helper.rb +80 -0
- data/app/jobs/proctolog/application_job.rb +8 -0
- data/app/jobs/proctolog/ask_for_release_job.rb +9 -0
- data/app/jobs/proctolog/fetch_pictures_ahead_job.rb +36 -0
- data/app/jobs/proctolog/prune_fresh_profiles_job.rb +9 -0
- data/app/jobs/proctolog/read_mentions_ahead_job.rb +12 -0
- data/app/models/proctolog/acceptance.rb +47 -0
- data/app/models/proctolog/app.rb +92 -0
- data/app/models/proctolog/browser.rb +42 -0
- data/app/models/proctolog/burst.rb +268 -0
- data/app/models/proctolog/burst_notices.rb +143 -0
- data/app/models/proctolog/chapters.rb +42 -0
- data/app/models/proctolog/check.rb +122 -0
- data/app/models/proctolog/choices.rb +112 -0
- data/app/models/proctolog/code_review.rb +251 -0
- data/app/models/proctolog/decision_record.rb +12 -0
- data/app/models/proctolog/decisions.rb +199 -0
- data/app/models/proctolog/description.rb +165 -0
- data/app/models/proctolog/difference.rb +138 -0
- data/app/models/proctolog/doctor.rb +127 -0
- data/app/models/proctolog/evidence.rb +29 -0
- data/app/models/proctolog/failure.rb +7 -0
- data/app/models/proctolog/focus.rb +48 -0
- data/app/models/proctolog/fresh_sitting.rb +35 -0
- data/app/models/proctolog/github.rb +353 -0
- data/app/models/proctolog/handover/brief.rb +138 -0
- data/app/models/proctolog/handover/layers.rb +128 -0
- data/app/models/proctolog/handover/places.rb +98 -0
- data/app/models/proctolog/handover/taken.rb +41 -0
- data/app/models/proctolog/handover/worklist.rb +35 -0
- data/app/models/proctolog/handover.rb +123 -0
- data/app/models/proctolog/housekeeping.rb +23 -0
- data/app/models/proctolog/kit.rb +36 -0
- data/app/models/proctolog/marks.rb +130 -0
- data/app/models/proctolog/membership.rb +91 -0
- data/app/models/proctolog/mentions.rb +117 -0
- data/app/models/proctolog/note.rb +94 -0
- data/app/models/proctolog/outside.rb +45 -0
- data/app/models/proctolog/overlay/brief.rb +52 -0
- data/app/models/proctolog/overlay/written.rb +33 -0
- data/app/models/proctolog/overlay.rb +87 -0
- data/app/models/proctolog/owner.rb +16 -0
- data/app/models/proctolog/pick.rb +47 -0
- data/app/models/proctolog/picture.rb +138 -0
- data/app/models/proctolog/pieces_rendering.rb +40 -0
- data/app/models/proctolog/project.rb +118 -0
- data/app/models/proctolog/pull_request.rb +89 -0
- data/app/models/proctolog/recording.rb +194 -0
- data/app/models/proctolog/release.rb +59 -0
- data/app/models/proctolog/rendering.rb +303 -0
- data/app/models/proctolog/reports.rb +66 -0
- data/app/models/proctolog/response_files.rb +55 -0
- data/app/models/proctolog/rounds.rb +305 -0
- data/app/models/proctolog/route.rb +151 -0
- data/app/models/proctolog/rules.rb +121 -0
- data/app/models/proctolog/scenario.rb +126 -0
- data/app/models/proctolog/server.rb +273 -0
- data/app/models/proctolog/since.rb +108 -0
- data/app/models/proctolog/size.rb +30 -0
- data/app/models/proctolog/standing.rb +132 -0
- data/app/models/proctolog/theme.rb +40 -0
- data/app/models/proctolog/unopened/numbers.rb +48 -0
- data/app/models/proctolog/unopened/opening.rb +88 -0
- data/app/models/proctolog/unopened.rb +203 -0
- data/app/models/proctolog/visit.rb +26 -0
- data/app/views/layouts/proctolog/application.html.erb +58 -0
- data/app/views/proctolog/acceptances/new.html.erb +34 -0
- data/app/views/proctolog/bursts/_line.html.erb +28 -0
- data/app/views/proctolog/bursts/_merged.html.erb +23 -0
- data/app/views/proctolog/bursts/_moved.html.erb +16 -0
- data/app/views/proctolog/bursts/_notices.html.erb +39 -0
- data/app/views/proctolog/bursts/_put_away.html.erb +11 -0
- data/app/views/proctolog/bursts/_rules.html.erb +27 -0
- data/app/views/proctolog/bursts/_side.html.erb +49 -0
- data/app/views/proctolog/bursts/_side_stop.html.erb +10 -0
- data/app/views/proctolog/bursts/show.html.erb +54 -0
- data/app/views/proctolog/code_reviews/show.html.erb +33 -0
- data/app/views/proctolog/decisions/_alternative.html.erb +5 -0
- data/app/views/proctolog/decisions/_beneath.html.erb +21 -0
- data/app/views/proctolog/decisions/_box.html.erb +27 -0
- data/app/views/proctolog/decisions/_choices.html.erb +9 -0
- data/app/views/proctolog/decisions/_draft_tools.html.erb +47 -0
- data/app/views/proctolog/decisions/_part.html.erb +61 -0
- data/app/views/proctolog/decisions/_round_data.html.erb +1 -0
- data/app/views/proctolog/decisions/_theme.html.erb +13 -0
- data/app/views/proctolog/decisions/index.html.erb +75 -0
- data/app/views/proctolog/notes/_card.html.erb +19 -0
- data/app/views/proctolog/notes/_template.html.erb +41 -0
- data/app/views/proctolog/open_pull_requests/_side.html.erb +30 -0
- data/app/views/proctolog/open_pull_requests/index.html.erb +106 -0
- data/app/views/proctolog/prompts/show.html.erb +41 -0
- data/app/views/proctolog/pull_requests/_connections.html.erb +65 -0
- data/app/views/proctolog/pull_requests/_finish.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_margin.html.erb +33 -0
- data/app/views/proctolog/pull_requests/_note.html.erb +4 -0
- data/app/views/proctolog/pull_requests/_outline.html.erb +26 -0
- data/app/views/proctolog/pull_requests/_paragraphs.html.erb +9 -0
- data/app/views/proctolog/pull_requests/_scenarios.html.erb +16 -0
- data/app/views/proctolog/pull_requests/_since.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_standing.html.erb +51 -0
- data/app/views/proctolog/pull_requests/_status.html.erb +5 -0
- data/app/views/proctolog/pull_requests/_try.html.erb +122 -0
- data/app/views/proctolog/pull_requests/show.html.erb +145 -0
- data/app/views/proctolog/recordings/_dialog.html.erb +26 -0
- data/app/views/proctolog/recordings/_list.html.erb +8 -0
- data/app/views/proctolog/recordings/_note.html.erb +22 -0
- data/app/views/proctolog/recordings/index.html.erb +33 -0
- data/app/views/proctolog/recordings/show.html.erb +118 -0
- data/app/views/proctolog/scenarios/_dialog.html.erb +22 -0
- data/app/views/proctolog/scenarios/_note.html.erb +18 -0
- data/app/views/proctolog/scenarios/_open.html.erb +9 -0
- data/app/views/proctolog/scenarios/_scenario.html.erb +48 -0
- data/app/views/proctolog/scenarios/_server.html.erb +20 -0
- data/app/views/proctolog/scenarios/index.html.erb +62 -0
- data/bin/proctolog +330 -0
- data/completions/_proctolog +143 -0
- data/config/application.rb +94 -0
- data/config/database.yml +16 -0
- data/config/environment.rb +6 -0
- data/config/initializers/grouse.rb +113 -0
- data/config/initializers/grouse_notes.rb +9 -0
- data/config/keys.yml +6 -0
- data/config/routes.rb +71 -0
- data/config/storage.yml +4 -0
- data/db/migrate/20261019213542_create_grouse_tables.grouse.rb +128 -0
- data/db/migrate/20261019213550_create_active_storage_tables.active_storage.rb +58 -0
- data/docs/manual/app.md +157 -0
- data/docs/manual/bursts.md +129 -0
- data/docs/manual/deciding.md +89 -0
- data/docs/manual/desk.png +0 -0
- data/docs/manual/films.md +20 -0
- data/docs/manual/handover.md +93 -0
- data/docs/manual/pr-format.md +75 -0
- data/docs/manual/reading.md +233 -0
- data/docs/manual/unopened.md +80 -0
- data/docs/manual/updates.md +55 -0
- data/lib/proctolog/installation.rb +125 -0
- data/lib/proctolog/version.rb +7 -0
- data/public/asking.js +50 -0
- data/public/code-review.js +40 -0
- data/public/cursor.js +292 -0
- data/public/decision-screenshots.js +97 -0
- data/public/decisions.css +949 -0
- data/public/decisions.js +1289 -0
- data/public/desk.css +582 -0
- data/public/desk.js +126 -0
- data/public/favicon.png +0 -0
- data/public/keys.js +228 -0
- data/public/mentions.js +389 -0
- data/public/messages.js +103 -0
- data/public/notes.js +731 -0
- data/public/pick.css +99 -0
- data/public/plaque.png +0 -0
- data/public/pr-number.js +11 -0
- data/public/prompt.css +46 -0
- data/public/prompt.js +16 -0
- data/public/pull_request.css +805 -0
- data/public/recording.css +379 -0
- data/public/recording.js +394 -0
- data/public/release.js +25 -0
- data/public/respond.css +260 -0
- data/public/respond.js +95 -0
- data/public/review-drafts.js +949 -0
- data/public/review-rounds.js +1447 -0
- data/public/scenarios.css +253 -0
- data/public/scenarios.js +36 -0
- data/public/schemas/proctolog-response-v1.json +101 -0
- data/public/side.js +133 -0
- data/public/standing.css +478 -0
- data/public/terminal-apl.svg +6 -0
- data/public/terminal-cuneiform.svg +3 -0
- data/public/terminal-grain-dots.svg +6 -0
- data/public/terminal-grain-weave.svg +6 -0
- data/public/terminal-grain-wedges.svg +6 -0
- data/public/terminal-shades.svg +3 -0
- data/public/terminal.css +4426 -0
- data/public/toast.js +26 -0
- data/public/try.js +124 -0
- data/public/viewer.js +808 -0
- data/public/words.js +81 -0
- data/skills/proctolog-handover/SKILL.md +77 -0
- data/skills/proctolog-pr-writing/SKILL.md +228 -0
- data/skills/proctolog-pr-writing/references/example-description.md +33 -0
- metadata +366 -0
data/public/keys.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// What every page with keys shares: ? opens the list of them all, and the bar
|
|
2
|
+
// at the foot of the page says the few that matter where the owner is now. A
|
|
3
|
+
// page's script tells the bar by calling `mode`; a page without one keeps the
|
|
4
|
+
// line the server wrote.
|
|
5
|
+
|
|
6
|
+
import { toggleMessages } from "./messages.js";
|
|
7
|
+
import { prNumber } from "./pr-number.js";
|
|
8
|
+
import { toast } from "./toast.js";
|
|
9
|
+
|
|
10
|
+
const help = document.querySelector("#keys-help");
|
|
11
|
+
const line = document.querySelector("[data-mode]");
|
|
12
|
+
|
|
13
|
+
// What sits above the foot, a toast or the messages, stands clear of it at
|
|
14
|
+
// whatever height it takes: its keys may wrap to two lines, or the list of
|
|
15
|
+
// them all be docked.
|
|
16
|
+
const foot = document.querySelector(".foot");
|
|
17
|
+
if (foot)
|
|
18
|
+
new ResizeObserver(() =>
|
|
19
|
+
document.body.style.setProperty("--foot-height", `${foot.offsetHeight}px`),
|
|
20
|
+
).observe(foot);
|
|
21
|
+
|
|
22
|
+
// The pane at the left opens on the pull request the page is about.
|
|
23
|
+
const current = document.querySelector(".side .stops [aria-current]");
|
|
24
|
+
if (current) {
|
|
25
|
+
const pane = current.closest(".side");
|
|
26
|
+
// Scroll the route pane alone; on a narrow page it sits above the reading.
|
|
27
|
+
if (getComputedStyle(pane).position === "sticky")
|
|
28
|
+
pane.scrollTop +=
|
|
29
|
+
current.getBoundingClientRect().top - pane.getBoundingClientRect().top -
|
|
30
|
+
(pane.clientHeight - current.offsetHeight) / 2;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The desk keeps what it read of GitHub until it is asked to read again. Once
|
|
34
|
+
// a page is shown, it asks whether GitHub has moved on since, and says which
|
|
35
|
+
// pull requests did, in the pane and on their rows. The answer is good for a
|
|
36
|
+
// few minutes in this tab, so going from page to page asks nothing.
|
|
37
|
+
const GOOD_FOR = 3 * 60 * 1000;
|
|
38
|
+
const read = document.querySelector("[data-freshness]");
|
|
39
|
+
|
|
40
|
+
const sayMoved = ({ changed = [], closed = [] }) => {
|
|
41
|
+
const line = read.querySelector("[data-moved]");
|
|
42
|
+
const numbers = (list) => list.map((number) => `#${number}`).join(", ");
|
|
43
|
+
line.textContent = [
|
|
44
|
+
changed.length ? `Changed on GitHub since: ${numbers(changed)}` : "",
|
|
45
|
+
closed.length ? `No longer open: ${numbers(closed)}` : "",
|
|
46
|
+
]
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join(". ");
|
|
49
|
+
line.hidden = !line.textContent;
|
|
50
|
+
// A row says which of the two, for the words side.js shows over it.
|
|
51
|
+
for (const row of document.querySelectorAll("[data-side-pr]")) {
|
|
52
|
+
const number = prNumber(row.dataset.sidePr);
|
|
53
|
+
if (closed.includes(number)) row.dataset.moved = "closed";
|
|
54
|
+
else if (changed.includes(number)) row.dataset.moved = "changed";
|
|
55
|
+
else delete row.dataset.moved;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const askWhetherMoved = async () => {
|
|
60
|
+
const key = `proctolog:moved:${read.dataset.freshness}:${read.dataset.readAt}`;
|
|
61
|
+
try {
|
|
62
|
+
const kept = JSON.parse(sessionStorage.getItem(key) ?? "null");
|
|
63
|
+
if (kept && kept.asked > Date.now() - GOOD_FOR) return sayMoved(kept);
|
|
64
|
+
} catch {
|
|
65
|
+
// A tab that keeps nothing asks on every page.
|
|
66
|
+
}
|
|
67
|
+
const response = await fetch(read.dataset.freshness, {
|
|
68
|
+
headers: { Accept: "application/json" },
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) return;
|
|
71
|
+
const answer = { ...(await response.json()), asked: Date.now() };
|
|
72
|
+
try {
|
|
73
|
+
sessionStorage.setItem(key, JSON.stringify(answer));
|
|
74
|
+
} catch {
|
|
75
|
+
// Said on this page all the same.
|
|
76
|
+
}
|
|
77
|
+
sayMoved(answer);
|
|
78
|
+
};
|
|
79
|
+
if (read) setTimeout(() => askWhetherMoved().catch(() => {}), 400);
|
|
80
|
+
|
|
81
|
+
// The key a press means, whatever the keyboard is set to. A letter is read by
|
|
82
|
+
// where it sits when the layout types another script (Hebrew, Greek, Cyrillic),
|
|
83
|
+
// and its case by Shift alone, so Caps Lock does not turn j into J.
|
|
84
|
+
export const keyOf = (event) => {
|
|
85
|
+
const { key, code = "" } = event;
|
|
86
|
+
const letter = /^[a-z]$/i.test(key)
|
|
87
|
+
? key
|
|
88
|
+
: key.length === 1 && key > "\u007f" && /^Key[A-Z]$/.test(code)
|
|
89
|
+
? code.slice(3)
|
|
90
|
+
: undefined;
|
|
91
|
+
if (!letter) return key;
|
|
92
|
+
return event.shiftKey ? letter.toUpperCase() : letter.toLowerCase();
|
|
93
|
+
};
|
|
94
|
+
// The route's own script is not a module: it asks for the key, and says what
|
|
95
|
+
// a key did, by these names.
|
|
96
|
+
globalThis.proctologKey = keyOf;
|
|
97
|
+
globalThis.proctologSay = toast;
|
|
98
|
+
|
|
99
|
+
const typing = (target) =>
|
|
100
|
+
target.closest?.("input, textarea, select, [contenteditable]");
|
|
101
|
+
|
|
102
|
+
// Grouse's overlay keeps its keys. A key typed in a note reaches the page as
|
|
103
|
+
// pressed on <grouse-widget> itself, which no guard on the desk reads as
|
|
104
|
+
// typing, so it goes no further than the overlay. The overlay's own shortcut
|
|
105
|
+
// listens before this, as the key comes down.
|
|
106
|
+
document
|
|
107
|
+
.querySelector("grouse-widget")
|
|
108
|
+
?.addEventListener("keydown", (event) => event.stopPropagation());
|
|
109
|
+
|
|
110
|
+
// ? docks the list of every key above the bar, and puts it away again. It is
|
|
111
|
+
// no dialog: the page's keys go on answering under it, and the desk remembers
|
|
112
|
+
// from page to page that the owner keeps it open.
|
|
113
|
+
const KEPT_OPEN = "proctolog:keys-open";
|
|
114
|
+
const toggle = document.querySelector("[data-show-keys]");
|
|
115
|
+
const dock = (open) => {
|
|
116
|
+
help.hidden = !open;
|
|
117
|
+
document.body.toggleAttribute("data-keys-open", open);
|
|
118
|
+
// The page leaves the docked list its room, so its last lines can still be read.
|
|
119
|
+
document.body.style.setProperty("--keys-height", `${help.offsetHeight}px`);
|
|
120
|
+
toggle.setAttribute("aria-expanded", open);
|
|
121
|
+
toggle.querySelector("[data-keys-word]").textContent = open
|
|
122
|
+
? "put the keys away"
|
|
123
|
+
: "keep the keys open";
|
|
124
|
+
try {
|
|
125
|
+
localStorage.setItem(KEPT_OPEN, open ? "1" : "");
|
|
126
|
+
} catch {
|
|
127
|
+
// A browser that keeps nothing opens the list on each page it is asked on.
|
|
128
|
+
}
|
|
129
|
+
document.dispatchEvent(new CustomEvent("keys-docked"));
|
|
130
|
+
};
|
|
131
|
+
const keptOpen = () => {
|
|
132
|
+
try {
|
|
133
|
+
return localStorage.getItem(KEPT_OPEN) === "1";
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
if (help && keptOpen()) dock(true);
|
|
139
|
+
|
|
140
|
+
document.addEventListener("keydown", (event) => {
|
|
141
|
+
if (!help || event.isComposing || event.metaKey || event.ctrlKey) return;
|
|
142
|
+
if (event.altKey || event.key !== "?" || typing(event.target)) return;
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
dock(help.hidden);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// m lists what the desk said lately, on every page, and puts the list away.
|
|
148
|
+
document.addEventListener("keydown", (event) => {
|
|
149
|
+
if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey)
|
|
150
|
+
return;
|
|
151
|
+
if (keyOf(event) !== "m" || typing(event.target)) return;
|
|
152
|
+
if (document.querySelector("dialog[open]")) return;
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
toggleMessages();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// P brings the whole burst back from a part, from every page: the pane says
|
|
158
|
+
// which part is in focus and holds the way back.
|
|
159
|
+
document.addEventListener("keydown", (event) => {
|
|
160
|
+
if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey)
|
|
161
|
+
return;
|
|
162
|
+
if (keyOf(event) !== "P" || typing(event.target)) return;
|
|
163
|
+
if (document.querySelector("dialog[open]")) return;
|
|
164
|
+
const back = document.querySelector('.side a[data-key="P"]');
|
|
165
|
+
if (!back) return;
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
location.assign(back.href);
|
|
168
|
+
});
|
|
169
|
+
toggle?.addEventListener("click", () => dock(help.hidden));
|
|
170
|
+
// A press of the pointer there leaves the owner where they are on the page.
|
|
171
|
+
toggle?.addEventListener("mousedown", (event) => event.preventDefault());
|
|
172
|
+
|
|
173
|
+
// The keys of the block the owner is on lead the docked list. A page with a
|
|
174
|
+
// cursor of its own says them here; `hints` are pairs like the bar's.
|
|
175
|
+
export const thisBlock = (hints = []) => {
|
|
176
|
+
const group = help?.querySelector("[data-this-block]");
|
|
177
|
+
if (!group) return;
|
|
178
|
+
group.hidden = !hints.length;
|
|
179
|
+
group.querySelector("dl").replaceChildren(
|
|
180
|
+
...hints.map(([keys, what]) => {
|
|
181
|
+
const row = document.createElement("div");
|
|
182
|
+
const pressed = document.createElement("dt");
|
|
183
|
+
pressed.append(
|
|
184
|
+
...keys
|
|
185
|
+
.split(" ")
|
|
186
|
+
.map((key) =>
|
|
187
|
+
Object.assign(document.createElement("kbd"), { textContent: key }),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
row.append(
|
|
191
|
+
pressed,
|
|
192
|
+
Object.assign(document.createElement("dd"), { textContent: what }),
|
|
193
|
+
);
|
|
194
|
+
return row;
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Whether a panel has opened over the page: a page's own keys rest while one
|
|
200
|
+
// has. The docked list of keys is not one.
|
|
201
|
+
export const helping = () =>
|
|
202
|
+
!!document.querySelector("dialog[open], :popover-open");
|
|
203
|
+
|
|
204
|
+
// `hints` are pairs of keys and what they do: [["j k", "move"], ["a", "leave as is"]].
|
|
205
|
+
let shown;
|
|
206
|
+
export const mode = (name, hints = []) => {
|
|
207
|
+
const said = JSON.stringify([name, hints]);
|
|
208
|
+
if (!line || said === shown) return;
|
|
209
|
+
shown = said;
|
|
210
|
+
const label = Object.assign(document.createElement("strong"), {
|
|
211
|
+
textContent: name,
|
|
212
|
+
});
|
|
213
|
+
line.replaceChildren(
|
|
214
|
+
label,
|
|
215
|
+
...hints.map(([keys, what]) => {
|
|
216
|
+
const hint = document.createElement("span");
|
|
217
|
+
hint.append(
|
|
218
|
+
...keys
|
|
219
|
+
.split(" ")
|
|
220
|
+
.map((key) =>
|
|
221
|
+
Object.assign(document.createElement("kbd"), { textContent: key }),
|
|
222
|
+
),
|
|
223
|
+
` ${what}`,
|
|
224
|
+
);
|
|
225
|
+
return hint;
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
};
|
data/public/mentions.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// A # and a number, typed into any box of the desk, may be a pull request:
|
|
2
|
+
// the desk lists those it may be just under the #, from every pull request
|
|
3
|
+
// of the repository, open or not. ↑ and ↓ choose, Tab or Enter puts the one
|
|
4
|
+
// chosen in place of what was typed. The owner may not mean a pull request
|
|
5
|
+
// at all: Esc puts the list away and leaves the # as typed, and so does
|
|
6
|
+
// carrying on past it, or leaving it behind. A key the list answers is the
|
|
7
|
+
// list's alone: the page under it never hears that Esc, so no form closes
|
|
8
|
+
// and no screen is left with it.
|
|
9
|
+
|
|
10
|
+
import { keyOf } from "./keys.js";
|
|
11
|
+
|
|
12
|
+
// A box that takes a pattern of its own (a slug) takes no #.
|
|
13
|
+
const takes = (field) =>
|
|
14
|
+
field?.matches?.("textarea, input[type=text], input:not([type])") &&
|
|
15
|
+
!field.pattern &&
|
|
16
|
+
!field.readOnly;
|
|
17
|
+
|
|
18
|
+
// The # the caret is in, in a box's text: where it starts and ends, and its
|
|
19
|
+
// digits. One that does not begin a word is none (a word#12, an ), nor
|
|
20
|
+
// is one whose digits run on into a word.
|
|
21
|
+
const mentionAt = (field) => {
|
|
22
|
+
const { value, selectionStart: caret, selectionEnd } = field;
|
|
23
|
+
if (caret !== selectionEnd) return;
|
|
24
|
+
const begun = value.slice(0, caret).match(/(?<![\w&#])#(\d{1,9})$/);
|
|
25
|
+
const rest = value.slice(caret).match(/^\d*(?!\w)/);
|
|
26
|
+
if (!begun || !rest) return;
|
|
27
|
+
return {
|
|
28
|
+
start: caret - begun[0].length,
|
|
29
|
+
end: caret + rest[0].length,
|
|
30
|
+
digits: begun[1] + rest[0],
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Where a character of a box's text stands on the screen: the text up to it
|
|
35
|
+
// set again, unseen, in the box's own type and width.
|
|
36
|
+
const SET_AS_IN_THE_BOX = [
|
|
37
|
+
"boxSizing",
|
|
38
|
+
"width",
|
|
39
|
+
"borderTopWidth",
|
|
40
|
+
"borderRightWidth",
|
|
41
|
+
"borderBottomWidth",
|
|
42
|
+
"borderLeftWidth",
|
|
43
|
+
"paddingTop",
|
|
44
|
+
"paddingRight",
|
|
45
|
+
"paddingBottom",
|
|
46
|
+
"paddingLeft",
|
|
47
|
+
"fontFamily",
|
|
48
|
+
"fontSize",
|
|
49
|
+
"fontWeight",
|
|
50
|
+
"letterSpacing",
|
|
51
|
+
"lineHeight",
|
|
52
|
+
"textIndent",
|
|
53
|
+
"textTransform",
|
|
54
|
+
"wordSpacing",
|
|
55
|
+
"tabSize",
|
|
56
|
+
"overflowWrap",
|
|
57
|
+
"wordBreak",
|
|
58
|
+
];
|
|
59
|
+
const placeOf = (field, index) => {
|
|
60
|
+
const style = getComputedStyle(field);
|
|
61
|
+
const copy = document.createElement("div");
|
|
62
|
+
for (const name of SET_AS_IN_THE_BOX) copy.style[name] = style[name];
|
|
63
|
+
Object.assign(copy.style, {
|
|
64
|
+
position: "fixed",
|
|
65
|
+
top: "0",
|
|
66
|
+
left: "0",
|
|
67
|
+
visibility: "hidden",
|
|
68
|
+
whiteSpace: field.matches("textarea") ? "pre-wrap" : "pre",
|
|
69
|
+
});
|
|
70
|
+
copy.textContent = field.value.slice(0, index);
|
|
71
|
+
const mark = copy.appendChild(document.createElement("span"));
|
|
72
|
+
mark.textContent = "#";
|
|
73
|
+
document.body.append(copy);
|
|
74
|
+
const box = field.getBoundingClientRect();
|
|
75
|
+
const place = {
|
|
76
|
+
left: box.left + mark.offsetLeft - field.scrollLeft,
|
|
77
|
+
top: box.top + mark.offsetTop - field.scrollTop,
|
|
78
|
+
bottom: box.top + mark.offsetTop + mark.offsetHeight - field.scrollTop,
|
|
79
|
+
};
|
|
80
|
+
copy.remove();
|
|
81
|
+
return place;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const made = (tag, properties = {}) =>
|
|
85
|
+
Object.assign(document.createElement(tag), properties);
|
|
86
|
+
|
|
87
|
+
// The list, made once and moved to whichever box asks: into the box's own
|
|
88
|
+
// dialog when it has one, as the rest of the page is out of reach while a
|
|
89
|
+
// dialog is open.
|
|
90
|
+
const panel = made("div", { className: "mentions", hidden: true });
|
|
91
|
+
panel.dataset.desk = "";
|
|
92
|
+
const options = made("ul", { id: "mentions", role: "listbox" });
|
|
93
|
+
options.setAttribute("aria-label", "Pull requests this # may mean");
|
|
94
|
+
const looking = made("p", { className: "looking" });
|
|
95
|
+
const hint = made("p", { className: "hint" });
|
|
96
|
+
hint.append(
|
|
97
|
+
made("kbd", { textContent: "↑" }),
|
|
98
|
+
made("kbd", { textContent: "↓" }),
|
|
99
|
+
" choose ",
|
|
100
|
+
made("kbd", { textContent: "Tab" }),
|
|
101
|
+
" put it in ",
|
|
102
|
+
made("kbd", { textContent: "Esc" }),
|
|
103
|
+
" leave the # as it is",
|
|
104
|
+
);
|
|
105
|
+
panel.append(looking, options, hint);
|
|
106
|
+
// A press of the pointer on the list leaves the caret in the box.
|
|
107
|
+
panel.addEventListener("mousedown", (event) => event.preventDefault());
|
|
108
|
+
|
|
109
|
+
// The # the list is open for, and those it may mean. `picked` is the one
|
|
110
|
+
// the owner chose themselves, by key or pointer. `settled` is a # the owner
|
|
111
|
+
// put away or filled in: it is not offered again while it stands.
|
|
112
|
+
let here;
|
|
113
|
+
let found = [];
|
|
114
|
+
let chosen = 0;
|
|
115
|
+
let picked;
|
|
116
|
+
let settled;
|
|
117
|
+
|
|
118
|
+
const shown = () => !panel.hidden;
|
|
119
|
+
|
|
120
|
+
const close = () => {
|
|
121
|
+
panel.hidden = true;
|
|
122
|
+
here?.field.setAttribute("aria-expanded", "false");
|
|
123
|
+
here?.field.removeAttribute("aria-activedescendant");
|
|
124
|
+
here = undefined;
|
|
125
|
+
found = [];
|
|
126
|
+
picked = undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const leave = () => {
|
|
130
|
+
if (here) settled = { field: here.field, start: here.start };
|
|
131
|
+
close();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const place = () => {
|
|
135
|
+
if (!here) return;
|
|
136
|
+
const at = placeOf(here.field, here.start);
|
|
137
|
+
const below = at.bottom + 2;
|
|
138
|
+
const room = innerHeight - below;
|
|
139
|
+
const { offsetHeight: height, offsetWidth: width } = panel;
|
|
140
|
+
panel.style.top = `${room < height && at.top > height ? at.top - height - 2 : below}px`;
|
|
141
|
+
panel.style.left = `${Math.max(0, Math.min(at.left, innerWidth - width - 8))}px`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const choose = (index) => {
|
|
145
|
+
chosen = (index + found.length) % found.length;
|
|
146
|
+
options.querySelectorAll("[role=option]").forEach((option, at) => {
|
|
147
|
+
option.setAttribute("aria-selected", at === chosen);
|
|
148
|
+
if (at === chosen) {
|
|
149
|
+
here.field.setAttribute("aria-activedescendant", option.id);
|
|
150
|
+
option.scrollIntoView({ block: "nearest" });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const pick = (index) => {
|
|
156
|
+
choose(index);
|
|
157
|
+
picked = found[chosen];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const row = (mention, index) => {
|
|
161
|
+
const option = made("li", { id: `mention-${mention.number}` });
|
|
162
|
+
option.setAttribute("role", "option");
|
|
163
|
+
option.append(
|
|
164
|
+
made("span", { className: "number", textContent: `#${mention.number}` }),
|
|
165
|
+
made("span", { className: "title", textContent: mention.title }),
|
|
166
|
+
made("span", { className: "state", textContent: mention.state }),
|
|
167
|
+
);
|
|
168
|
+
option.addEventListener("mousemove", () => {
|
|
169
|
+
if (chosen !== index) pick(index);
|
|
170
|
+
});
|
|
171
|
+
option.addEventListener("click", () => {
|
|
172
|
+
pick(index);
|
|
173
|
+
put();
|
|
174
|
+
});
|
|
175
|
+
return option;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const show = () => {
|
|
179
|
+
const { field } = here;
|
|
180
|
+
const home = field.closest("dialog") ?? document.body;
|
|
181
|
+
if (panel.parentElement !== home) home.append(panel);
|
|
182
|
+
panel.hidden = false;
|
|
183
|
+
field.setAttribute("aria-autocomplete", "list");
|
|
184
|
+
field.setAttribute("aria-controls", options.id);
|
|
185
|
+
field.setAttribute("aria-expanded", "true");
|
|
186
|
+
place();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// The desk's answer may come after the owner has picked from a list
|
|
190
|
+
// narrowed by their typing: what they picked stays chosen while it is there.
|
|
191
|
+
const list = (mentions) => {
|
|
192
|
+
found = mentions;
|
|
193
|
+
looking.hidden = true;
|
|
194
|
+
options.replaceChildren(...mentions.map(row));
|
|
195
|
+
options.hidden = false;
|
|
196
|
+
hint.hidden = false;
|
|
197
|
+
show();
|
|
198
|
+
const kept = mentions.findIndex(({ number }) => number === picked?.number);
|
|
199
|
+
choose(Math.max(0, kept));
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// What the desk answered for each run of digits, for as long as the page is
|
|
203
|
+
// open. A question `signal` calls off is never answered.
|
|
204
|
+
const answers = new Map();
|
|
205
|
+
const lookUp = async (digits, signal) => {
|
|
206
|
+
if (answers.has(digits)) return answers.get(digits);
|
|
207
|
+
const response = await fetch(
|
|
208
|
+
`/mentions?number=${encodeURIComponent(digits)}`,
|
|
209
|
+
{ headers: { Accept: "application/json" }, signal },
|
|
210
|
+
);
|
|
211
|
+
if (!response.ok) return [];
|
|
212
|
+
const mentions = await response.json();
|
|
213
|
+
answers.set(digits, mentions);
|
|
214
|
+
return mentions;
|
|
215
|
+
};
|
|
216
|
+
let asking;
|
|
217
|
+
const lookUpTyped = (digits) => {
|
|
218
|
+
asking?.abort();
|
|
219
|
+
asking = new AbortController();
|
|
220
|
+
return lookUp(digits, asking.signal);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Words a page's script sets itself, a saved answer, shown as the server
|
|
224
|
+
// shows the owner's words: each # that names a pull request goes to it, its
|
|
225
|
+
// title beside; one of the burst's own goes to its page on the desk.
|
|
226
|
+
const WRITTEN = /((?<![\w&#])#\d+(?!\w))/;
|
|
227
|
+
const mentionOf = async (written) => {
|
|
228
|
+
const digits = written.slice(1);
|
|
229
|
+
const answer = await lookUp(digits).catch(() => []);
|
|
230
|
+
const mention = answer.find(({ number }) => `${number}` === digits);
|
|
231
|
+
if (!mention) return written;
|
|
232
|
+
const shown = made("span", { className: "mention" });
|
|
233
|
+
shown.dataset.state = mention.state;
|
|
234
|
+
const onDesk = document.querySelector(`[data-side-pr="${digits}"] a[href]`);
|
|
235
|
+
shown.append(
|
|
236
|
+
made("a", { href: onDesk?.href ?? mention.url, textContent: written }),
|
|
237
|
+
" ",
|
|
238
|
+
made("span", { className: "mention-title", textContent: mention.title }),
|
|
239
|
+
);
|
|
240
|
+
if (mention.state !== "open")
|
|
241
|
+
shown.append(
|
|
242
|
+
" ",
|
|
243
|
+
made("span", {
|
|
244
|
+
className: "mention-state",
|
|
245
|
+
textContent: `· ${mention.state}`,
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
return shown;
|
|
249
|
+
};
|
|
250
|
+
export const withMentions = async (element) => {
|
|
251
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
252
|
+
const texts = [];
|
|
253
|
+
while (walker.nextNode())
|
|
254
|
+
if (!walker.currentNode.parentElement.closest(".mention"))
|
|
255
|
+
texts.push(walker.currentNode);
|
|
256
|
+
for (const text of texts) {
|
|
257
|
+
const pieces = text.data.split(WRITTEN);
|
|
258
|
+
if (pieces.length === 1) continue;
|
|
259
|
+
const shown = await Promise.all(
|
|
260
|
+
pieces.map((piece) => (WRITTEN.test(piece) ? mentionOf(piece) : piece)),
|
|
261
|
+
);
|
|
262
|
+
if (text.isConnected) text.replaceWith(...shown);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const lookingFor = (digits) => {
|
|
267
|
+
found = [];
|
|
268
|
+
looking.textContent = `Looking for #${digits} among the pull requests…`;
|
|
269
|
+
looking.hidden = false;
|
|
270
|
+
options.hidden = true;
|
|
271
|
+
hint.hidden = true;
|
|
272
|
+
show();
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Another digit narrows the list at once, to those that still begin so, and
|
|
276
|
+
// the desk's answer follows. The first # of a desk reads every pull request
|
|
277
|
+
// GitHub has, which takes a few seconds: the list says so while it waits,
|
|
278
|
+
// if the wait is long enough to be seen.
|
|
279
|
+
const offer = async (field, at) => {
|
|
280
|
+
here = { field, ...at };
|
|
281
|
+
const narrowed = found.filter(({ number }) =>
|
|
282
|
+
`${number}`.startsWith(at.digits),
|
|
283
|
+
);
|
|
284
|
+
if (narrowed.length) list(narrowed);
|
|
285
|
+
else if (shown()) lookingFor(at.digits);
|
|
286
|
+
const waiting = setTimeout(() => {
|
|
287
|
+
if (here?.digits === at.digits && !found.length) lookingFor(at.digits);
|
|
288
|
+
}, 150);
|
|
289
|
+
const mentions = await lookUpTyped(at.digits).catch(() => undefined);
|
|
290
|
+
clearTimeout(waiting);
|
|
291
|
+
if (mentions === undefined) return;
|
|
292
|
+
if (here?.field !== field || here.digits !== at.digits) return;
|
|
293
|
+
if (mentions.length) list(mentions);
|
|
294
|
+
else close();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// The pull request chosen, in place of the # as typed. Typed in, as far as
|
|
298
|
+
// the box can tell, so that undo takes it back out. A space after it when
|
|
299
|
+
// it ends the text, so that the owner writes on.
|
|
300
|
+
const put = () => {
|
|
301
|
+
const mention = found[chosen];
|
|
302
|
+
if (!here || !mention) return false;
|
|
303
|
+
const { field, start, end } = here;
|
|
304
|
+
const text = `#${mention.number}${end === field.value.length ? " " : ""}`;
|
|
305
|
+
settled = { field, start };
|
|
306
|
+
close();
|
|
307
|
+
field.setSelectionRange(start, end);
|
|
308
|
+
if (!document.execCommand("insertText", false, text)) {
|
|
309
|
+
field.setRangeText(text, start, end, "end");
|
|
310
|
+
field.dispatchEvent(new Event("input", { bubbles: true }));
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
document.addEventListener("input", (event) => {
|
|
315
|
+
const field = event.target;
|
|
316
|
+
if (!takes(field) || event.isComposing) return;
|
|
317
|
+
const at = mentionAt(field);
|
|
318
|
+
if (!at) {
|
|
319
|
+
settled = undefined;
|
|
320
|
+
return close();
|
|
321
|
+
}
|
|
322
|
+
if (settled?.field === field && settled.start === at.start) return close();
|
|
323
|
+
settled = undefined;
|
|
324
|
+
offer(field, at);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// The caret leaving the #, or the box, puts the list away.
|
|
328
|
+
const stillHere = () => {
|
|
329
|
+
if (!here) return;
|
|
330
|
+
const at = mentionAt(here.field);
|
|
331
|
+
if (document.activeElement !== here.field || at?.start !== here.start)
|
|
332
|
+
close();
|
|
333
|
+
};
|
|
334
|
+
document.addEventListener("keyup", stillHere);
|
|
335
|
+
document.addEventListener("pointerup", stillHere);
|
|
336
|
+
document.addEventListener("focusout", (event) => {
|
|
337
|
+
if (event.target === here?.field) close();
|
|
338
|
+
});
|
|
339
|
+
addEventListener("resize", place);
|
|
340
|
+
document.addEventListener("scroll", place, true);
|
|
341
|
+
|
|
342
|
+
// The keys the list answers while it is shown. Enter and Tab put a pull
|
|
343
|
+
// request in only when there is one to put; otherwise they are the box's.
|
|
344
|
+
const KEYS = {
|
|
345
|
+
Escape: leave,
|
|
346
|
+
ArrowDown: () => pick(chosen + 1),
|
|
347
|
+
"Ctrl+n": () => pick(chosen + 1),
|
|
348
|
+
ArrowUp: () => pick(chosen - 1),
|
|
349
|
+
"Ctrl+p": () => pick(chosen - 1),
|
|
350
|
+
Enter: put,
|
|
351
|
+
Tab: put,
|
|
352
|
+
};
|
|
353
|
+
const pressed = (event) =>
|
|
354
|
+
[
|
|
355
|
+
event.ctrlKey && "Ctrl",
|
|
356
|
+
event.altKey && "Alt",
|
|
357
|
+
event.metaKey && "Meta",
|
|
358
|
+
event.shiftKey && "Shift",
|
|
359
|
+
keyOf(event),
|
|
360
|
+
]
|
|
361
|
+
.filter(Boolean)
|
|
362
|
+
.join("+");
|
|
363
|
+
|
|
364
|
+
// Heard first of anything on the page, in the window's capture, and kept
|
|
365
|
+
// from everything after. The key's release goes with it.
|
|
366
|
+
let answered;
|
|
367
|
+
addEventListener(
|
|
368
|
+
"keydown",
|
|
369
|
+
(event) => {
|
|
370
|
+
if (!shown() || event.isComposing || event.target !== here?.field) return;
|
|
371
|
+
const key = pressed(event);
|
|
372
|
+
const does = KEYS[key];
|
|
373
|
+
if (!does || (!found.length && key !== "Escape")) return;
|
|
374
|
+
if (does() === false) return;
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
event.stopImmediatePropagation();
|
|
377
|
+
answered = event.key;
|
|
378
|
+
},
|
|
379
|
+
true,
|
|
380
|
+
);
|
|
381
|
+
addEventListener(
|
|
382
|
+
"keyup",
|
|
383
|
+
(event) => {
|
|
384
|
+
if (event.key !== answered) return;
|
|
385
|
+
answered = undefined;
|
|
386
|
+
event.stopImmediatePropagation();
|
|
387
|
+
},
|
|
388
|
+
true,
|
|
389
|
+
);
|