samagotchi 0.56.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +1 -1
  4. data/docs/cli.md +1 -1
  5. data/docs/configuration.md +5 -3
  6. data/docs/hooks.md +2 -2
  7. data/docs/memory.md +1 -1
  8. data/docs/plugins.md +63 -0
  9. data/docs/sessions.md +2 -2
  10. data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
  11. data/lib/samagotchi/bootstrap/probe.rb +3 -0
  12. data/lib/samagotchi/bootstrap_command.rb +2 -0
  13. data/lib/samagotchi/broadcast_command.rb +2 -0
  14. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  15. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
  17. data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
  18. data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  21. data/lib/samagotchi/context_command.rb +2 -0
  22. data/lib/samagotchi/desktop/macos.rb +3 -0
  23. data/lib/samagotchi/engine.rb +2 -2
  24. data/lib/samagotchi/gem_update.rb +3 -0
  25. data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
  26. data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
  27. data/lib/samagotchi/host_registry.rb +15 -0
  28. data/lib/samagotchi/installed_versions.rb +3 -0
  29. data/lib/samagotchi/model_profile.rb +14 -3
  30. data/lib/samagotchi/plugin/context.rb +5 -0
  31. data/lib/samagotchi/redacted_inspect.rb +43 -0
  32. data/lib/samagotchi/session_commands.rb +5 -4
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -8
  34. data/lib/samagotchi/terminal_ui.rb +14 -5
  35. data/lib/samagotchi/tool_runner.rb +14 -3
  36. data/lib/samagotchi/tools/execute.rb +3 -2
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +14 -6
  39. data/lib/samagotchi/web/public/app.js +31 -14
  40. data/lib/samagotchi/web/public/index.html +32 -5
  41. data/lib/samagotchi/web/public/question_card.js +5 -3
  42. data/lib/samagotchi/web/public/question_cards.js +21 -1
  43. data/lib/samagotchi/web/public/read_view.js +74 -0
  44. data/lib/samagotchi/web/public/ref_menu.js +188 -0
  45. data/lib/samagotchi/web/public/ref_scan.js +216 -0
  46. data/lib/samagotchi/web/public/refs.js +283 -54
  47. data/lib/samagotchi/web/public/stage_view.js +21 -0
  48. data/lib/samagotchi/web/public/turn_html.js +4 -2
  49. data/lib/samagotchi/web/public/turn_view.js +5 -3
  50. data/lib/samagotchi/web/token.rb +3 -0
  51. data/lib/samagotchi/worker.rb +2 -2
  52. metadata +7 -1
