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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/README.md +1 -1
- data/docs/cli.md +1 -1
- data/docs/configuration.md +5 -3
- data/docs/hooks.md +2 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +63 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
- data/lib/samagotchi/bootstrap/probe.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -0
- data/lib/samagotchi/broadcast_command.rb +2 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_command.rb +2 -0
- data/lib/samagotchi/desktop/macos.rb +3 -0
- data/lib/samagotchi/engine.rb +2 -2
- data/lib/samagotchi/gem_update.rb +3 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/installed_versions.rb +3 -0
- data/lib/samagotchi/model_profile.rb +14 -3
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/redacted_inspect.rb +43 -0
- data/lib/samagotchi/session_commands.rb +5 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -8
- data/lib/samagotchi/terminal_ui.rb +14 -5
- data/lib/samagotchi/tool_runner.rb +14 -3
- data/lib/samagotchi/tools/execute.rb +3 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -6
- data/lib/samagotchi/web/public/app.js +31 -14
- data/lib/samagotchi/web/public/index.html +32 -5
- data/lib/samagotchi/web/public/question_card.js +5 -3
- data/lib/samagotchi/web/public/question_cards.js +21 -1
- data/lib/samagotchi/web/public/read_view.js +74 -0
- data/lib/samagotchi/web/public/ref_menu.js +188 -0
- data/lib/samagotchi/web/public/ref_scan.js +216 -0
- data/lib/samagotchi/web/public/refs.js +283 -54
- data/lib/samagotchi/web/public/stage_view.js +21 -0
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/turn_view.js +5 -3
- data/lib/samagotchi/web/token.rb +3 -0
- data/lib/samagotchi/worker.rb +2 -2
- 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?})
|
|
4
|
-
// same file at a line)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
30
|
-
// click's
|
|
31
|
-
//
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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(
|
|
63
|
-
|
|
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?.(
|
|
66
|
-
const owner = diffLine?.closest(
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
105
|
-
if (!
|
|
271
|
+
const actions = refActions(target, viewer());
|
|
272
|
+
if (!actions.length) return;
|
|
106
273
|
if (selectingIn(target.owner, selection())) return;
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
284
|
+
run(actions[0], target);
|
|
112
285
|
});
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
root.addEventListener("
|
|
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
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
}
|