samagotchi 0.57.0 → 0.59.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 +56 -1
- data/docs/architecture.md +3 -3
- data/docs/cli.md +5 -4
- data/docs/configuration.md +40 -23
- data/docs/hooks.md +2 -2
- data/docs/internals/background-tasks.md +7 -0
- data/docs/internals/engine.md +5 -5
- data/docs/internals/prompt-caching.md +2 -2
- data/docs/internals/tool-guardrails.md +2 -1
- data/docs/plugins.md +26 -6
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +6 -2
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +11 -5
- data/lib/samagotchi/engine.rb +28 -3
- data/lib/samagotchi/host_config.rb +3 -1
- data/lib/samagotchi/host_registry.rb +2 -1
- data/lib/samagotchi/model_profile.rb +14 -3
- data/lib/samagotchi/model_settings.rb +4 -2
- data/lib/samagotchi/process_group.rb +76 -3
- data/lib/samagotchi/redacted_inspect.rb +21 -6
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_commands.rb +19 -6
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/steer_cut_after.rb +69 -0
- data/lib/samagotchi/system_prompt.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
- data/lib/samagotchi/terminal_ui.rb +15 -6
- data/lib/samagotchi/tool_activity.rb +18 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/execute.rb +318 -70
- data/lib/samagotchi/tools/task_get.rb +2 -0
- data/lib/samagotchi/tools/task_runtime.rb +96 -13
- data/lib/samagotchi/tools/task_wait.rb +23 -5
- data/lib/samagotchi/utf8_default.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +8 -2
- data/lib/samagotchi/web/public/app.js +7 -6
- data/lib/samagotchi/web/public/index.html +20 -7
- data/lib/samagotchi/web/public/read_view.js +3 -1
- data/lib/samagotchi/web/public/ref_menu.js +188 -0
- data/lib/samagotchi/web/public/ref_scan.js +11 -1
- data/lib/samagotchi/web/public/refs.js +271 -51
- data/lib/samagotchi/web/public/stage_view.js +21 -0
- data/lib/samagotchi/web/public/task_stop.js +25 -7
- data/lib/samagotchi/worker.rb +2 -2
- metadata +3 -1
|
@@ -10,6 +10,9 @@ module Samagotchi
|
|
|
10
10
|
TAIL_LINES_DEFAULT = 10
|
|
11
11
|
TAIL_LINES_MAX = 100
|
|
12
12
|
POLL_INTERVAL = 0.5
|
|
13
|
+
# A handed-over execute keeps stderr in a log of its own: at most this
|
|
14
|
+
# many of its last lines follow the output tail.
|
|
15
|
+
STDERR_TAIL_LINES_MAX = 10
|
|
13
16
|
|
|
14
17
|
def self.name = NAME
|
|
15
18
|
|
|
@@ -41,7 +44,7 @@ module Samagotchi
|
|
|
41
44
|
|
|
42
45
|
if completion_pattern
|
|
43
46
|
output_tail = output_tail(record, tail_lines)
|
|
44
|
-
return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
|
|
47
|
+
return format_response(record, wait_result: "pattern_matched", output_tail: output_tail, tail_lines: tail_lines) if completion_pattern.match?(output_tail)
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
return cancelled_response(task_id, tail_lines, started) if cancelled.call
|
|
@@ -55,7 +58,7 @@ module Samagotchi
|
|
|
55
58
|
refreshed, = TaskRuntime.get_record(task_id)
|
|
56
59
|
return finished_response(refreshed, tail_lines) unless refreshed.fetch("status") == "running"
|
|
57
60
|
|
|
58
|
-
format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines))
|
|
61
|
+
format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines), tail_lines: tail_lines)
|
|
59
62
|
rescue StandardError => e
|
|
60
63
|
"Error: #{e.message}"
|
|
61
64
|
end
|
|
@@ -72,7 +75,8 @@ module Samagotchi
|
|
|
72
75
|
" To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
|
|
73
76
|
"waited: #{(monotonic_time - started).round}s"
|
|
74
77
|
]
|
|
75
|
-
format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines),
|
|
78
|
+
format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
|
|
79
|
+
extra: note)
|
|
76
80
|
end
|
|
77
81
|
private_class_method :cancelled_response
|
|
78
82
|
|
|
@@ -81,7 +85,8 @@ module Samagotchi
|
|
|
81
85
|
def self.finished_response(record, tail_lines)
|
|
82
86
|
return format_response(record) unless TaskRuntime.stopped_by_user?(record)
|
|
83
87
|
|
|
84
|
-
format_response(record, output_tail: output_tail(record, tail_lines),
|
|
88
|
+
format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
|
|
89
|
+
extra: [TaskRuntime::USER_STOP_NOTE])
|
|
85
90
|
end
|
|
86
91
|
private_class_method :finished_response
|
|
87
92
|
|
|
@@ -90,7 +95,17 @@ module Samagotchi
|
|
|
90
95
|
end
|
|
91
96
|
private_class_method :output_tail
|
|
92
97
|
|
|
93
|
-
|
|
98
|
+
# The last lines of a handed-over execute's stderr log (stderr_path),
|
|
99
|
+
# "" for a task_create task (one log) or an empty stderr.
|
|
100
|
+
def self.stderr_tail(record, tail_lines)
|
|
101
|
+
path = record["stderr_path"]
|
|
102
|
+
return "" if path.to_s.empty? || tail_lines.nil?
|
|
103
|
+
|
|
104
|
+
TaskRuntime.output_tail_lines(path, [tail_lines, STDERR_TAIL_LINES_MAX].min)
|
|
105
|
+
end
|
|
106
|
+
private_class_method :stderr_tail
|
|
107
|
+
|
|
108
|
+
def self.format_response(record, wait_result: nil, output_tail: nil, tail_lines: nil, extra: [])
|
|
94
109
|
lines = [
|
|
95
110
|
"task_id: #{record.fetch("id")}",
|
|
96
111
|
"status: #{record.fetch("status")}",
|
|
@@ -98,12 +113,15 @@ module Samagotchi
|
|
|
98
113
|
"stop_reason: #{record["stop_reason"]}",
|
|
99
114
|
"output_path: #{record.fetch("output_path")}"
|
|
100
115
|
]
|
|
116
|
+
lines << "stderr_path: #{record["stderr_path"]}" if record["stderr_path"]
|
|
101
117
|
return lines.join("\n") unless output_tail
|
|
102
118
|
|
|
103
119
|
lines << "wait_result: #{wait_result}" if wait_result
|
|
104
120
|
lines.concat(extra)
|
|
105
121
|
lines << "output_tail:"
|
|
106
122
|
lines << output_tail.rstrip unless output_tail.empty?
|
|
123
|
+
stderr_tail = stderr_tail(record, tail_lines)
|
|
124
|
+
lines.concat(["stderr_tail:", stderr_tail.rstrip]) unless stderr_tail.empty?
|
|
107
125
|
lines.join("\n")
|
|
108
126
|
end
|
|
109
127
|
private_class_method :format_response
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
|
-
# chi's files (memories,
|
|
4
|
+
# chi's files (memories, AGENTS.md, sessions, config.yml) are UTF-8,
|
|
5
5
|
# whatever the locale. Under LC_ALL=C (or with no locale at all: an app
|
|
6
6
|
# started from Finder or launchd, `env -i`) Ruby reads files as US-ASCII,
|
|
7
7
|
# and a UTF-8 memory index then breaks the system prompt
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -45,6 +45,7 @@ require_relative "editor"
|
|
|
45
45
|
require_relative "message_parts"
|
|
46
46
|
require_relative "session_summary"
|
|
47
47
|
require_relative "../log"
|
|
48
|
+
require_relative "../redacted_inspect"
|
|
48
49
|
|
|
49
50
|
module Samagotchi
|
|
50
51
|
module Web
|
|
@@ -56,6 +57,9 @@ module Samagotchi
|
|
|
56
57
|
# (the single live client transport); history for any session — including
|
|
57
58
|
# dead ones — is served by GET /api/sessions/:id/output from output/ files.
|
|
58
59
|
class App
|
|
60
|
+
# @lan holds the LAN access token.
|
|
61
|
+
include RedactedInspect
|
|
62
|
+
|
|
59
63
|
DEFAULT_HOST = BridgeClient::HOST
|
|
60
64
|
BRIDGE_WAIT_TIMEOUT = 10.0
|
|
61
65
|
# An uploaded image may be this big before it is downscaled.
|
|
@@ -2130,10 +2134,12 @@ module Samagotchi
|
|
|
2130
2134
|
# before returning, and a read blocked on a quiet worker would hang
|
|
2131
2135
|
# Ctrl-C of chi web. The bytes go through unchanged.
|
|
2132
2136
|
class ProxyStreamBody
|
|
2137
|
+
# @param headers [Hash] the request's Rack env; only its Last-Event-ID
|
|
2138
|
+
# is kept (the env carries the token cookie, never held or inspected)
|
|
2133
2139
|
def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true })
|
|
2134
2140
|
@client = BridgeClient.new(session_id: session_id, port: port, host: host)
|
|
2135
2141
|
@query = query
|
|
2136
|
-
@
|
|
2142
|
+
@last_event_id = headers["HTTP_LAST_EVENT_ID"]
|
|
2137
2143
|
@server_running = server_running
|
|
2138
2144
|
end
|
|
2139
2145
|
|
|
@@ -2142,7 +2148,7 @@ module Samagotchi
|
|
|
2142
2148
|
# Last-Event-ID header over ?from_seq, and the reconnect URL carries a
|
|
2143
2149
|
# stale initial cursor — without this the bridge would replay content
|
|
2144
2150
|
# already delivered (duplicate bubbles).
|
|
2145
|
-
@client.stream(query: @query, last_event_id: @
|
|
2151
|
+
@client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &)
|
|
2146
2152
|
end
|
|
2147
2153
|
end
|
|
2148
2154
|
end
|
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
|
|
66
66
|
} from "./timing.js";
|
|
67
67
|
import { createLiveTurn } from "./live_turn.js";
|
|
68
|
-
import { installRefClicks
|
|
68
|
+
import { installRefClicks } from "./refs.js";
|
|
69
69
|
import { linkRefs, touchedFiles } from "./ref_scan.js";
|
|
70
70
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
71
71
|
import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
|
|
@@ -855,13 +855,11 @@ function setMarkdownWarning(warning) {
|
|
|
855
855
|
markdownWarningEl.hidden = false;
|
|
856
856
|
}
|
|
857
857
|
|
|
858
|
-
// Both from one payload, together; the body's has-editor
|
|
859
|
-
// classes follow (the refs' hover styling keys on refs-<kind>).
|
|
858
|
+
// Both from one payload, together; the body's has-editor class follows.
|
|
860
859
|
function setCapabilities(capabilities, editorUrl) {
|
|
861
860
|
pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
|
|
862
861
|
pageEditorUrl = pageCapabilities.editor === true && typeof editorUrl === "string" && editorUrl ? editorUrl : null;
|
|
863
862
|
document.body.classList.toggle("has-editor", pageEditorUrl !== null);
|
|
864
|
-
setRefKinds(document.body, pageViewer());
|
|
865
863
|
}
|
|
866
864
|
|
|
867
865
|
// What this viewer can do with a ref (refs.js REF_ACTIONS).
|
|
@@ -869,8 +867,11 @@ function pageViewer() {
|
|
|
869
867
|
return { editor: pageCapabilities.editor === true, editorUrl: pageEditorUrl };
|
|
870
868
|
}
|
|
871
869
|
|
|
872
|
-
// Refs anywhere on the page (a tool row's file, its diff lines
|
|
873
|
-
|
|
870
|
+
// Refs anywhere on the page (a tool row's file, its diff lines, an
|
|
871
|
+
// answer's path:line): one listener set; "Copy path" says so in a toast.
|
|
872
|
+
installRefClicks(document.body, pageViewer, {
|
|
873
|
+
copy: async (text) => showToast((await copyText(text)) ? "Path copied" : "Couldn't copy the path"),
|
|
874
|
+
});
|
|
874
875
|
|
|
875
876
|
async function readTurnEnd() {
|
|
876
877
|
if (!selected) return;
|
|
@@ -380,13 +380,26 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
380
380
|
.diff{margin:0;padding:4px 0;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:420px}
|
|
381
381
|
.diff code{display:block;min-width:max-content}
|
|
382
382
|
.diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
|
|
383
|
-
/* Refs (refs.js): text until hovered, and only
|
|
384
|
-
.
|
|
385
|
-
.
|
|
386
|
-
.
|
|
387
|
-
|
|
388
|
-
.
|
|
389
|
-
.
|
|
383
|
+
/* Refs (refs.js): text until hovered or focused, and only with a target for this viewer (refs.js marks it: data-ref-main, the main one). A link ref keeps its link look. */
|
|
384
|
+
.ref[data-ref-main]:not(a):is(:hover,:focus-visible){color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
|
|
385
|
+
.ref[data-ref-main="editor"]:not(a):is(:hover,:focus-visible)::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
|
|
386
|
+
.ref[data-ref-kind]:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
|
|
387
|
+
:is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
|
|
388
|
+
:is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
|
|
389
|
+
.activity-row .read-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
|
|
390
|
+
.activity-row .read-line[data-ref-main]:hover::before{color:var(--accent)}
|
|
391
|
+
/* A ref's ▾ and its menu (ref_menu.js): one each, placed next to the ref. */
|
|
392
|
+
.ref-more{position:fixed;z-index:60;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-panel);color:var(--text-muted);font-size:12px;line-height:1;cursor:pointer;box-shadow:0 1px 3px var(--shadow-strong)}
|
|
393
|
+
.ref-more[hidden]{display:none}
|
|
394
|
+
.ref-more:hover{color:var(--accent);border-color:var(--accent)}
|
|
395
|
+
.ref-menu{position:fixed;z-index:61;min-width:160px;max-width:calc(100vw - 16px);padding:4px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg-panel);box-shadow:var(--toast-shadow);animation:fadeIn .12s ease-out}
|
|
396
|
+
.ref-menu [role="menuitem"]{display:block;width:100%;padding:6px 10px;border:0;border-radius:5px;background:none;color:var(--text);font:inherit;font-size:13px;text-align:left;cursor:pointer;white-space:nowrap}
|
|
397
|
+
.ref-menu [role="menuitem"]:is(:hover,:focus-visible){background:var(--accent-bg);color:var(--text-bright);outline:none}
|
|
398
|
+
@media (pointer:coarse){.ref-menu [role="menuitem"]{padding:11px 14px;font-size:15px}}
|
|
399
|
+
/* Show in turn: the row (and its line) flash once. */
|
|
400
|
+
@keyframes ref-flash{0%,35%{background:color-mix(in srgb,var(--accent) 22%,transparent)}100%{background:transparent}}
|
|
401
|
+
.ref-flash{animation:ref-flash 1.6s ease-out}
|
|
402
|
+
@media (prefers-reduced-motion:reduce){.ref-menu{animation:none}.ref-flash{animation:none;outline:2px solid var(--accent);outline-offset:2px}}
|
|
390
403
|
.diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
|
|
391
404
|
.diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
|
|
392
405
|
.diff-hunk{color:var(--text-faint)}
|
|
@@ -67,8 +67,10 @@ export function readLinesHtml(row) {
|
|
|
67
67
|
const first = numbered[0].line;
|
|
68
68
|
const last = numbered.at(-1).line;
|
|
69
69
|
const label = first === last ? `line ${first}` : `lines ${first}–${last}`;
|
|
70
|
+
// A blank line of the file holds no text, only a <br>: an empty block
|
|
71
|
+
// drops out of a copied selection, a space would be copied as one.
|
|
70
72
|
const spans = lines.map(({ text, line }) => line === null
|
|
71
73
|
? `<span class="read-line read-meta">${escapeHtml(text)}</span>`
|
|
72
|
-
: `<span class="read-line" data-line="${line}">${escapeHtml(text) || "
|
|
74
|
+
: `<span class="read-line" data-line="${line}">${escapeHtml(text) || "<br>"}</span>`).join("");
|
|
73
75
|
return `<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
|
|
74
76
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// A ref's menu (refs.js): the small ▾ that shows next to a hovered or
|
|
2
|
+
// focused ref with more than one target, and the menu of its targets it
|
|
3
|
+
// opens (a tap and ↓ open it too). One of each per page, placed next to
|
|
4
|
+
// the ref (position: fixed); a scroll, a resize, a click elsewhere, Esc or
|
|
5
|
+
// Tab closes them. Keyboard: ↑/↓ (Home/End) move, Enter picks, Esc closes
|
|
6
|
+
// back to the ref.
|
|
7
|
+
|
|
8
|
+
// How long the ▾ stays after the pointer leaves the ref: long enough to
|
|
9
|
+
// reach it.
|
|
10
|
+
const HIDE_MS = 300;
|
|
11
|
+
// Kept from the viewport's edges (a phone's width).
|
|
12
|
+
const EDGE = 8;
|
|
13
|
+
|
|
14
|
+
// +doc+'s ▾ and menu. open(el, items, {focus}) shows +items+ ({id, label,
|
|
15
|
+
// run}) under +el+; showMore(el, onOpen) puts the ▾ after +el+, whose click
|
|
16
|
+
// calls +onOpen+; hideMoreSoon(el) takes it away unless the pointer reaches
|
|
17
|
+
// it; close({refocus}) closes the menu.
|
|
18
|
+
export function createRefMenu(doc = globalThis.document) {
|
|
19
|
+
let more = null;
|
|
20
|
+
let menu = null;
|
|
21
|
+
let moreFor = null;
|
|
22
|
+
let menuFor = null;
|
|
23
|
+
let onMore = null;
|
|
24
|
+
let hideTimer = null;
|
|
25
|
+
const win = doc.defaultView || globalThis;
|
|
26
|
+
|
|
27
|
+
function moreButton() {
|
|
28
|
+
if (more) return more;
|
|
29
|
+
more = doc.createElement("button");
|
|
30
|
+
more.type = "button";
|
|
31
|
+
more.className = "ref-more";
|
|
32
|
+
more.textContent = "▾";
|
|
33
|
+
more.title = "More for this ref";
|
|
34
|
+
more.setAttribute("aria-label", "More for this ref");
|
|
35
|
+
more.setAttribute("aria-haspopup", "menu");
|
|
36
|
+
more.tabIndex = -1;
|
|
37
|
+
more.hidden = true;
|
|
38
|
+
more.addEventListener("mouseenter", () => clearTimeout(hideTimer));
|
|
39
|
+
more.addEventListener("mouseleave", () => hideMoreSoon(moreFor));
|
|
40
|
+
more.addEventListener("mousedown", (e) => e.preventDefault()); // keeps the ref's focus
|
|
41
|
+
more.addEventListener("click", (e) => {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
e.stopPropagation();
|
|
44
|
+
onMore?.();
|
|
45
|
+
});
|
|
46
|
+
doc.body.appendChild(more);
|
|
47
|
+
return more;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function showMore(el, onOpen) {
|
|
51
|
+
clearTimeout(hideTimer);
|
|
52
|
+
const button = moreButton();
|
|
53
|
+
moreFor = el;
|
|
54
|
+
onMore = onOpen;
|
|
55
|
+
button.hidden = false;
|
|
56
|
+
const rect = lastRect(el);
|
|
57
|
+
const top = rect.top + (rect.height - (button.offsetHeight || rect.height)) / 2;
|
|
58
|
+
button.style.left = `${Math.min(rect.right + 2, win.innerWidth - (button.offsetWidth || 16) - 2)}px`;
|
|
59
|
+
button.style.top = `${top}px`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hideMore() {
|
|
63
|
+
clearTimeout(hideTimer);
|
|
64
|
+
if (more) more.hidden = true;
|
|
65
|
+
moreFor = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hideMoreSoon(el) {
|
|
69
|
+
if (!more || (el && el !== moreFor)) return;
|
|
70
|
+
clearTimeout(hideTimer);
|
|
71
|
+
hideTimer = setTimeout(() => {
|
|
72
|
+
if (menu && menuFor === moreFor) return;
|
|
73
|
+
if (doc.activeElement && doc.activeElement === moreFor) return;
|
|
74
|
+
hideMore();
|
|
75
|
+
}, HIDE_MS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function open(el, items, { focus = true } = {}) {
|
|
79
|
+
close();
|
|
80
|
+
menuFor = el;
|
|
81
|
+
menu = doc.createElement("div");
|
|
82
|
+
menu.className = "ref-menu";
|
|
83
|
+
menu.setAttribute("role", "menu");
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
const button = doc.createElement("button");
|
|
86
|
+
button.type = "button";
|
|
87
|
+
button.setAttribute("role", "menuitem");
|
|
88
|
+
button.tabIndex = -1;
|
|
89
|
+
button.dataset.target = item.id;
|
|
90
|
+
button.textContent = item.label;
|
|
91
|
+
button.addEventListener("click", (e) => {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
e.stopPropagation();
|
|
94
|
+
close({ refocus: false });
|
|
95
|
+
hideMore();
|
|
96
|
+
item.run();
|
|
97
|
+
});
|
|
98
|
+
menu.appendChild(button);
|
|
99
|
+
}
|
|
100
|
+
menu.addEventListener("keydown", onMenuKey);
|
|
101
|
+
menu.addEventListener("focusout", (e) => {
|
|
102
|
+
if (menu && !menu.contains(e.relatedTarget) && e.relatedTarget) close({ refocus: false });
|
|
103
|
+
});
|
|
104
|
+
doc.body.appendChild(menu);
|
|
105
|
+
place(menu, el);
|
|
106
|
+
el.setAttribute("aria-expanded", "true");
|
|
107
|
+
doc.addEventListener("pointerdown", onOutside, true);
|
|
108
|
+
win.addEventListener("scroll", onScroll, true);
|
|
109
|
+
win.addEventListener("resize", onScroll);
|
|
110
|
+
if (focus) menuItems()[0]?.focus();
|
|
111
|
+
return menu;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function close({ refocus = false } = {}) {
|
|
115
|
+
if (!menu) return;
|
|
116
|
+
const el = menuFor;
|
|
117
|
+
menu.remove();
|
|
118
|
+
menu = null;
|
|
119
|
+
menuFor = null;
|
|
120
|
+
el?.removeAttribute("aria-expanded");
|
|
121
|
+
doc.removeEventListener("pointerdown", onOutside, true);
|
|
122
|
+
win.removeEventListener("scroll", onScroll, true);
|
|
123
|
+
win.removeEventListener("resize", onScroll);
|
|
124
|
+
if (refocus) el?.focus?.();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function menuItems() {
|
|
128
|
+
return menu ? [...menu.querySelectorAll('[role="menuitem"]')] : [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onMenuKey(e) {
|
|
132
|
+
const items = menuItems();
|
|
133
|
+
const at = items.indexOf(doc.activeElement);
|
|
134
|
+
const go = (index) => items[(index + items.length) % items.length]?.focus();
|
|
135
|
+
if (e.key === "ArrowDown") go(at + 1);
|
|
136
|
+
else if (e.key === "ArrowUp") go(at < 0 ? -1 : at - 1);
|
|
137
|
+
else if (e.key === "Home") go(0);
|
|
138
|
+
else if (e.key === "End") go(-1);
|
|
139
|
+
else if (e.key === "Escape") close({ refocus: true });
|
|
140
|
+
else if (e.key === "Tab") { close({ refocus: true }); return; }
|
|
141
|
+
else return;
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function onOutside(e) {
|
|
147
|
+
if (menu?.contains(e.target) || more?.contains(e.target)) return;
|
|
148
|
+
close();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onScroll(e) {
|
|
152
|
+
if (menu?.contains(e?.target)) return;
|
|
153
|
+
close();
|
|
154
|
+
hideMore();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Under the ref's last line, flipped above it when there is no room
|
|
158
|
+
// below, kept inside the viewport.
|
|
159
|
+
function place(box, el) {
|
|
160
|
+
const rect = lastRect(el);
|
|
161
|
+
const first = el.getClientRects?.()[0] || rect;
|
|
162
|
+
const width = box.offsetWidth;
|
|
163
|
+
const height = box.offsetHeight;
|
|
164
|
+
const left = Math.max(EDGE, Math.min(rect.left, win.innerWidth - width - EDGE));
|
|
165
|
+
const below = rect.bottom + 4;
|
|
166
|
+
const top = below + height > win.innerHeight - EDGE && first.top - height - 4 >= EDGE ? first.top - height - 4 : below;
|
|
167
|
+
box.style.left = `${left}px`;
|
|
168
|
+
box.style.top = `${top}px`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
open,
|
|
173
|
+
close,
|
|
174
|
+
showMore,
|
|
175
|
+
hideMore,
|
|
176
|
+
hideMoreSoon,
|
|
177
|
+
get isOpen() { return !!menu; },
|
|
178
|
+
get element() { return menu; },
|
|
179
|
+
get moreButton() { return more; },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The ref's last line box (a ref wrapped over two lines ends on the
|
|
184
|
+
// second); its whole box when it has none.
|
|
185
|
+
function lastRect(el) {
|
|
186
|
+
const rects = el.getClientRects?.() || [];
|
|
187
|
+
return rects.length ? rects[rects.length - 1] : el.getBoundingClientRect();
|
|
188
|
+
}
|
|
@@ -118,10 +118,20 @@ export function touchedFiles(roots) {
|
|
|
118
118
|
// Wraps the refs in +el+'s text in span.ref (refs.js clicks and
|
|
119
119
|
// hover them as it does a row title). Text inside pre, a link or a ref is
|
|
120
120
|
// left alone; an inline code element is wrapped whole when it is a ref
|
|
121
|
-
// alone.
|
|
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.
|
|
122
125
|
export function linkRefs(el, touched, cwd) {
|
|
123
126
|
if (!el || !touched?.size) return;
|
|
124
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
|
+
}
|
|
125
135
|
for (const code of [...el.querySelectorAll("code")]) {
|
|
126
136
|
if (code.closest(SKIP)) continue;
|
|
127
137
|
const ref = aloneRef(code.textContent, touched, cwd);
|