@@ -0,0 +1,216 @@
1
+ // `path:line` refs in an answer's or a step's text (traceable refs P3).
2
+ // A name becomes a file ref (refs.js: span.ref with data-ref-kind="file")
3
+ // only when it names a file the session's rows touched (their refs): the
4
+ // answer's text is unchanged, it resolves on the page, and nothing the
5
+ // session didn't touch gets linked. The scan is github-pr's LineLinker
6
+ // (bundles/github-pr/plugin.rb) on the DOM: pre, links and fenced blocks
7
+ // are left alone, inline code only when it is the ref alone, a bare URL's
8
+ // text too.
9
+
10
+ import { refAttrs } from "./refs.js";
11
+
12
+ // A path (no spaces), a line, an optional end line and an optional column
13
+ // (kept in the text, not used). LineLinker::REF.
14
+ export const REF = /(?<![\w./@+~-])(?<path>[\w./@+~-]*[\w@+~-]):(?<from>\d+)(?:-(?<to>\d+))?(?::\d+)?(?![\w/])/;
15
+ export const REF_ALONE = new RegExp(`^${REF.source}$`);
16
+ // A ref, or a bare path (no `:line`): a bare one counts only when it is a
17
+ // touched file exactly (resolveRef's +bare+). Not after a `\` or a `:`
18
+ // either: the tail of `C:\x\foo.rb:3` or `host:lib/foo.rb:3` is another
19
+ // machine's file.
20
+ const REF_OR_PATH = /(?<![\w./@+~\\:-])(?<path>[\w./@+~-]*[\w@+~-])(?::(?<from>\d+)(?:-(?<to>\d+))?(?::\d+)?(?![\w/])|(?![\w/]|:\d))/g;
21
+ const PATH_ALONE = /^(?<path>[\w./@+~-]*[\w@+~-])$/;
22
+ const URL_SPAN = /[a-z][a-z0-9+.-]*:\/\/\S+/gi;
23
+ const MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/g;
24
+ // A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
25
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
26
+ // Elements whose text is never scanned: code blocks, links (a github-pr
27
+ // PR link stays the PR's), a ref already made.
28
+ const SKIP = "pre, a, [data-ref-kind]";
29
+
30
+ // The ref a match names: { path, line, endLine } or null. A path has a `/`
31
+ // or an extension (`10:30` is a time, `host:8080` a port); line 0 is never
32
+ // a ref; an end line not after the line is dropped.
33
+ export function refOf(groups) {
34
+ const path = groups?.path || "";
35
+ if (!path.includes("/") && !/\.[A-Za-z0-9]+$/.test(path)) return null;
36
+ if (groups.from === undefined) return { path, line: null, endLine: null };
37
+ const line = Number(groups.from);
38
+ if (!line) return null;
39
+ const to = groups.to === undefined ? null : Number(groups.to);
40
+ return { path, line, endLine: to && to > line ? to : null };
41
+ }
42
+
43
+ // The absolute touched file +path+ names, or null: the path itself when
44
+ // absolute, else the path under +cwd+ (`.` and `..` folded either way); else (not for a +bare+ path: no
45
+ // `:line`) the ONE touched file ending in `/<path>`. Ambiguous: none.
46
+ export function resolveRef(path, touched, cwd, { bare = false } = {}) {
47
+ const rel = String(path).replace(/^(?:\.\/)+/, "");
48
+ const exact = rel.startsWith("/") ? joinPath("", rel) : cwd ? joinPath(cwd, rel) : null;
49
+ if (exact && touched.has(exact)) return exact;
50
+ if (bare || rel.startsWith("/")) return null;
51
+ const tail = `/${rel.replace(/^(?:\.\.\/)+/, "")}`;
52
+ const found = [...touched].filter((file) => file.endsWith(tail));
53
+ return found.length === 1 ? found[0] : null;
54
+ }
55
+
56
+ // +rel+ under +dir+, `.` and `..` segments folded.
57
+ function joinPath(dir, rel) {
58
+ const out = [];
59
+ for (const part of `${dir}/${rel}`.split("/")) {
60
+ if (part === "" || part === ".") continue;
61
+ if (part === "..") out.pop();
62
+ else out.push(part);
63
+ }
64
+ return `/${out.join("/")}`;
65
+ }
66
+
67
+ // [start, end, ref] for each ref in a plain +text+ that names a touched
68
+ // file: fenced blocks, markdown links and URLs skipped, inline code only
69
+ // as the ref alone (the span with its backticks). +touched+: a Set of
70
+ // absolute paths; +cwd+: the session's working directory.
71
+ export function textRefs(text, touched, cwd) {
72
+ if (!touched?.size || !text) return [];
73
+ const fences = fencedBlocks(text);
74
+ const skipped = [...fences, ...spans(text, URL_SPAN), ...spans(text, MARKDOWN_LINK)];
75
+ const code = inlineCode(text, fences);
76
+ const found = [];
77
+ for (const [start, end, inner] of code) {
78
+ if (insideAny(skipped, start, end)) continue;
79
+ const ref = aloneRef(inner, touched, cwd);
80
+ if (ref) found.push([start, end, ref]);
81
+ }
82
+ for (const match of text.matchAll(REF_OR_PATH)) {
83
+ const start = match.index;
84
+ const end = start + match[0].length;
85
+ if (insideAny(skipped, start, end) || insideAny(code, start, end)) continue;
86
+ const ref = resolved(refOf(match.groups), touched, cwd);
87
+ if (ref) found.push([start, end, ref]);
88
+ }
89
+ return found.sort((a, b) => a[0] - b[0]);
90
+ }
91
+
92
+ // The ref an inline code's text is when it is a ref (or a touched path)
93
+ // alone, else null.
94
+ function aloneRef(text, touched, cwd) {
95
+ const inner = String(text).trim();
96
+ const match = inner.match(REF_ALONE) || inner.match(PATH_ALONE);
97
+ return match ? resolved(refOf(match.groups), touched, cwd) : null;
98
+ }
99
+
100
+ function resolved(ref, touched, cwd) {
101
+ if (!ref) return null;
102
+ const path = resolveRef(ref.path, touched, cwd, { bare: ref.line === null });
103
+ return path ? { kind: "file", path, line: ref.line, end_line: ref.endLine } : null;
104
+ }
105
+
106
+ // The absolute paths of the file refs under +roots+ (the session's rows,
107
+ // approval cards): what a text ref may name.
108
+ export function touchedFiles(roots) {
109
+ const touched = new Set();
110
+ for (const root of roots) {
111
+ for (const el of root.querySelectorAll('[data-ref-kind="file"][data-ref-path]')) {
112
+ touched.add(el.dataset.refPath);
113
+ }
114
+ }
115
+ return touched;
116
+ }
117
+
118
+ // Wraps the refs in +el+'s text in span.ref (refs.js clicks and
119
+ // hover them as it does a row title). Text inside pre, a link or a ref is
120
+ // left alone; an inline code element is wrapped whole when it is a ref
121
+ // alone. A link whose text is a ref alone (github-pr's PR diff link) is
122
+ // that ref itself: its href stays the plain click's, the ref's other
123
+ // targets join it in the menu (refs.js). Runs again over the same element
124
+ // without wrapping twice.
125
+ export function linkRefs(el, touched, cwd) {
126
+ if (!el || !touched?.size) return;
127
+ const doc = el.ownerDocument;
128
+ for (const link of [...el.querySelectorAll("a[href]:not([data-ref-kind])")]) {
129
+ if (link.parentElement?.closest("pre, a, [data-ref-kind]")) continue;
130
+ const ref = aloneRef(link.textContent, touched, cwd);
131
+ if (!ref) continue;
132
+ link.classList.add("ref");
133
+ for (const [name, value] of Object.entries(refSpan(doc, ref).dataset)) link.dataset[name] = value;
134
+ }
135
+ for (const code of [...el.querySelectorAll("code")]) {
136
+ if (code.closest(SKIP)) continue;
137
+ const ref = aloneRef(code.textContent, touched, cwd);
138
+ if (!ref) continue;
139
+ const span = refSpan(doc, ref);
140
+ code.replaceWith(span);
141
+ span.append(code);
142
+ }
143
+ const walker = doc.createTreeWalker(el, 4 /* NodeFilter.SHOW_TEXT */);
144
+ const nodes = [];
145
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
146
+ const skip = node.parentElement?.closest(`${SKIP}, code`);
147
+ if (!skip || !el.contains(skip)) nodes.push(node);
148
+ }
149
+ for (const node of nodes) wrapRefs(node, textRefs(node.data, touched, cwd));
150
+ }
151
+
152
+ function wrapRefs(node, hits) {
153
+ if (!hits.length) return;
154
+ const doc = node.ownerDocument;
155
+ const frag = doc.createDocumentFragment();
156
+ let pos = 0;
157
+ for (const [start, end, ref] of hits) {
158
+ if (start < pos) continue;
159
+ if (start > pos) frag.append(node.data.slice(pos, start));
160
+ const span = refSpan(doc, ref);
161
+ span.textContent = node.data.slice(start, end);
162
+ frag.append(span);
163
+ pos = end;
164
+ }
165
+ if (pos < node.data.length) frag.append(node.data.slice(pos));
166
+ node.replaceWith(frag);
167
+ }
168
+
169
+ function refSpan(doc, ref) {
170
+ const holder = doc.createElement("div");
171
+ holder.innerHTML = `<span class="ref"${refAttrs(ref)}></span>`;
172
+ return holder.firstChild;
173
+ }
174
+
175
+ // [start, end] of each fenced block (an unclosed one runs to the end).
176
+ function fencedBlocks(text) {
177
+ const blocks = [];
178
+ let open = null;
179
+ let offset = 0;
180
+ for (const line of text.split(/(?<=\n)/)) {
181
+ const marker = line.match(FENCE_OPEN)?.[1];
182
+ if (!open && marker) {
183
+ open = [offset, marker];
184
+ } else if (open && marker && marker[0] === open[1][0] && marker.length >= open[1].length && line.trim() === marker) {
185
+ blocks.push([open[0], offset + line.length]);
186
+ open = null;
187
+ }
188
+ offset += line.length;
189
+ }
190
+ if (open) blocks.push([open[0], text.length]);
191
+ return blocks;
192
+ }
193
+
194
+ // [start, end, inner] of each inline code span outside +fences+: a run of
195
+ // backticks to the next run of the same length.
196
+ function inlineCode(text, fences) {
197
+ const runs = spans(text, /`+/g).filter(([start, end]) => !insideAny(fences, start, end));
198
+ const found = [];
199
+ let index = 0;
200
+ while (index < runs.length) {
201
+ const [start, end] = runs[index];
202
+ const closing = runs.findIndex(([s, e], k) => k > index && e - s === end - start);
203
+ if (closing < 0) { index += 1; continue; }
204
+ found.push([start, runs[closing][1], text.slice(end, runs[closing][0])]);
205
+ index = closing + 1;
206
+ }
207
+ return found;
208
+ }
209
+
210
+ function spans(text, regex) {
211
+ return [...text.matchAll(regex)].map((m) => [m.index, m.index + m[0].length]);
212
+ }
213
+
214
+ function insideAny(list, start, end) {
215
+ return list.some(([s, e]) => start >= s && end <= e);
216
+ }
@@ -1,16 +1,25 @@
1
1
  // Refs: quiet links from what a turn touched to the thing itself. A ref is
2
2
  // a kind and a target: today a file tool's row title (the server's FileRef,
3
- // {kind: "file", path, line?, end_line?}) and that row's diff lines (the
4
- // same file at a line). The DOM carries data-ref-kind and the target's
5
- // attributes, never what a click does: that is an action, kept per kind in
6
- // REF_ACTIONS, and a click runs the first one available to this viewer. A
7
- // ref with none (a phone: no editor) stays plain text; the page marks the
8
- // kinds that have one on its body (refs-<kind>), which the hover styling
9
- // keys on.
3
+ // {kind: "file", path, line?, end_line?}), that row's diff lines and a
4
+ // read row's numbered lines (the same file at a line), and an edit/write
5
+ // approval card's paths and its dry run's diff lines, and a `path:line` in
6
+ // an answer or a step's text (ref_scan.js; a link whose text is one too).
7
+ // The DOM carries data-ref-kind and the target's attributes, never what a
8
+ // click does: those are the ref's targets, kept per kind in REF_ACTIONS.
9
+ // A plain click runs the first one available (the main target); a ref
10
+ // with more shows a ▾ on hover that opens a menu of all of them
11
+ // (ref_menu.js), and a tap opens it. A ref with a target is a tab stop
12
+ // (syncRefTabs): Enter is its click, ↓ its menu. A ref with none (a phone's row title:
13
+ // no editor, and the row is the ref's own) stays plain text; refs.js marks
14
+ // a hovered or focused ref with its main target (data-ref-main), which the
15
+ // hover styling keys on.
10
16
 
11
17
  import { escapeHtml } from "./format.js";
18
+ import { copyText } from "./copy.js";
19
+ import { createRefMenu } from "./ref_menu.js";
12
20
 
13
- // The data attributes a ref's element carries.
21
+ // The data attributes a ref's element carries (its tab stop comes from
22
+ // syncRefTabs, for the viewer).
14
23
  export function refAttrs(ref) {
15
24
  if (!ref?.kind) return "";
16
25
  const attr = (name, value) => (value ? ` data-ref-${name}="${escapeHtml(String(value))}"` : "");
@@ -26,47 +35,87 @@ export function editorUrl(template, path, line) {
26
35
  return template.replaceAll("{path}", encoded).replaceAll("{line}", String(line || 1));
27
36
  }
28
37
 
29
- // What a ref can do, per kind, in order: the first available one is a
30
- // click's. +viewer+ is what this page's viewer can do ({editor, editorUrl}:
31
- // the server's capabilities.editor and editor_url).
38
+ // A ref's targets, per kind, in order: the first available one is a plain
39
+ // click's (the main target), all of them are the menu's. Each says when
40
+ // it's available for +viewer+ (what this page's viewer can do: {editor,
41
+ // editorUrl}, the server's capabilities.editor and editor_url) and
42
+ // +target+ (refTarget's). A secondary one (copy) joins a ref that has
43
+ // another, never alone. A line (a diff or read line) has its click only.
32
44
  export const REF_ACTIONS = Object.freeze({
33
45
  file: Object.freeze([
46
+ // A link whose text is the ref (github-pr's PR diff): its href stays
47
+ // the plain click's, which the browser follows.
48
+ Object.freeze({
49
+ id: "link",
50
+ label: (target) => (/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
51
+ available: (_viewer, target) => !!target.href,
52
+ run: (target, _viewer, env) => env.openLink(target.href),
53
+ }),
34
54
  Object.freeze({
35
55
  id: "editor",
56
+ label: () => "Open in editor",
36
57
  available: (viewer) => viewer?.editor === true && !!viewer.editorUrl,
37
58
  url: (target, viewer) => editorUrl(viewer.editorUrl, target.path, target.line),
59
+ run(target, viewer, env) {
60
+ const url = this.url(target, viewer);
61
+ if (url) env.open(url);
62
+ },
38
63
  // The hover hint: it opens the file as it is now.
39
64
  hint: (target) => `Open the current file in your editor${target.line ? ` at line ${target.line}` : ""}`,
40
65
  }),
66
+ // The row that touched the file, in the turn: not for a row's own
67
+ // title or lines (they are in the turn already).
68
+ Object.freeze({
69
+ id: "show",
70
+ label: () => "Show in turn",
71
+ available: (_viewer, target) => !target.isLine && !target.owner?.matches?.(".activity-row") && !!turnRowFor(target),
72
+ run: (target, _viewer, env) => env.show(target),
73
+ hint: () => "Show the row that touched it in the turn",
74
+ }),
75
+ Object.freeze({
76
+ id: "copy",
77
+ label: () => "Copy path",
78
+ secondary: true,
79
+ available: (_viewer, target) => !target.isLine && !!target.path,
80
+ run: (target, _viewer, env) => env.copy(target.path),
81
+ }),
41
82
  ]),
42
83
  });
43
84
 
44
- // The first action +viewer+ has for a ref of +kind+, else null.
45
- export function refAction(kind, viewer, actions = REF_ACTIONS) {
46
- return (actions[kind] || []).find((action) => action.available(viewer)) || null;
85
+ // The targets +viewer+ has for +target+ (refTarget's), in order; none
86
+ // when only a secondary one is.
87
+ export function refActions(target, viewer, actions = REF_ACTIONS) {
88
+ if (!target) return [];
89
+ const found = (actions[target.kind] || []).filter((action) => action.available(viewer, target));
90
+ return found.some((action) => !action.secondary) ? found : [];
47
91
  }
48
92
 
49
- // The kinds that have an action for +viewer+ (the page's refs-<kind> classes).
50
- export function availableKinds(viewer, actions = REF_ACTIONS) {
51
- return Object.keys(actions).filter((kind) => refAction(kind, viewer, actions));
52
- }
93
+ // What owns a ref and its diff lines: a tool row, or an edit's approval
94
+ // card (its paths and its dry run).
95
+ const OWNERS = ".activity-row, .bubble.approval";
96
+
97
+ // A line of the owner's file: a diff line, or a read row's numbered line.
98
+ const LINES = ".diff-line[data-line], .read-line[data-line]";
53
99
 
54
- // What a click on +el+ points at: {kind, path, line, owner} or null. A row
55
- // title opens its file at the lines the call named (an edit without any at
56
- // its first changed line); a diff line (or a hunk header) in a row with a
57
- // ref opens the row's file at that line. A diff line outside a row (an
58
- // approval card's dry run) is none.
100
+ // What a click on +el+ points at: {kind, path, line, owner, el, isLine,
101
+ // href} or null. A row title (or an approval card's path) opens its file
102
+ // at the lines the call named (an edit without any at its first changed
103
+ // line); a diff line (or a hunk header) or a read row's numbered line, in
104
+ // a row or a card with a ref, opens that file at that line. +el+ is the
105
+ // ref's element (the line for a line), +href+ a link ref's.
106
+ // A diff line elsewhere is none.
59
107
  export function refTarget(el) {
60
108
  const title = el?.closest?.("[data-ref-kind]");
61
109
  if (title) {
62
- const owner = title.closest(".activity-row");
63
- return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title };
110
+ const owner = title.closest(OWNERS);
111
+ const href = title.matches("a[href]") ? title.getAttribute("href") : null;
112
+ return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title, el: title, isLine: false, href };
64
113
  }
65
- const diffLine = el?.closest?.(".diff-line[data-line]");
66
- const owner = diffLine?.closest(".activity-row");
114
+ const diffLine = el?.closest?.(LINES);
115
+ const owner = diffLine?.closest(OWNERS);
67
116
  const ref = owner?.querySelector("[data-ref-kind]");
68
117
  if (!ref) return null;
69
- return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner };
118
+ return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner, el: diffLine, isLine: true, href: null };
70
119
  }
71
120
 
72
121
  function targetOf(el) {
@@ -80,6 +129,99 @@ function firstChange(row) {
80
129
  return line ? Number(line.dataset.line) : null;
81
130
  }
82
131
 
132
+ // The tool row that touched +target+'s file, for "show in turn", or null:
133
+ // a row with a ref to the same file that isn't the ref's own. Looked for
134
+ // in the ref's own step (a step's text comes before its rows), then its
135
+ // own turn (turnBlockOf), then the rows before it, then the nearest after
136
+ // it; in the first of those that has any, the last one showing the ref's
137
+ // line, else the last one (after it: the nearest).
138
+ export function turnRowFor(target) {
139
+ const el = target?.el;
140
+ if (!el?.ownerDocument || !target.path) return null;
141
+ const rows = [...el.ownerDocument.querySelectorAll(".activity-row")].filter((row) => {
142
+ if (row.contains(el)) return false;
143
+ const ref = row.querySelector("[data-ref-kind]");
144
+ return ref?.dataset.refKind === target.kind && ref.dataset.refPath === target.path;
145
+ });
146
+ const step = el.closest("details.gen");
147
+ const turn = turnBlockOf(el);
148
+ const before = rows.filter((row) => precedes(row, el));
149
+ const pools = [
150
+ step ? rows.filter((row) => step.contains(row)) : [],
151
+ turn ? rows.filter((row) => turn.contains(row)) : [],
152
+ before,
153
+ rows.filter((row) => !before.includes(row)).reverse(),
154
+ ];
155
+ const pool = pools.find((found) => found.length);
156
+ if (!pool) return null;
157
+ const showing = target.line ? pool.filter((row) => lineIn(row, target.line)) : [];
158
+ return showing.at(-1) || pool.at(-1);
159
+ }
160
+
161
+ // What holds +el+'s turn's rows: the steps block it is in, the stage (a
162
+ // live turn: its answer comes before its cloud), else the nearest steps
163
+ // block before it with no prompt between them (an answer, a card, in the
164
+ // history). null when there is none.
165
+ function turnBlockOf(el) {
166
+ const own = el.closest(".turn-work, .turn-stage");
167
+ if (own) return own;
168
+ const doc = el.ownerDocument;
169
+ const block = [...doc.querySelectorAll(".turn-work")].filter((b) => precedes(b, el)).at(-1);
170
+ if (!block) return null;
171
+ const prompt = [...doc.querySelectorAll(".bubble.user")].some((u) => precedes(block, u) && precedes(u, el));
172
+ return prompt ? null : block;
173
+ }
174
+
175
+ // +a+ comes before +b+ in the page (and doesn't hold it).
176
+ function precedes(a, b) {
177
+ return !a.contains(b) && !!(a.compareDocumentPosition(b) & 4 /* Node.DOCUMENT_POSITION_FOLLOWING */);
178
+ }
179
+
180
+ // +row+'s line +line+: an added or context diff line first (a removed one
181
+ // shares its number), then a read line; null when it doesn't show it.
182
+ function lineIn(row, line) {
183
+ for (const selector of [".diff-line.diff-add", ".diff-line.diff-ctx", ".read-line", ".diff-line"]) {
184
+ const found = row.querySelector(`${selector}[data-line="${Number(line)}"]`);
185
+ if (found) return found;
186
+ }
187
+ return null;
188
+ }
189
+
190
+ // "Show in turn": opens what hides the row that touched +target+'s file
191
+ // (the turn's steps, its step), its diff (or the read that shows the
192
+ // line), scrolls to the line (else the row) and flashes both. A view that
193
+ // hides its rows its own way (the stage's cloud) opens on the row's
194
+ // ref:reveal event, which bubbles. The row, or null when there is none.
195
+ export function showInTurn(target, { behavior = smoothScroll() } = {}) {
196
+ const row = turnRowFor(target);
197
+ if (!row) return null;
198
+ const view = row.ownerDocument.defaultView || globalThis;
199
+ row.dispatchEvent(new view.CustomEvent("ref:reveal", { bubbles: true }));
200
+ const line = target.line ? lineIn(row, target.line) : null;
201
+ const fold = line?.closest("details.activity-diff, details.activity-read") || row.querySelector("details.activity-diff");
202
+ for (let details = (line || fold || row).closest("details"); details; details = details.parentElement?.closest("details")) {
203
+ details.open = true;
204
+ }
205
+ if (fold) fold.open = true;
206
+ (line || row).scrollIntoView?.({ block: "center", behavior });
207
+ for (const el of [row, line].filter(Boolean)) flash(el);
208
+ return row;
209
+ }
210
+
211
+ const FLASH_MS = 1600;
212
+
213
+ function flash(el) {
214
+ el.classList.remove("ref-flash");
215
+ void el.offsetWidth; // restarts the animation on a second show
216
+ el.classList.add("ref-flash");
217
+ clearTimeout(el._refFlash);
218
+ el._refFlash = setTimeout(() => el.classList.remove("ref-flash"), FLASH_MS);
219
+ }
220
+
221
+ function smoothScroll() {
222
+ return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
223
+ }
224
+
83
225
  // A drag that selected text in the clicked ref's row isn't a click on the
84
226
  // ref. A selection elsewhere on the page (or a collapsed one) doesn't count.
85
227
  function selectingIn(owner, selection) {
@@ -93,42 +235,129 @@ function defaultOpen(url) {
93
235
  else window.location.href = url;
94
236
  }
95
237
 
96
- // One delegated listener on +root+ for every ref on the page. +viewer+()
97
- // is read per click and hover, so a capabilities change (another session,
98
- // a reload) is seen; +open+(url) opens an action's URL.
99
- export function installRefClicks(root, viewer, { open = defaultOpen, selection = () => window.getSelection?.() } = {}) {
238
+ // A link ref's href from the menu: a new tab, as the answer's links open.
239
+ function defaultOpenLink(href) {
240
+ window.open(href, "_blank", "noopener");
241
+ }
242
+
243
+ // One delegated listener set on +root+ for every ref on the page.
244
+ // +viewer+() is read per click, key and hover, so a capabilities change
245
+ // (another session, a reload) is seen. The targets run through: +open+(url)
246
+ // (the editor's URL), +openLink+(href), +show+(target) and +copy+(text).
247
+ // +menu+ is the ▾ and its menu (ref_menu.js).
248
+ export function installRefClicks(root, viewer, {
249
+ open = defaultOpen, openLink = defaultOpenLink, show = (target) => showInTurn(target), copy = copyText,
250
+ selection = () => window.getSelection?.(), menu = createRefMenu(root.ownerDocument),
251
+ } = {}) {
252
+ const env = { open, openLink, show, copy };
253
+ const run = (action, target) => action.run(target, viewer(), env);
254
+ const items = (actions, target) => actions.map((action) => ({ id: action.id, label: action.label(target), run: () => run(action, target) }));
255
+ const openMenu = (target, actions, options) => menu.open(target.el, items(actions, target), options);
256
+ // The last pointer's type: a tap opens the menu, a mouse click runs the
257
+ // main target.
258
+ let pointer = "mouse";
259
+ root.addEventListener("pointerdown", (e) => { pointer = e.pointerType || "mouse"; }, true);
260
+ // A key press: the keyboard leads now (Enter on a link after a tap is
261
+ // the link's own click); Tab works out the tab stops before it moves.
262
+ root.addEventListener("keydown", (e) => {
263
+ pointer = "keyboard";
264
+ if (e.key === "Tab") syncRefTabs(root, viewer());
265
+ }, true);
266
+
100
267
  root.addEventListener("click", (e) => {
101
268
  if (e.button !== 0 || e.defaultPrevented) return;
102
269
  const target = refTarget(e.target);
103
270
  if (!target) return;
104
- const action = refAction(target.kind, viewer());
105
- if (!action) return;
271
+ const actions = refActions(target, viewer());
272
+ if (!actions.length) return;
106
273
  if (selectingIn(target.owner, selection())) return;
107
- const url = action.url(target, viewer());
108
- if (!url) return;
274
+ if (pointer === "touch" && !target.isLine && actions.length > 1) {
275
+ e.preventDefault();
276
+ e.stopPropagation();
277
+ openMenu(target, actions, { focus: false });
278
+ return;
279
+ }
280
+ // A link's own click: the browser follows it.
281
+ if (actions[0].id === "link") return;
109
282
  e.preventDefault();
110
283
  e.stopPropagation();
111
- open(url);
284
+ run(actions[0], target);
112
285
  });
113
- // The hover hint, on the ref's own element, worked out on each hover
114
- // (the viewer can change): its own title (a row title's hover) first,
115
- // then the action's hint when there is one.
116
- root.addEventListener("mouseover", (e) => {
286
+
287
+ // A focused ref: Enter is its main target (a link's Enter is its own
288
+ // click), ↓ opens the menu with the first target focused, Esc hides the ▾.
289
+ root.addEventListener("keydown", (e) => {
290
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
291
+ const el = e.target;
292
+ if (!el?.matches?.("[data-ref-kind]")) return;
293
+ if (e.key === "Escape") menu.hideMore();
294
+ if (e.key !== "Enter" && e.key !== "ArrowDown") return;
295
+ const target = refTarget(el);
296
+ const actions = refActions(target, viewer());
297
+ if (!actions.length) return;
298
+ if (e.key === "Enter") {
299
+ if (actions[0].id === "link") return;
300
+ e.preventDefault();
301
+ run(actions[0], target);
302
+ } else if (actions.length > 1) {
303
+ e.preventDefault();
304
+ openMenu(target, actions, { focus: true });
305
+ }
306
+ });
307
+
308
+ // On hover or focus: the ref's main target on it (data-ref-main, for the
309
+ // styling), its hover hint, and the ▾ when it has more than one (not
310
+ // after a tap, which opens the menu itself).
311
+ const mark = (e) => {
117
312
  const target = refTarget(e.target);
118
313
  if (!target) return;
119
- const el = e.target.closest("[data-ref-kind]") || e.target.closest(".diff-line[data-line]");
120
- if (!el) return;
121
- if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
122
- const own = el.dataset.refTitle;
123
- const action = refAction(target.kind, viewer());
124
- const title = [own, action ? action.hint(target) : ""].filter(Boolean).join("\n");
125
- if (title) el.setAttribute("title", title);
126
- else el.removeAttribute("title");
127
- });
314
+ const actions = refActions(target, viewer());
315
+ const el = target.el;
316
+ if (actions.length) el.dataset.refMain = actions[0].id;
317
+ else delete el.dataset.refMain;
318
+ if (!target.isLine) setRefRole(el, actions);
319
+ hint(el, target, actions[0]);
320
+ if (!target.isLine && actions.length > 1 && pointer !== "touch") menu.showMore(el, () => openMenu(refTarget(el), refActions(refTarget(el), viewer()), { focus: true }));
321
+ };
322
+ root.addEventListener("mouseover", mark);
323
+ root.addEventListener("focusin", mark);
324
+ const leave = (e) => {
325
+ const el = e.target?.closest?.("[data-ref-kind]");
326
+ if (el && !el.contains(e.relatedTarget)) menu.hideMoreSoon(el);
327
+ };
328
+ root.addEventListener("mouseout", leave);
329
+ root.addEventListener("focusout", leave);
330
+ }
331
+
332
+ // The tab stops under +root+ for +viewer+: a ref is one (a button, with
333
+ // aria-haspopup when it has a menu) only while it has a target, so a
334
+ // phone's or an editorless viewer's row titles aren't. Worked out before a
335
+ // Tab moves the focus and on a ref's hover or focus; a line never is one.
336
+ export function syncRefTabs(root, viewer) {
337
+ for (const el of root.querySelectorAll("[data-ref-kind]")) setRefRole(el, refActions(refTarget(el), viewer));
338
+ }
339
+
340
+ function setRefRole(el, actions) {
341
+ if (actions.length > 1) el.setAttribute("aria-haspopup", "menu");
342
+ else el.removeAttribute("aria-haspopup");
343
+ // A link is focusable and a link already.
344
+ if (el.matches("a[href]")) return;
345
+ if (actions.length) {
346
+ el.tabIndex = 0;
347
+ el.setAttribute("role", "button");
348
+ } else {
349
+ el.removeAttribute("tabindex");
350
+ el.removeAttribute("role");
351
+ }
128
352
  }
129
353
 
130
- // The body's classes for +viewer+: refs-<kind> for each kind with an action.
131
- export function setRefKinds(body, viewer, actions = REF_ACTIONS) {
132
- const kinds = availableKinds(viewer, actions);
133
- for (const kind of Object.keys(actions)) body.classList.toggle(`refs-${kind}`, kinds.includes(kind));
354
+ // The hover hint, on the ref's own element, worked out on each hover (the
355
+ // viewer can change): its own title (a row title's hover) first, then the
356
+ // main target's hint when it has one.
357
+ function hint(el, target, action) {
358
+ if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
359
+ const own = el.dataset.refTitle;
360
+ const title = [own, action?.hint ? action.hint(target) : ""].filter(Boolean).join("\n");
361
+ if (title) el.setAttribute("title", title);
362
+ else el.removeAttribute("title");
134
363
  }