openclacky 1.5.13 → 1.5.14
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 +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
const MAIN_BRANCHES = { main: true, master: true };
|
|
28
28
|
const DIFF_ASIDE_WIDTH = 860;
|
|
29
29
|
const MAX_DIFF_LINES = 3000;
|
|
30
|
+
let releaseAsideWidth = null; // set while the aside is widened for the diff
|
|
30
31
|
const ICON_CLOSE = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>';
|
|
32
|
+
const ICON_BACK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>';
|
|
31
33
|
const ICON_REVERT = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>';
|
|
32
34
|
const t = (k, fallback) => {
|
|
33
35
|
const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
|
|
@@ -38,6 +40,7 @@
|
|
|
38
40
|
const style = document.createElement("style");
|
|
39
41
|
style.id = "changes-panel-style";
|
|
40
42
|
style.textContent = `
|
|
43
|
+
.changes-root { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
41
44
|
.changes-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
42
45
|
.changes-summary { flex: none; padding: 10px 12px 8px; border-bottom: 1px solid var(--color-border-secondary); }
|
|
43
46
|
.changes-summary .h { font-size: 13px; color: var(--color-text-secondary); }
|
|
@@ -47,7 +50,7 @@
|
|
|
47
50
|
.changes-branch code { font-family: ui-monospace, monospace; font-weight: 600; }
|
|
48
51
|
.cg-body { flex: 1; min-height: 0; display: flex; }
|
|
49
52
|
.cg-list-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }
|
|
50
|
-
.changes-panel.has-diff .cg-list-pane { flex: 0
|
|
53
|
+
.changes-panel.has-diff .cg-list-pane { flex: 0 1 232px; min-width: 120px; max-width: 40%; border-right: 1px solid var(--color-border-primary); }
|
|
51
54
|
.cg-listbar { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--color-border-secondary); font-size: 11px; color: var(--color-text-secondary); user-select: none; }
|
|
52
55
|
.cg-listbar .cg-check { cursor: pointer; }
|
|
53
56
|
.cg-count { margin-left: auto; color: var(--color-text-tertiary); }
|
|
@@ -69,6 +72,8 @@
|
|
|
69
72
|
.cg-diff-pane { display: none; flex: 1; min-width: 0; min-height: 0; flex-direction: column; }
|
|
70
73
|
.changes-panel.has-diff .cg-diff-pane { display: flex; }
|
|
71
74
|
.cg-diff-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--color-border-secondary); }
|
|
75
|
+
.cg-diff-back { flex: none; display: none; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-secondary); cursor: pointer; }
|
|
76
|
+
.cg-diff-back:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
72
77
|
.cg-diff-path { flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
73
78
|
.cg-diff-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
|
|
74
79
|
.cg-diff-close:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
@@ -90,6 +95,15 @@
|
|
|
90
95
|
.changes-hint { text-align: center; font-size: 11px; color: var(--color-text-tertiary); margin-top: 7px; min-height: 1em; }
|
|
91
96
|
.changes-empty, .changes-loading, .changes-error { color: var(--color-text-tertiary); padding: 16px; font-size: 12px; text-align: center; }
|
|
92
97
|
.changes-error { color: var(--color-error); }
|
|
98
|
+
@media (max-width: 768px) {
|
|
99
|
+
.changes-panel.has-diff .changes-summary,
|
|
100
|
+
.changes-panel.has-diff .cg-list-pane,
|
|
101
|
+
.changes-panel.has-diff .changes-foot { display: none; }
|
|
102
|
+
.changes-panel.has-diff .cg-diff-pane { display: flex; width: 100%; }
|
|
103
|
+
.cg-diff-head { padding-left: 6px; }
|
|
104
|
+
.cg-diff-back { display: inline-flex; }
|
|
105
|
+
.cg-diff-close { display: none; }
|
|
106
|
+
}
|
|
93
107
|
`;
|
|
94
108
|
document.head.appendChild(style);
|
|
95
109
|
}
|
|
@@ -140,19 +154,19 @@
|
|
|
140
154
|
return `${t("changes.save.prefix", "手动存档")} · ${stamp}`;
|
|
141
155
|
}
|
|
142
156
|
|
|
143
|
-
// The diff pane needs horizontal room
|
|
144
|
-
//
|
|
157
|
+
// The diff pane needs horizontal room, so borrow it from the host while a
|
|
158
|
+
// diff is open. The host coordinates overlapping panels and preserves any
|
|
159
|
+
// width the user chooses manually in the meantime.
|
|
145
160
|
function ensureAsideWidth() {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
161
|
+
if (releaseAsideWidth || !window.Clacky || !Clacky.Aside || !Clacky.Aside.requestWidth) return;
|
|
162
|
+
releaseAsideWidth = Clacky.Aside.requestWidth(DIFF_ASIDE_WIDTH);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function restoreAsideWidth() {
|
|
166
|
+
if (!releaseAsideWidth) return;
|
|
167
|
+
const release = releaseAsideWidth;
|
|
168
|
+
releaseAsideWidth = null;
|
|
169
|
+
release();
|
|
156
170
|
}
|
|
157
171
|
|
|
158
172
|
// ── Panel state ─────────────────────────────────────────────────────────
|
|
@@ -319,6 +333,7 @@
|
|
|
319
333
|
}
|
|
320
334
|
|
|
321
335
|
function closeDiff() {
|
|
336
|
+
restoreAsideWidth();
|
|
322
337
|
if (!state.els) return;
|
|
323
338
|
state.selected = null;
|
|
324
339
|
state.els.panelEl.classList.remove("has-diff");
|
|
@@ -541,6 +556,10 @@
|
|
|
541
556
|
const listEl = el("div", { class: "changes-list" });
|
|
542
557
|
const listPane = el("div", { class: "cg-list-pane" }, listbar, listEl);
|
|
543
558
|
|
|
559
|
+
const diffBack = el("button", { class: "cg-diff-back", type: "button",
|
|
560
|
+
title: t("changes.backToList", "返回改动列表"), "aria-label": t("changes.backToList", "返回改动列表") });
|
|
561
|
+
diffBack.innerHTML = ICON_BACK;
|
|
562
|
+
diffBack.addEventListener("click", closeDiff);
|
|
544
563
|
const diffPathEl = el("span", { class: "cg-diff-path" });
|
|
545
564
|
const diffRevert = el("button", { class: "cg-diff-revert", type: "button",
|
|
546
565
|
title: t("changes.restore.title", "撤销此文件的改动") });
|
|
@@ -554,7 +573,7 @@
|
|
|
554
573
|
diffClose.addEventListener("click", closeDiff);
|
|
555
574
|
const diffCanvas = el("div", { class: "cg-diff" });
|
|
556
575
|
const diffPane = el("div", { class: "cg-diff-pane" },
|
|
557
|
-
el("div", { class: "cg-diff-head" }, diffPathEl, diffRevert, diffClose), diffCanvas);
|
|
576
|
+
el("div", { class: "cg-diff-head" }, diffBack, diffPathEl, diffRevert, diffClose), diffCanvas);
|
|
558
577
|
|
|
559
578
|
const saveBtn = el("button", { class: "changes-save-btn", type: "button", text: t("changes.save.btn", "存档当前版本") });
|
|
560
579
|
const hint = el("div", { class: "changes-hint" });
|
|
@@ -602,6 +621,7 @@
|
|
|
602
621
|
return () => {
|
|
603
622
|
unsubscribeComplete();
|
|
604
623
|
unsubscribeFileSaved();
|
|
624
|
+
restoreAsideWidth();
|
|
605
625
|
if (activeSessionId === sid) {
|
|
606
626
|
activeRoot = null;
|
|
607
627
|
activeBody = null;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
id: preview
|
|
2
|
+
name: Preview
|
|
3
|
+
display_name: Preview
|
|
4
|
+
display_name_zh: 预览
|
|
5
|
+
description: Live preview panel — click an HTML file or localhost URL in chat to open it
|
|
6
|
+
description_zh: 预览面板 — 点击聊天里的 HTML 文件或 localhost 链接打开
|
|
7
|
+
version: "0.1.0"
|
|
8
|
+
author: OpenClacky
|
|
9
|
+
homepage: https://www.openclacky.com
|
|
10
|
+
license: MIT
|
|
11
|
+
contributes:
|
|
12
|
+
panels:
|
|
13
|
+
- id: preview
|
|
14
|
+
title: Preview
|
|
15
|
+
title_zh: 预览
|
|
16
|
+
description: Live preview of workspace HTML files
|
|
17
|
+
description_zh: 工作区 HTML 文件实时预览
|
|
18
|
+
order: 40
|
|
19
|
+
attach: ["*"]
|
|
20
|
+
view: panels/preview/view.js
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
// ── Official panel: live preview ──────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// "预览 / Preview": a mini browser view of workspace HTML files (served by
|
|
4
|
+
// GET /preview/s/<session>/<rel>) and of any http(s) URL — dev servers, local
|
|
5
|
+
// hosts — loaded straight into the sandboxed iframe. The toolbar mirrors a
|
|
6
|
+
// browser chrome: back / forward / reload on the left, a centred address bar,
|
|
7
|
+
// open-external on the right. Clicking an HTML file or a localhost URL in the
|
|
8
|
+
// chat stream calls Clacky.Preview.open(), which opens the aside, activates
|
|
9
|
+
// this tab and (re)loads the page.
|
|
10
|
+
//
|
|
11
|
+
// Host-origin pages (proxied workspace files and proxied dev servers) are
|
|
12
|
+
// sandboxed WITHOUT allow-same-origin so AI-generated pages can run
|
|
13
|
+
// scripts/forms/popups but never touch the host origin (cookies, localStorage,
|
|
14
|
+
// DOM). Third-party URLs loaded directly (e.g. a Vite dev server) get
|
|
15
|
+
// allow-same-origin added, otherwise their ES-module imports fail and the
|
|
16
|
+
// frame stays blank. Because the sandbox hides load errors, the panel HEADs
|
|
17
|
+
// the URL first and shows a friendly 404 card instead of a blank frame.
|
|
18
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
(() => {
|
|
21
|
+
if (!window.Clacky || !Clacky.ext) return;
|
|
22
|
+
|
|
23
|
+
const PREVIEW_ASIDE_WIDTH = 620;
|
|
24
|
+
const SANDBOX = "allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals";
|
|
25
|
+
|
|
26
|
+
const ICON_GLOBE = '<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>';
|
|
27
|
+
const ICON_BACK = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>';
|
|
28
|
+
const ICON_FORWARD = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>';
|
|
29
|
+
const ICON_RELOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/></svg>';
|
|
30
|
+
const ICON_GO = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>';
|
|
31
|
+
const ICON_EXTERNAL = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/></svg>';
|
|
32
|
+
|
|
33
|
+
const t = (k, fallback) => {
|
|
34
|
+
const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
|
|
35
|
+
return (v && v !== k) ? v : fallback;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function el(tag, attrs, ...kids) {
|
|
39
|
+
const node = document.createElement(tag);
|
|
40
|
+
if (attrs) {
|
|
41
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
42
|
+
if (k === "class") node.className = v;
|
|
43
|
+
else if (k === "text") node.textContent = v;
|
|
44
|
+
else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v);
|
|
45
|
+
else node.setAttribute(k, v);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
kids.forEach((c) => node.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
|
|
49
|
+
return node;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!document.getElementById("preview-panel-style")) {
|
|
53
|
+
const style = document.createElement("style");
|
|
54
|
+
style.id = "preview-panel-style";
|
|
55
|
+
style.textContent = `
|
|
56
|
+
.preview-root { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
57
|
+
.preview-bar { flex: none; display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--color-border-secondary); }
|
|
58
|
+
.preview-nav-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
|
|
59
|
+
.preview-nav-btn:hover:not(:disabled) { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
60
|
+
.preview-nav-btn:disabled { opacity: .35; cursor: default; }
|
|
61
|
+
.preview-address-wrap { flex: 1; min-width: 0; position: relative; display: flex; align-items: center; }
|
|
62
|
+
.preview-address { width: 100%; height: 27px; padding: 0 32px 0 14px; border: none; border-radius: 14px; background: var(--color-bg-secondary); color: var(--color-text-primary); font-size: 12px; font-family: ui-monospace, monospace; text-align: center; transition: box-shadow var(--transition-fast), background var(--transition-fast); }
|
|
63
|
+
.preview-address::placeholder { color: var(--color-text-tertiary); font-family: -apple-system, sans-serif; }
|
|
64
|
+
.preview-address:hover:not(:focus) { box-shadow: 0 0 0 1px var(--color-border-strong); }
|
|
65
|
+
.preview-address:focus { outline: none; box-shadow: 0 0 0 1px var(--color-accent-primary); background: var(--color-bg-primary); }
|
|
66
|
+
.preview-go { position: absolute; right: 5px; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; border: none; border-radius: 50%; background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
|
|
67
|
+
.preview-go:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
68
|
+
.preview-icon-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
|
|
69
|
+
.preview-icon-btn:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
70
|
+
.preview-frame-wrap { flex: 1; min-height: 0; overflow: hidden; }
|
|
71
|
+
.preview-frame { display: block; width: 100%; height: 100%; border: none; background: #fff; }
|
|
72
|
+
.preview-empty, .preview-notfound { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 24px; text-align: center; }
|
|
73
|
+
.preview-empty-icon { color: var(--color-text-tertiary); opacity: .5; margin-bottom: 4px; }
|
|
74
|
+
.preview-empty-title { font-size: 15px; font-weight: 600; color: var(--color-text-primary); }
|
|
75
|
+
.preview-empty-sub { font-size: 12px; color: var(--color-text-tertiary); max-width: 240px; line-height: 1.5; }
|
|
76
|
+
.preview-notfound-title { font-size: 13px; color: var(--color-text-secondary); }
|
|
77
|
+
.preview-retry { margin-top: 4px; padding: 6px 14px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: transparent; color: var(--color-text-primary); font-size: 12px; cursor: pointer; }
|
|
78
|
+
.preview-retry:hover { background: var(--color-bg-hover); }
|
|
79
|
+
`;
|
|
80
|
+
document.head.appendChild(style);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Panel state ────────────────────────────────────────────────────────
|
|
84
|
+
// The tab body is re-rendered on every session switch; these refs live at
|
|
85
|
+
// module level so the eager onAttach subscription can route an event into
|
|
86
|
+
// a not-yet-rendered body via `pending`.
|
|
87
|
+
let els = null; // live DOM refs while the tab body is rendered
|
|
88
|
+
let activeSessionId = null;
|
|
89
|
+
let currentRel = null;
|
|
90
|
+
let backStack = []; // rels reachable via back, oldest → newest
|
|
91
|
+
let forwardStack = []; // rels reachable via forward, newest → oldest
|
|
92
|
+
let pending = null; // { sid, rel } arrived before that body rendered
|
|
93
|
+
let releaseAsideWidth = null; // set while the aside is widened for the preview
|
|
94
|
+
|
|
95
|
+
function isExternal(rel) {
|
|
96
|
+
return /^https?:\/\//i.test(rel);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// /Users/... (bare absolute path) or file:///Users/... — a local file
|
|
100
|
+
// addressed by absolute path, served via /preview/f/ instead of the
|
|
101
|
+
// workspace route.
|
|
102
|
+
function isFile(rel) {
|
|
103
|
+
return /^file:\/\//i.test(rel) || /^\//.test(rel);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function filePath(rel) {
|
|
107
|
+
if (/^file:\/\//i.test(rel)) {
|
|
108
|
+
try {
|
|
109
|
+
let p = new URL(rel).pathname;
|
|
110
|
+
try { p = decodeURIComponent(p); } catch (_e) { /* keep encoded */ }
|
|
111
|
+
return p;
|
|
112
|
+
} catch (_e) {
|
|
113
|
+
return rel.replace(/^file:\/\//i, "");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return rel;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// True when the URL is served from the host origin through the preview
|
|
120
|
+
// proxy (workspace files /preview/s/ or dev servers /preview/p/). These
|
|
121
|
+
// pages must stay sandboxed without allow-same-origin so AI-generated
|
|
122
|
+
// content can never touch the host's cookies / localStorage / DOM.
|
|
123
|
+
function isProxied(url) {
|
|
124
|
+
return url.startsWith("/preview/s/") || url.startsWith("/preview/p/") || url.startsWith("/preview/f/");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A localhost / 127.0.0.1 dev server is opened through the host's proxy
|
|
128
|
+
// (/preview/p/<port>/<path>) so pages that set X-Frame-Options still render
|
|
129
|
+
// in the sandboxed iframe. Everything else loads straight into the iframe.
|
|
130
|
+
function proxyUrlFor(rel) {
|
|
131
|
+
try {
|
|
132
|
+
const u = new URL(rel);
|
|
133
|
+
const host = u.hostname.toLowerCase();
|
|
134
|
+
if (host !== "localhost" && host !== "127.0.0.1") return null;
|
|
135
|
+
const port = u.port || "80";
|
|
136
|
+
return `/preview/p/${port}${u.pathname}${u.search}`;
|
|
137
|
+
} catch (_e) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildUrl(sid, rel) {
|
|
143
|
+
if (isExternal(rel)) return proxyUrlFor(rel) || rel;
|
|
144
|
+
if (isFile(rel)) {
|
|
145
|
+
const enc = filePath(rel).split("/").map(encodeURIComponent).join("/");
|
|
146
|
+
return "/preview/f" + enc;
|
|
147
|
+
}
|
|
148
|
+
const enc = rel.split("/").map(encodeURIComponent).join("/");
|
|
149
|
+
return `/preview/s/${encodeURIComponent(sid)}/${enc}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The address bar shows workspace files as their full /preview/s/ URL so
|
|
153
|
+
// the user can see where the file lives; dev-server rels already carry
|
|
154
|
+
// their own http(s) URL.
|
|
155
|
+
function displayUrl(rel) {
|
|
156
|
+
if (isExternal(rel) || isFile(rel)) return rel;
|
|
157
|
+
return location.origin + buildUrl(activeSessionId, rel);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function cleanRel(input) {
|
|
161
|
+
const s = String(input || "").trim().replace(/\\/g, "/");
|
|
162
|
+
return /^\//.test(s) ? s : s.replace(/^\.\//, "");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The preview needs horizontal room, so the aside is widened while a page is
|
|
166
|
+
// shown. Same contract as the changes panel: borrowed space, given back on
|
|
167
|
+
// close, never persisted over the user's own width.
|
|
168
|
+
function ensureAsideWidth() {
|
|
169
|
+
if (releaseAsideWidth || !window.Clacky || !Clacky.Aside) return;
|
|
170
|
+
releaseAsideWidth = Clacky.Aside.requestWidth(PREVIEW_ASIDE_WIDTH);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function restoreAsideWidth() {
|
|
174
|
+
if (!releaseAsideWidth) return;
|
|
175
|
+
const release = releaseAsideWidth;
|
|
176
|
+
releaseAsideWidth = null;
|
|
177
|
+
release();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function showOverlay(which) {
|
|
181
|
+
if (!els) return;
|
|
182
|
+
els.empty.style.display = which === "empty" ? "flex" : "none";
|
|
183
|
+
els.notFound.style.display = which === "notFound" ? "flex" : "none";
|
|
184
|
+
els.unreachable.style.display = which === "unreachable" ? "flex" : "none";
|
|
185
|
+
els.frame.style.display = which ? "none" : "block";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function syncAddress(rel) {
|
|
189
|
+
if (!els) return;
|
|
190
|
+
if (document.activeElement === els.address) return;
|
|
191
|
+
els.address.value = rel ? displayUrl(rel) : "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function updateNavButtons() {
|
|
195
|
+
if (!els) return;
|
|
196
|
+
els.backBtn.disabled = backStack.length === 0;
|
|
197
|
+
els.forwardBtn.disabled = forwardStack.length === 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Follows in-frame navigation. A sandboxed frame without allow-same-origin
|
|
201
|
+
// cannot be inspected: contentWindow.location is sealed and the src property
|
|
202
|
+
// does not follow internal navigations. Instead the server injects a
|
|
203
|
+
// reporter into every preview HTML page that announces its URL on load, so
|
|
204
|
+
// link clicks inside the frame keep the address bar and the back/forward
|
|
205
|
+
// stacks in sync with what is actually on screen.
|
|
206
|
+
function relFromUrl(url) {
|
|
207
|
+
try {
|
|
208
|
+
const u = new URL(url, location.href);
|
|
209
|
+
const m = u.pathname.match(/^\/preview\/s\/[^/]+\/(.*)$/);
|
|
210
|
+
if (!m || !m[1]) return null;
|
|
211
|
+
return m[1].split("/").map(decodeURIComponent).join("/");
|
|
212
|
+
} catch (_e) { return null; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!window.__clackyPreviewNav) {
|
|
216
|
+
window.__clackyPreviewNav = true;
|
|
217
|
+
window.addEventListener("message", (e) => {
|
|
218
|
+
const data = e.data;
|
|
219
|
+
if (!data || data.__clackyPreview !== 1) return;
|
|
220
|
+
if (els && els.iframe && e.source !== els.iframe.contentWindow) return;
|
|
221
|
+
const rel = relFromUrl(data.href);
|
|
222
|
+
if (!rel || rel === currentRel) return;
|
|
223
|
+
if (currentRel) backStack.push(currentRel);
|
|
224
|
+
forwardStack = [];
|
|
225
|
+
currentRel = rel;
|
|
226
|
+
syncAddress(rel);
|
|
227
|
+
updateNavButtons();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A brand-new element is the only reload primitive that works uniformly:
|
|
232
|
+
// re-assigning an identical src is a no-op, and contentWindow is sealed by
|
|
233
|
+
// the sandbox.
|
|
234
|
+
function freshFrame(url, opaque) {
|
|
235
|
+
const sandbox = opaque ? SANDBOX : (SANDBOX + " allow-same-origin");
|
|
236
|
+
const f = el("iframe", {
|
|
237
|
+
class: "preview-frame",
|
|
238
|
+
sandbox: sandbox,
|
|
239
|
+
referrerpolicy: "no-referrer",
|
|
240
|
+
title: t("preview.tab", "预览"),
|
|
241
|
+
});
|
|
242
|
+
els.frame.replaceChildren(f);
|
|
243
|
+
els.iframe = f;
|
|
244
|
+
f.src = url;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function openRel(sid, rel, record) {
|
|
248
|
+
rel = cleanRel(rel);
|
|
249
|
+
if (!rel || !sid) return;
|
|
250
|
+
|
|
251
|
+
if (record) {
|
|
252
|
+
if (currentRel && currentRel !== rel) backStack.push(currentRel);
|
|
253
|
+
forwardStack = [];
|
|
254
|
+
}
|
|
255
|
+
currentRel = rel;
|
|
256
|
+
if (els && activeSessionId === sid) {
|
|
257
|
+
syncAddress(rel);
|
|
258
|
+
updateNavButtons();
|
|
259
|
+
} else {
|
|
260
|
+
pending = { sid: sid, rel: rel };
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const url = buildUrl(sid, rel);
|
|
265
|
+
let frameUrl = url;
|
|
266
|
+
// Proxied URLs (workspace files and dev servers) are same-origin, so they
|
|
267
|
+
// can be HEAD-probed. A non-200 from the proxy means the dev server is
|
|
268
|
+
// down — show a friendly "unreachable" card instead of the raw text in
|
|
269
|
+
// the frame. Truly cross-origin URLs skip the probe (CORS blocks HEAD).
|
|
270
|
+
if (isProxied(url)) {
|
|
271
|
+
try {
|
|
272
|
+
const res = await fetch(url, { method: "HEAD" });
|
|
273
|
+
if (res.status !== 200) {
|
|
274
|
+
if (currentRel === rel) {
|
|
275
|
+
showOverlay(url.startsWith("/preview/p/") ? "unreachable" : "notFound");
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// The proxy reports whether the upstream allows framing on its own.
|
|
280
|
+
// Vite-style dev servers send no X-Frame-Options, and their modules
|
|
281
|
+
// import by absolute path (/node_modules/.vite/deps/...), which the
|
|
282
|
+
// proxy cannot rewrite — for those, load the dev server directly so
|
|
283
|
+
// the ESM graph (and HMR websockets) resolves against the right origin.
|
|
284
|
+
if (url.startsWith("/preview/p/") && res.headers.get("X-Clacky-Upstream-Frameable") === "1") {
|
|
285
|
+
frameUrl = rel;
|
|
286
|
+
}
|
|
287
|
+
} catch (_e) { /* offline/race — try the iframe anyway */ }
|
|
288
|
+
}
|
|
289
|
+
if (currentRel !== rel || !els) return;
|
|
290
|
+
showOverlay(null);
|
|
291
|
+
syncAddress(rel);
|
|
292
|
+
ensureAsideWidth();
|
|
293
|
+
// Only third-party origins may self-identify: Vite-style dev servers
|
|
294
|
+
// need allow-same-origin for their ES-module graph to load. Proxy URLs
|
|
295
|
+
// (/preview/p/, /preview/s/) stay on the host origin and must remain
|
|
296
|
+
// opaque so AI pages can never touch the host's cookies or localStorage.
|
|
297
|
+
freshFrame(frameUrl, isProxied(frameUrl));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function goBack() {
|
|
301
|
+
const rel = backStack.pop();
|
|
302
|
+
if (!rel || !activeSessionId) return;
|
|
303
|
+
if (currentRel) forwardStack.push(currentRel);
|
|
304
|
+
openRel(activeSessionId, rel, false);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function goForward() {
|
|
308
|
+
const rel = forwardStack.pop();
|
|
309
|
+
if (!rel || !activeSessionId) return;
|
|
310
|
+
if (currentRel) backStack.push(currentRel);
|
|
311
|
+
openRel(activeSessionId, rel, false);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function reloadCurrent() {
|
|
315
|
+
if (activeSessionId && currentRel) openRel(activeSessionId, currentRel, false);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function openExternal() {
|
|
319
|
+
if (!activeSessionId || !currentRel) return;
|
|
320
|
+
// External URLs open as-is: the proxy strips X-Frame-Options only to
|
|
321
|
+
// satisfy the iframe, a fresh tab needs no such workaround. Bare
|
|
322
|
+
// absolute paths get a file:// prefix for the new tab.
|
|
323
|
+
let url;
|
|
324
|
+
if (isExternal(currentRel)) {
|
|
325
|
+
url = currentRel;
|
|
326
|
+
} else if (isFile(currentRel)) {
|
|
327
|
+
url = /^file:\/\//i.test(currentRel) ? currentRel : "file://" + currentRel;
|
|
328
|
+
} else {
|
|
329
|
+
url = buildUrl(activeSessionId, currentRel);
|
|
330
|
+
}
|
|
331
|
+
window.open(url, "_blank", "noopener");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
Clacky.ext.ui.mount("session.aside", (container, ctx) => {
|
|
335
|
+
if (!ctx || !ctx.sessionId) return;
|
|
336
|
+
|
|
337
|
+
const address = el("input", {
|
|
338
|
+
class: "preview-address", type: "text",
|
|
339
|
+
placeholder: t("preview.address", "输入本地文件路径或 localhost 地址"),
|
|
340
|
+
"aria-label": t("preview.address", "输入本地文件路径或 localhost 地址"),
|
|
341
|
+
spellcheck: "false",
|
|
342
|
+
});
|
|
343
|
+
const submitAddress = () => {
|
|
344
|
+
// The bar shows a full URL; map /preview/s/<sid>/<rel> back to the rel
|
|
345
|
+
// the rest of the panel navigates by, and leave everything else as-is.
|
|
346
|
+
openRel(ctx.sessionId, relFromUrl(address.value) || address.value, true);
|
|
347
|
+
address.blur();
|
|
348
|
+
};
|
|
349
|
+
address.addEventListener("keydown", (e) => {
|
|
350
|
+
if (e.key === "Enter") submitAddress();
|
|
351
|
+
});
|
|
352
|
+
address.addEventListener("focus", () => address.select());
|
|
353
|
+
|
|
354
|
+
const goBtn = el("button", {
|
|
355
|
+
class: "preview-go", type: "button",
|
|
356
|
+
title: t("preview.go", "前往"), onclick: submitAddress,
|
|
357
|
+
});
|
|
358
|
+
goBtn.innerHTML = ICON_GO;
|
|
359
|
+
const addressWrap = el("div", { class: "preview-address-wrap" }, address, goBtn);
|
|
360
|
+
|
|
361
|
+
const backBtn = el("button", {
|
|
362
|
+
class: "preview-nav-btn", type: "button",
|
|
363
|
+
title: t("preview.back", "后退"), onclick: goBack,
|
|
364
|
+
});
|
|
365
|
+
backBtn.innerHTML = ICON_BACK;
|
|
366
|
+
|
|
367
|
+
const forwardBtn = el("button", {
|
|
368
|
+
class: "preview-nav-btn", type: "button",
|
|
369
|
+
title: t("preview.forward", "前进"), onclick: goForward,
|
|
370
|
+
});
|
|
371
|
+
forwardBtn.innerHTML = ICON_FORWARD;
|
|
372
|
+
|
|
373
|
+
const reloadBtn = el("button", {
|
|
374
|
+
class: "preview-nav-btn", type: "button",
|
|
375
|
+
title: t("preview.reload", "刷新"), onclick: reloadCurrent,
|
|
376
|
+
});
|
|
377
|
+
reloadBtn.innerHTML = ICON_RELOAD;
|
|
378
|
+
|
|
379
|
+
const extBtn = el("button", {
|
|
380
|
+
class: "preview-icon-btn", type: "button",
|
|
381
|
+
title: t("preview.openExternal", "在新标签页打开"), onclick: openExternal,
|
|
382
|
+
});
|
|
383
|
+
extBtn.innerHTML = ICON_EXTERNAL;
|
|
384
|
+
|
|
385
|
+
const bar = el("div", { class: "preview-bar" }, backBtn, forwardBtn, reloadBtn, addressWrap, extBtn);
|
|
386
|
+
const frame = el("div", { class: "preview-frame-wrap" });
|
|
387
|
+
|
|
388
|
+
const notFound = el("div", { class: "preview-notfound" },
|
|
389
|
+
el("div", { class: "preview-notfound-title", text: t("preview.notFound", "文件不存在") }),
|
|
390
|
+
el("button", { class: "preview-retry", type: "button",
|
|
391
|
+
text: t("preview.reload", "刷新"), onclick: reloadCurrent }));
|
|
392
|
+
|
|
393
|
+
const unreachable = el("div", { class: "preview-notfound" },
|
|
394
|
+
el("div", { class: "preview-notfound-title", text: t("preview.unreachable", "无法连接到开发服务器") }),
|
|
395
|
+
el("button", { class: "preview-retry", type: "button",
|
|
396
|
+
text: t("preview.reload", "刷新"), onclick: reloadCurrent }));
|
|
397
|
+
|
|
398
|
+
const emptyIcon = el("div", { class: "preview-empty-icon" });
|
|
399
|
+
emptyIcon.innerHTML = ICON_GLOBE;
|
|
400
|
+
const empty = el("div", { class: "preview-empty" },
|
|
401
|
+
emptyIcon,
|
|
402
|
+
el("div", { class: "preview-empty-title", text: t("preview.empty.title", "开始浏览") }),
|
|
403
|
+
el("div", { class: "preview-empty-sub",
|
|
404
|
+
text: t("preview.empty.sub", "输入本地文件路径或 localhost 地址以打开页面") }));
|
|
405
|
+
|
|
406
|
+
const root = el("div", { class: "preview-root", "data-panel": "preview" },
|
|
407
|
+
bar, frame, notFound, unreachable, empty);
|
|
408
|
+
container.appendChild(root);
|
|
409
|
+
|
|
410
|
+
els = {
|
|
411
|
+
frame: frame, iframe: null,
|
|
412
|
+
empty: empty, notFound: notFound, unreachable: unreachable,
|
|
413
|
+
address: address,
|
|
414
|
+
backBtn: backBtn, forwardBtn: forwardBtn,
|
|
415
|
+
};
|
|
416
|
+
activeSessionId = ctx.sessionId;
|
|
417
|
+
syncAddress(currentRel);
|
|
418
|
+
updateNavButtons();
|
|
419
|
+
|
|
420
|
+
if (pending && pending.sid === ctx.sessionId) {
|
|
421
|
+
const rel = pending.rel;
|
|
422
|
+
pending = null;
|
|
423
|
+
openRel(ctx.sessionId, rel, false);
|
|
424
|
+
} else if (currentRel) {
|
|
425
|
+
openRel(ctx.sessionId, currentRel, false);
|
|
426
|
+
} else {
|
|
427
|
+
showOverlay("empty");
|
|
428
|
+
}
|
|
429
|
+
}, {
|
|
430
|
+
order: 50,
|
|
431
|
+
tab: {
|
|
432
|
+
id: "preview",
|
|
433
|
+
label: () => t("preview.tab", "预览"),
|
|
434
|
+
// Eager onAttach: runs before the tab is ever opened by hand, and only
|
|
435
|
+
// handles session-switch cleanup — restoring the borrowed aside width
|
|
436
|
+
// and resetting the preview state for the next session.
|
|
437
|
+
onAttach(ctx) {
|
|
438
|
+
if (!ctx || !ctx.sessionId) return;
|
|
439
|
+
const sid = ctx.sessionId;
|
|
440
|
+
return () => {
|
|
441
|
+
restoreAsideWidth();
|
|
442
|
+
// A same-session re-render keeps the preview state; only a real
|
|
443
|
+
// session switch starts the next workspace from a clean slate.
|
|
444
|
+
const switchedAway = !(Clacky.ext.context && Clacky.ext.context.sessionId === sid);
|
|
445
|
+
if (switchedAway) {
|
|
446
|
+
currentRel = null;
|
|
447
|
+
backStack = [];
|
|
448
|
+
forwardStack = [];
|
|
449
|
+
if (pending && pending.sid === sid) pending = null;
|
|
450
|
+
}
|
|
451
|
+
if (activeSessionId === sid) {
|
|
452
|
+
els = null;
|
|
453
|
+
activeSessionId = null;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Programmatic entry point used by the host (the workspace viewer and the
|
|
461
|
+
// chat link interceptor): open a workspace-relative HTML path or an http(s)
|
|
462
|
+
// URL. Returns false when there is no active session.
|
|
463
|
+
if (window.Clacky) {
|
|
464
|
+
Clacky.Preview = {
|
|
465
|
+
open(rel) {
|
|
466
|
+
const sid = Clacky.ext.context && Clacky.ext.context.sessionId;
|
|
467
|
+
if (!sid || !rel) return false;
|
|
468
|
+
openRel(sid, rel, true);
|
|
469
|
+
if (window.Clacky && Clacky.Aside) Clacky.Aside.open();
|
|
470
|
+
Clacky.ext.activateTab("session.aside", "preview");
|
|
471
|
+
return true;
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
})();
|
|
@@ -211,16 +211,7 @@
|
|
|
211
211
|
// The diff pane needs horizontal room; widen the aside once when a diff is
|
|
212
212
|
// first opened (same trick as the Files/Git tabs, still user-resizable).
|
|
213
213
|
function ensureAsideWidth() {
|
|
214
|
-
|
|
215
|
-
if (!aside) return;
|
|
216
|
-
let w = 0;
|
|
217
|
-
try {
|
|
218
|
-
w = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width")) || 0;
|
|
219
|
-
} catch (_e) { w = 0; }
|
|
220
|
-
if (w < DIFF_ASIDE_WIDTH) {
|
|
221
|
-
aside.style.setProperty("--session-aside-width", DIFF_ASIDE_WIDTH + "px");
|
|
222
|
-
try { localStorage.setItem("clacky.aside.width", String(DIFF_ASIDE_WIDTH)); } catch (_e) { /* non-fatal */ }
|
|
223
|
-
}
|
|
214
|
+
if (window.Clacky && Clacky.Aside) Clacky.Aside.requestWidth(DIFF_ASIDE_WIDTH, { persist: true });
|
|
224
215
|
}
|
|
225
216
|
|
|
226
217
|
function renderTimeline(state) {
|
|
@@ -22,7 +22,7 @@ module Clacky
|
|
|
22
22
|
PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze
|
|
23
23
|
API_KEYS = %w[id handler].freeze
|
|
24
24
|
SKILL_KEYS = %w[id dir protected].freeze
|
|
25
|
-
AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills avatar hidden tools].freeze
|
|
25
|
+
AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills disabled_skills avatar hidden tools].freeze
|
|
26
26
|
CHANNEL_KEYS = %w[id platform adapter].freeze
|
|
27
27
|
PATCH_KEYS = %w[target file fingerprint on_mismatch].freeze
|
|
28
28
|
HOOK_KEYS = %w[event file].freeze
|