@uniqbit/mate-core 0.18.4 → 1.0.0-canary.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.
- package/claude-plugin/hooks/companion-instructions.mjs +9 -0
- package/claude-plugin/hooks/hooks.json +12 -2
- package/opencode-plugin/src/add-dir.ts +76 -10
- package/opencode-plugin/src/companion.ts +1 -8
- package/opencode-plugin/src/server.ts +2 -15
- package/package.json +10 -2
- package/src/brand/art.generated.ts +69 -0
- package/src/brand/index.ts +12 -0
- package/src/brand/palette.ts +19 -0
- package/src/brand/svg.generated.ts +8 -0
- package/src/brand/terminal.ts +83 -0
- package/src/brand/types.ts +33 -0
- package/src/cli/commands/artifact/artifact.ts +8 -0
- package/src/cli/commands/artifact/delivery/archive.ts +117 -0
- package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
- package/src/cli/commands/artifact/delivery/environment.ts +104 -0
- package/src/cli/commands/artifact/delivery/link.ts +154 -0
- package/src/cli/commands/artifact/delivery/output.ts +58 -0
- package/src/cli/commands/artifact/finish/command.ts +33 -3
- package/src/cli/commands/artifact/finish/engine.ts +47 -3
- package/src/cli/commands/artifact/finish/finisher.ts +10 -0
- package/src/cli/commands/artifact/finish/openspec.ts +200 -129
- package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
- package/src/cli/commands/artifact/pending/command.ts +31 -3
- package/src/cli/commands/artifact/pending/discovery.ts +21 -57
- package/src/cli/commands/cap/graphify.ts +52 -3
- package/src/cli/commands/cap/index-cmd.ts +8 -1
- package/src/cli/commands/cap/openspec.ts +13 -25
- package/src/cli/commands/companion/open.ts +56 -39
- package/src/cli/commands/companion/update.ts +75 -42
- package/src/cli/commands/doctor.ts +257 -78
- package/src/cli/commands/install.ts +0 -8
- package/src/cli/commands/plugin/install.ts +9 -4
- package/src/cli/commands/plugin/plugin.ts +3 -2
- package/src/cli/commands/report/adapter.ts +11 -2
- package/src/cli/commands/report/contract.ts +21 -0
- package/src/cli/commands/report/highlight.ts +34 -4
- package/src/cli/commands/report/markdown.ts +110 -0
- package/src/cli/commands/report/palette.ts +118 -0
- package/src/cli/commands/report/renderer.ts +456 -153
- package/src/cli/commands/report/types.ts +16 -0
- package/src/cli/commands/setup.ts +7 -15
- package/src/cli/commands/studio/client-asset.ts +16 -0
- package/src/cli/commands/studio/openspec-cli.ts +10 -58
- package/src/cli/commands/studio/routes.ts +2 -0
- package/src/cli/commands/studio/selection.ts +19 -1
- package/src/cli/commands/studio/server.ts +42 -1
- package/src/cli/commands/studio/terminal-launch.ts +7 -16
- package/src/cli/commands/studio/views/client.ts +6 -616
- package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
- package/src/cli/commands/studio/views/document.tsx +137 -69
- package/src/cli/commands/studio/views/markdown.tsx +250 -0
- package/src/cli/commands/studio/views/palette.ts +134 -0
- package/src/cli/commands/studio/views/reports/index.tsx +2 -7
- package/src/cli/commands/studio/views/skills/index.tsx +43 -62
- package/src/cli/commands/studio/views/specs/index.tsx +7 -15
- package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
- package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
- package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
- package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
- package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
- package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
- package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
- package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
- package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
- package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
- package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
- package/src/cli/commands/studio/views/styles.ts +217 -206
- package/src/cli/commands/studio/views/vault/state.ts +3 -5
- package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
- package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
- package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
- package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
- package/src/cli/commands/wrap.ts +9 -42
- package/src/cli/main.ts +4 -1
- package/src/cli/parse-flags.ts +25 -7
- package/src/cli/setup-selector.tsx +38 -162
- package/src/cli/usage.ts +5 -2
- package/src/hooks/companion-instructions.ts +190 -0
- package/src/hooks/hook-io.ts +63 -0
- package/src/hooks/session-banner.ts +21 -29
- package/src/hooks/session-guidance.ts +10 -36
- package/src/hooks/session-prepare.ts +115 -28
- package/src/hooks/validate-artifact-path.ts +82 -24
- package/src/index.ts +4 -0
- package/src/lib/change-layout.ts +154 -0
- package/src/lib/components/wizard-header.tsx +3 -1
- package/src/lib/context-mode-package.ts +24 -11
- package/src/lib/delivery/archive-change.ts +154 -0
- package/src/lib/delivery/branches.ts +108 -0
- package/src/lib/delivery/environment.ts +29 -0
- package/src/lib/delivery/forge.ts +279 -0
- package/src/lib/delivery/overlap.ts +202 -0
- package/src/lib/delivery/record-delivery.ts +196 -0
- package/src/lib/delivery/record.ts +343 -0
- package/src/lib/delivery/refresh.ts +174 -0
- package/src/lib/delivery/repository-access.ts +97 -0
- package/src/lib/delivery/stages.ts +111 -0
- package/src/lib/delivery/token.ts +58 -0
- package/src/lib/frontmatter.ts +18 -0
- package/src/lib/opencode-plugin-package.ts +5 -5
- package/src/lib/openspec-cli.ts +116 -0
- package/src/lib/openspec-scopes.ts +86 -0
- package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
- package/src/lib/orchestrator/adapters/claude.ts +60 -162
- package/src/lib/orchestrator/adapters/opencode.ts +9 -24
- package/src/lib/orchestrator/config-store.ts +2 -70
- package/src/lib/orchestrator/editor.ts +9 -9
- package/src/lib/orchestrator/openspec-store.ts +14 -54
- package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
- package/src/lib/orchestrator/projection-claude-links.ts +159 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +19 -190
- package/src/lib/orchestrator/projection-entries.ts +45 -15
- package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
- package/src/lib/orchestrator/projection-types.ts +3 -2
- package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
- package/src/lib/orchestrator/session-prepare.ts +49 -87
- package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
- package/src/lib/orchestrator/types.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +22 -88
- package/src/lib/package-paths.ts +8 -0
- package/src/opencode/companion-hooks.ts +64 -5
- package/src/opencode/companion-policy.ts +37 -18
- package/src/opencode/projected-guidance.ts +4 -1
- package/src/opencode/session-prepare.ts +2 -1
- package/src/opencode/tui.tsx +4 -2
- package/src/runtime/artifact-refusal.ts +63 -0
- package/src/runtime/claude-delivery-plan.ts +89 -7
- package/src/runtime/commit-attribution-guard.ts +181 -0
- package/src/runtime/companion-guidance.ts +15 -3
- package/src/runtime/companion-instructions.ts +340 -0
- package/src/runtime/error-message.ts +4 -0
- package/src/runtime/guidance.ts +0 -17
- package/src/runtime/index.ts +5 -0
- package/src/runtime/managed-block.ts +117 -0
- package/src/runtime/markdown-scan.ts +266 -0
- package/src/runtime/policy.ts +16 -15
- package/src/runtime/projected-flag.ts +7 -0
- package/src/runtime/projected-guidance.ts +1 -0
- package/src/runtime/runtime-document-manifest.ts +62 -28
- package/src/runtime/secret-guard.ts +148 -0
- package/src/runtime/secret-patterns.ts +164 -0
- package/src/runtime/secret-scan.ts +90 -0
- package/src/runtime/session-kind.ts +13 -0
- package/src/runtime/shell-heredoc.ts +81 -0
- package/src/runtime/wrap-state.ts +36 -6
- package/src/runtime/write-atomic.ts +64 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
- package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
- package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/openspec-references.ts +140 -0
- package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
- package/src/tools/setup/capabilities/context-mode.ts +25 -5
- package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
- package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
- package/src/tools/setup/capabilities/graphify.ts +12 -0
- package/src/tools/setup/capabilities/openspec.ts +273 -270
- package/src/tools/setup/capabilities/tokensave.ts +36 -3
- package/src/tools/setup/context-services.ts +9 -17
- package/src/tools/setup/dynamic-plugins/audiences.ts +7 -39
- package/src/tools/setup/dynamic-plugins/declarations.ts +1 -1
- package/src/tools/setup/dynamic-plugins/hydrate.ts +4 -7
- package/src/tools/setup/dynamic-plugins/install.ts +24 -28
- package/src/tools/setup/engine.ts +93 -148
- package/src/tools/setup/managed-output.ts +51 -0
- package/src/tools/setup/mate.ts +86 -11
- package/src/tools/setup/plugin.ts +37 -17
- package/src/tools/setup/plugins/gitignore.ts +1 -7
- package/src/tools/setup/plugins/guidance.ts +7 -4
- package/src/tools/setup/providers/agent-file-sections.ts +4 -2
- package/src/tools/setup/providers/claude-attribution.ts +65 -0
- package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
- package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
- package/src/tools/setup/providers/claude.ts +285 -34
- package/src/tools/setup/providers/opencode.ts +180 -11
- package/src/tools/setup/registry.ts +2 -24
- package/src/tools/setup/surface-target.ts +4 -13
- package/src/tools/setup/utils.ts +14 -4
- package/src/tools/setup.ts +131 -18
- package/wrappers/bin/graphify +52 -4
- package/wrappers/bin/openspec +33 -9
- package/wrappers/lib/graphify-gate.mjs +8 -0
- package/opencode-plugin/src/audience-layer.ts +0 -91
- package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
- package/src/runtime/audience-layer.ts +0 -84
- package/src/runtime/opencode-audience-layer.ts +0 -176
- package/src/tools/setup/audience-isolation.fixture.ts +0 -325
- package/src/tools/setup/audience-layer.ts +0 -207
- package/src/tools/setup/audience-registrations.ts +0 -52
- package/src/tools/setup/contribution-ledger.ts +0 -207
- package/src/tools/setup/isolation-diagnostics.ts +0 -139
- package/src/tools/setup/runtime-documents.ts +0 -291
- package/src/tools/setup/upgrade-sweep.ts +0 -153
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { REPORT_VIEW_PREFIX } from "../../routes";
|
|
2
|
+
import { currentTheme, setThemeListener } from "./theme";
|
|
3
|
+
|
|
4
|
+
interface ReportSummary {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function reportHash(): string | null {
|
|
11
|
+
const match = /(?:^|[#&])report=([0-9a-f]{32})/.exec(location.hash || "");
|
|
12
|
+
return match ? match[1]! : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Hosted reports: the list, the sandboxed frame, and the toast and badge for a new arrival. */
|
|
16
|
+
export function wireReports(): void {
|
|
17
|
+
setThemeListener(null);
|
|
18
|
+
const shell = document.querySelector("[data-reports-events-url]");
|
|
19
|
+
if (!shell || typeof EventSource === "undefined") return;
|
|
20
|
+
const current = shell.getAttribute("data-companion");
|
|
21
|
+
const listUrl = shell.getAttribute("data-reports-url");
|
|
22
|
+
const viewUrl = shell.getAttribute("data-reports-view-url");
|
|
23
|
+
const list = document.getElementById("reports-list");
|
|
24
|
+
const frame = document.getElementById("reports-frame") as HTMLIFrameElement | null;
|
|
25
|
+
const open = document.getElementById("reports-open") as HTMLAnchorElement | null;
|
|
26
|
+
const empty = document.getElementById("reports-empty");
|
|
27
|
+
const template = document.getElementById("reports-item-template") as HTMLTemplateElement | null;
|
|
28
|
+
const badge = document.getElementById("reports-badge");
|
|
29
|
+
const toast = document.getElementById("reports-toast");
|
|
30
|
+
const toastTitle = document.getElementById("reports-toast-title");
|
|
31
|
+
const toastOpen = document.getElementById("reports-toast-open") as HTMLAnchorElement | null;
|
|
32
|
+
let shown: string | null = null;
|
|
33
|
+
let toastTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
|
|
35
|
+
function show(id: string): void {
|
|
36
|
+
if (!frame) return;
|
|
37
|
+
shown = id;
|
|
38
|
+
const theme = currentTheme();
|
|
39
|
+
const src = `${REPORT_VIEW_PREFIX}${id}${theme === "system" ? "" : `?scheme=${theme}`}`;
|
|
40
|
+
frame.src = src;
|
|
41
|
+
frame.hidden = false;
|
|
42
|
+
if (open) {
|
|
43
|
+
open.href = src;
|
|
44
|
+
open.hidden = false;
|
|
45
|
+
}
|
|
46
|
+
if (list) {
|
|
47
|
+
list.querySelectorAll("button[data-report-id]").forEach((button) => {
|
|
48
|
+
button.setAttribute(
|
|
49
|
+
"aria-pressed",
|
|
50
|
+
button.getAttribute("data-report-id") === id ? "true" : "false",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
history.replaceState(null, "", `#report=${id}`);
|
|
56
|
+
} catch {
|
|
57
|
+
/* the address is a convenience */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function render(reports: ReportSummary[]): void {
|
|
62
|
+
const first = template && template.content.firstElementChild;
|
|
63
|
+
if (!list || !template || !first) return;
|
|
64
|
+
list.replaceChildren();
|
|
65
|
+
if (empty) empty.hidden = reports.length > 0;
|
|
66
|
+
for (const report of reports) {
|
|
67
|
+
const item = first.cloneNode(true) as Element;
|
|
68
|
+
const button = item.querySelector("button")!;
|
|
69
|
+
button.setAttribute("data-report-id", report.id);
|
|
70
|
+
button.setAttribute("aria-pressed", report.id === shown ? "true" : "false");
|
|
71
|
+
item.querySelector("[data-report-title]")!.textContent = report.title;
|
|
72
|
+
item.querySelector("[data-report-time]")!.textContent = new Date(
|
|
73
|
+
report.createdAt,
|
|
74
|
+
).toLocaleString();
|
|
75
|
+
button.addEventListener("click", () => show(report.id));
|
|
76
|
+
list.append(item);
|
|
77
|
+
}
|
|
78
|
+
if (reports.length === 0 || reports.some((report) => report.id === shown)) return;
|
|
79
|
+
const named = reportHash();
|
|
80
|
+
show(reports.some((report) => report.id === named) ? named! : reports[0]!.id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function refreshList(): void {
|
|
84
|
+
if (!list || !listUrl) return;
|
|
85
|
+
fetch(listUrl, { headers: { accept: "application/json" } })
|
|
86
|
+
.then((response) => {
|
|
87
|
+
if (!response.ok) throw new Error(`status ${response.status}`);
|
|
88
|
+
return response.json();
|
|
89
|
+
})
|
|
90
|
+
.then((body) => render(body.reports || []))
|
|
91
|
+
.catch(() => {
|
|
92
|
+
/* the next event or reconnect retries */
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function announceReport(event: { id: string; title: string }): void {
|
|
97
|
+
if (badge) badge.hidden = false;
|
|
98
|
+
if (!toast || !toastTitle) return;
|
|
99
|
+
toastTitle.textContent = `New report: ${event.title}`;
|
|
100
|
+
if (toastOpen && viewUrl) toastOpen.href = `${viewUrl}#report=${event.id}`;
|
|
101
|
+
toast.setAttribute("data-shown", "true");
|
|
102
|
+
clearTimeout(toastTimeout);
|
|
103
|
+
toastTimeout = setTimeout(() => toast.setAttribute("data-shown", "false"), 10000);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setThemeListener(() => {
|
|
107
|
+
if (shown) show(shown);
|
|
108
|
+
});
|
|
109
|
+
const events = new EventSource(shell.getAttribute("data-reports-events-url")!);
|
|
110
|
+
events.addEventListener("open", refreshList);
|
|
111
|
+
events.addEventListener("message", (message) => {
|
|
112
|
+
let event;
|
|
113
|
+
try {
|
|
114
|
+
event = JSON.parse(message.data);
|
|
115
|
+
} catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!event || event.type !== "report-published" || event.companion !== current) return;
|
|
119
|
+
if (list) refreshList();
|
|
120
|
+
else announceReport(event);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { THEME_STORAGE_KEY } from "./keys";
|
|
2
|
+
|
|
3
|
+
export type Theme = "system" | "dark" | "light";
|
|
4
|
+
|
|
5
|
+
const THEME_CYCLE: Theme[] = ["system", "dark", "light"];
|
|
6
|
+
|
|
7
|
+
function readTheme(): Theme {
|
|
8
|
+
try {
|
|
9
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null;
|
|
10
|
+
return stored && THEME_CYCLE.includes(stored) ? stored : "system";
|
|
11
|
+
} catch {
|
|
12
|
+
return "system";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function storeTheme(theme: Theme): void {
|
|
17
|
+
try {
|
|
18
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
19
|
+
} catch {
|
|
20
|
+
/* a blocked web store only costs the preference, never the page */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let theme: Theme = "system";
|
|
25
|
+
/** Set by the Reports view to reload the shown report in the new appearance. */
|
|
26
|
+
let onThemeChange: (() => void) | null = null;
|
|
27
|
+
|
|
28
|
+
export function currentTheme(): Theme {
|
|
29
|
+
return theme;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setThemeListener(listener: (() => void) | null): void {
|
|
33
|
+
onThemeChange = listener;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function applyTheme(next: Theme): void {
|
|
37
|
+
theme = next;
|
|
38
|
+
if (onThemeChange) onThemeChange();
|
|
39
|
+
const root = document.documentElement;
|
|
40
|
+
if (next === "system") root.removeAttribute("data-theme");
|
|
41
|
+
else root.setAttribute("data-theme", next);
|
|
42
|
+
const control = document.getElementById("studio-theme");
|
|
43
|
+
if (control) {
|
|
44
|
+
control.textContent = `Theme: ${next}`;
|
|
45
|
+
control.setAttribute("data-theme-state", next);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Applies the remembered appearance and lets the control cycle it. */
|
|
50
|
+
export function wireTheme(): void {
|
|
51
|
+
onThemeChange = null;
|
|
52
|
+
applyTheme(readTheme());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function wireThemeToggle(): void {
|
|
56
|
+
const toggle = document.getElementById("studio-theme");
|
|
57
|
+
if (!toggle) return;
|
|
58
|
+
toggle.addEventListener("click", () => {
|
|
59
|
+
const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length]!;
|
|
60
|
+
applyTheme(next);
|
|
61
|
+
storeTheme(next);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { dirty, editor, pendingSave, setPendingSave } from "./page";
|
|
2
|
+
|
|
3
|
+
interface IncomingVersion {
|
|
4
|
+
content?: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
kind?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** What a mounted viewer asks of the pane that hosts it. */
|
|
10
|
+
export interface ViewerHooks {
|
|
11
|
+
/** A save was applied: the file should be shown rendered. */
|
|
12
|
+
saved: () => void;
|
|
13
|
+
/** The file changed on disk while shown rendered: show its current content. */
|
|
14
|
+
changed: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function status(message: string): void {
|
|
18
|
+
const node = document.getElementById("vault-status");
|
|
19
|
+
if (node) node.textContent = message;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setIncoming(event: IncomingVersion): void {
|
|
23
|
+
const box = document.getElementById("vault-incoming");
|
|
24
|
+
const content = document.getElementById("vault-incoming-content");
|
|
25
|
+
const recover = document.getElementById("vault-recover");
|
|
26
|
+
if (!box || !content || !recover) return;
|
|
27
|
+
box.hidden = false;
|
|
28
|
+
content.textContent = event.content || "";
|
|
29
|
+
recover.setAttribute("data-vault-content", event.content || "");
|
|
30
|
+
recover.setAttribute("data-vault-token", event.token || "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function acceptIncoming(event: IncomingVersion): void {
|
|
34
|
+
const node = editor();
|
|
35
|
+
if (!node) return;
|
|
36
|
+
node.value = event.content || "";
|
|
37
|
+
node.setAttribute("data-vault-original", node.value);
|
|
38
|
+
if (event.token) node.setAttribute("data-vault-token", event.token);
|
|
39
|
+
const box = document.getElementById("vault-incoming");
|
|
40
|
+
if (box) box.hidden = true;
|
|
41
|
+
status("incoming version loaded into the editor");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function save(node: HTMLTextAreaElement, hooks: ViewerHooks): void {
|
|
45
|
+
if (pendingSave()) return;
|
|
46
|
+
const companion = document.querySelector("[data-companion]");
|
|
47
|
+
const body = {
|
|
48
|
+
companion: companion ? companion.getAttribute("data-companion") : "",
|
|
49
|
+
path: node.getAttribute("data-vault-path"),
|
|
50
|
+
content: node.value,
|
|
51
|
+
token: node.getAttribute("data-vault-token"),
|
|
52
|
+
};
|
|
53
|
+
const request = fetch("/api/vault/save", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
})
|
|
58
|
+
.then((response) =>
|
|
59
|
+
response.json().then((result: IncomingVersion & { reason?: string }) => {
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
if (response.status === 409) setIncoming(result);
|
|
62
|
+
status(result.reason || "save refused");
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
node.setAttribute("data-vault-token", result.token || "");
|
|
66
|
+
node.setAttribute("data-vault-original", node.value);
|
|
67
|
+
status("saved");
|
|
68
|
+
hooks.saved();
|
|
69
|
+
return true;
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
.catch(() => {
|
|
73
|
+
status("save failed");
|
|
74
|
+
return false;
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
setPendingSave(null);
|
|
78
|
+
});
|
|
79
|
+
setPendingSave(request);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The one stream the mounted file holds open, closed only by `unmountViewer`. */
|
|
83
|
+
let stream: EventSource | null = null;
|
|
84
|
+
|
|
85
|
+
/** Applies a version the file stream reports; a dirty editor keeps its text and offers the new one. */
|
|
86
|
+
function followStream(
|
|
87
|
+
eventsUrl: string,
|
|
88
|
+
node: HTMLTextAreaElement | null,
|
|
89
|
+
hooks: ViewerHooks,
|
|
90
|
+
): void {
|
|
91
|
+
const events = new EventSource(eventsUrl);
|
|
92
|
+
stream = events;
|
|
93
|
+
events.addEventListener("message", (message) => {
|
|
94
|
+
let event: IncomingVersion;
|
|
95
|
+
try {
|
|
96
|
+
event = JSON.parse(message.data);
|
|
97
|
+
} catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (event.kind === "removed") {
|
|
101
|
+
status("this file no longer exists on disk");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!node) {
|
|
105
|
+
hooks.changed();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (event.kind === "overwritten") {
|
|
109
|
+
setIncoming(event);
|
|
110
|
+
status("a version was overwritten; its content is available below");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (dirty()) {
|
|
114
|
+
setIncoming(event);
|
|
115
|
+
status("the file changed on disk; unsaved content is still in the editor");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
node.value = event.content || "";
|
|
119
|
+
node.setAttribute("data-vault-original", node.value);
|
|
120
|
+
if (event.token) node.setAttribute("data-vault-token", event.token);
|
|
121
|
+
status("updated from disk");
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Closes the file's event stream and forgets the save record; the pane's own nodes go with the pane. */
|
|
126
|
+
export function unmountViewer(): void {
|
|
127
|
+
if (stream) {
|
|
128
|
+
if (typeof stream.close === "function") stream.close();
|
|
129
|
+
stream = null;
|
|
130
|
+
}
|
|
131
|
+
setPendingSave(null);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Binds whatever file pane is on screen, opening exactly one event stream for its file. */
|
|
135
|
+
export function mountViewer(hooks: ViewerHooks): void {
|
|
136
|
+
unmountViewer();
|
|
137
|
+
const root = document.querySelector("[data-vault-root]");
|
|
138
|
+
if (!root) return;
|
|
139
|
+
const node = editor();
|
|
140
|
+
if (node) {
|
|
141
|
+
node.setAttribute("data-vault-original", node.value);
|
|
142
|
+
const saveButton = document.getElementById("vault-save");
|
|
143
|
+
if (saveButton) saveButton.addEventListener("click", () => save(node, hooks));
|
|
144
|
+
const recover = document.getElementById("vault-recover");
|
|
145
|
+
if (recover) {
|
|
146
|
+
recover.addEventListener("click", () => {
|
|
147
|
+
acceptIncoming({
|
|
148
|
+
content: recover.getAttribute("data-vault-content") || "",
|
|
149
|
+
token: recover.getAttribute("data-vault-token") || "",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const eventsUrl = root.getAttribute("data-vault-events");
|
|
155
|
+
if (eventsUrl && typeof EventSource !== "undefined") followStream(eventsUrl, node, hooks);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Asks the browser to confirm leaving while the editor holds unsaved text. */
|
|
159
|
+
export function wireUnloadGuard(): void {
|
|
160
|
+
if (typeof window === "undefined") return;
|
|
161
|
+
window.addEventListener("beforeunload", (event) => {
|
|
162
|
+
if (!dirty()) return;
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
event.returnValue = "";
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetchMarkup,
|
|
3
|
+
guard,
|
|
4
|
+
isPlainClick,
|
|
5
|
+
navigate,
|
|
6
|
+
setInPlaceNavigation,
|
|
7
|
+
wireNavigation,
|
|
8
|
+
} from "./page";
|
|
9
|
+
import { mountViewer, status, unmountViewer, type ViewerHooks } from "./vault-editor";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* History state this module owns. `vaultScope` marks an entry whose file pane can be swapped
|
|
13
|
+
* in, and names the companion it belongs to; `vaultScroll` is where the page was scrolled when
|
|
14
|
+
* the entry was left.
|
|
15
|
+
*/
|
|
16
|
+
interface PaneState {
|
|
17
|
+
vaultScope?: string;
|
|
18
|
+
vaultScroll?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function entry(offset: number): PaneState {
|
|
22
|
+
return { vaultScope: scope(), vaultScroll: offset };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let swapSequence = 0;
|
|
26
|
+
/** The address of the state on screen, so a declined history move can put it back. */
|
|
27
|
+
let shownUrl = "";
|
|
28
|
+
|
|
29
|
+
function layout(): Element | null {
|
|
30
|
+
return document.querySelector("[data-vault-layout]");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function scrollOffset(): number {
|
|
34
|
+
return typeof window !== "undefined" && typeof window.scrollY === "number" ? window.scrollY : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scrollTo(offset: number): void {
|
|
38
|
+
if (typeof window !== "undefined" && typeof window.scrollTo === "function") {
|
|
39
|
+
window.scrollTo(0, offset);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The pane fragment's address for a page address on this page's own route, else `null`. */
|
|
44
|
+
function paneAddress(url: string): string | null {
|
|
45
|
+
const route = layout()?.getAttribute("data-vault-pane-route");
|
|
46
|
+
if (!route) return null;
|
|
47
|
+
try {
|
|
48
|
+
const target = new URL(url, location.href);
|
|
49
|
+
const current = new URL(location.href);
|
|
50
|
+
if (target.origin !== current.origin || target.pathname !== current.pathname) return null;
|
|
51
|
+
const pane = new URL(route, location.href);
|
|
52
|
+
pane.search = target.search;
|
|
53
|
+
return pane.toString();
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The companion this page's vault belongs to; a history entry of another one is not ours to swap. */
|
|
60
|
+
function scope(): string {
|
|
61
|
+
return layout()?.getAttribute("data-vault-scope") ?? "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function markShown(): void {
|
|
65
|
+
const root = document.querySelector("[data-vault-root]");
|
|
66
|
+
const path = root ? root.getAttribute("data-vault-path") : null;
|
|
67
|
+
document.querySelectorAll("a[data-vault-entry]").forEach((link) => {
|
|
68
|
+
if (path && link.getAttribute("data-vault-entry") === path) {
|
|
69
|
+
link.setAttribute("aria-current", "page");
|
|
70
|
+
} else {
|
|
71
|
+
link.removeAttribute("aria-current");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Replaces the breadcrumb and pane with the server's, rewiring what lives in them. */
|
|
77
|
+
function applyPane(markup: string, offset: number): void {
|
|
78
|
+
const parsed = new DOMParser().parseFromString(markup, "text/html");
|
|
79
|
+
const crumb = parsed.querySelector("[data-vault-crumb]");
|
|
80
|
+
const pane = parsed.querySelector("[data-vault-pane]");
|
|
81
|
+
const shownCrumb = document.querySelector("[data-vault-crumb]");
|
|
82
|
+
const shownPane = document.querySelector("[data-vault-pane]");
|
|
83
|
+
if (!crumb || !pane || !shownCrumb || !shownPane) throw new Error("no pane in the answer");
|
|
84
|
+
unmountViewer();
|
|
85
|
+
const nextCrumb = document.importNode(crumb, true) as Element;
|
|
86
|
+
const nextPane = document.importNode(pane, true) as Element;
|
|
87
|
+
shownCrumb.replaceWith(nextCrumb);
|
|
88
|
+
shownPane.replaceWith(nextPane);
|
|
89
|
+
wireNavigation(nextCrumb);
|
|
90
|
+
wireNavigation(nextPane);
|
|
91
|
+
mountViewer(hooks);
|
|
92
|
+
markShown();
|
|
93
|
+
scrollTo(offset);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type HistoryMove = "push" | "replace" | "none";
|
|
97
|
+
|
|
98
|
+
/** Shows the file `url` names in place; the latest request wins and a failed one is a normal navigation. */
|
|
99
|
+
function swap(url: string, move: HistoryMove, offset: number, fallback: () => void): void {
|
|
100
|
+
const address = paneAddress(url);
|
|
101
|
+
if (!address) {
|
|
102
|
+
fallback();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
swapSequence += 1;
|
|
106
|
+
const mine = swapSequence;
|
|
107
|
+
fetchMarkup(address)
|
|
108
|
+
.then((markup) => {
|
|
109
|
+
if (mine !== swapSequence) return;
|
|
110
|
+
applyPane(markup, offset);
|
|
111
|
+
const target = new URL(url, location.href).toString();
|
|
112
|
+
if (move === "push") {
|
|
113
|
+
history.replaceState({ ...(history.state as PaneState | null), vaultScroll: offset }, "");
|
|
114
|
+
history.pushState(entry(offset), "", target);
|
|
115
|
+
} else if (move === "replace") {
|
|
116
|
+
history.replaceState(entry(offset), "", target);
|
|
117
|
+
}
|
|
118
|
+
shownUrl = target;
|
|
119
|
+
})
|
|
120
|
+
.catch(() => {
|
|
121
|
+
if (mine === swapSequence) fallback();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function showFile(url: string): boolean {
|
|
126
|
+
if (!paneAddress(url)) return false;
|
|
127
|
+
swap(url, "push", scrollOffset(), () => location.assign(url));
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const hooks: ViewerHooks = {
|
|
132
|
+
saved() {
|
|
133
|
+
const root = document.querySelector("[data-vault-root]");
|
|
134
|
+
const read = root ? root.getAttribute("data-vault-read-href") : null;
|
|
135
|
+
if (!read) return;
|
|
136
|
+
const target = new URL(read, location.href).toString();
|
|
137
|
+
const offset = scrollOffset();
|
|
138
|
+
// The saved file is no longer the edit address, whatever navigation follows.
|
|
139
|
+
history.replaceState(entry(offset), "", target);
|
|
140
|
+
shownUrl = target;
|
|
141
|
+
swap(target, "none", offset, () => location.assign(target));
|
|
142
|
+
status("saved");
|
|
143
|
+
},
|
|
144
|
+
changed() {
|
|
145
|
+
const target = shownUrl || location.href;
|
|
146
|
+
swap(target, "none", scrollOffset(), () => {});
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function wireLinks(main: Element): void {
|
|
151
|
+
main.addEventListener("click", (raw) => {
|
|
152
|
+
const event = raw as MouseEvent;
|
|
153
|
+
if (!isPlainClick(event)) return;
|
|
154
|
+
const target = event.target as Element | null;
|
|
155
|
+
const link =
|
|
156
|
+
target && target.closest
|
|
157
|
+
? target.closest<HTMLAnchorElement>("a[data-vault-entry], a[data-vault-mode-link]")
|
|
158
|
+
: null;
|
|
159
|
+
if (!link) return;
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
navigate(link.href, true);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function wireHistory(): void {
|
|
166
|
+
if (typeof window === "undefined") return;
|
|
167
|
+
window.addEventListener("popstate", () => {
|
|
168
|
+
const next = location.href;
|
|
169
|
+
const previous = shownUrl;
|
|
170
|
+
const state = history.state as PaneState | null;
|
|
171
|
+
const saved = state?.vaultScroll ?? 0;
|
|
172
|
+
const swappable = !!state && state.vaultScope === scope();
|
|
173
|
+
void guard().then((allowed) => {
|
|
174
|
+
if (!allowed) {
|
|
175
|
+
// Put the address of what is still shown back where the move took it from.
|
|
176
|
+
history.pushState(entry(scrollOffset()), "", previous);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (swappable) swap(next, "none", saved, () => location.reload());
|
|
180
|
+
else location.reload();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Opens files and switches modes without replacing the document; wired once, with the page. */
|
|
186
|
+
export function wireVaultPane(): void {
|
|
187
|
+
const shown = layout();
|
|
188
|
+
if (!shown) return;
|
|
189
|
+
shownUrl = location.href;
|
|
190
|
+
// Only a page showing a file has a pane to come back to; a listing is reloaded.
|
|
191
|
+
if (document.querySelector("[data-vault-root]")) {
|
|
192
|
+
history.replaceState({ ...(history.state as PaneState | null), ...entry(scrollOffset()) }, "");
|
|
193
|
+
}
|
|
194
|
+
if (typeof history !== "undefined" && "scrollRestoration" in history) {
|
|
195
|
+
history.scrollRestoration = "manual";
|
|
196
|
+
}
|
|
197
|
+
setInPlaceNavigation(showFile);
|
|
198
|
+
const main = document.querySelector("[data-vault-main]");
|
|
199
|
+
if (main) wireLinks(main);
|
|
200
|
+
wireHistory();
|
|
201
|
+
mountViewer(hooks);
|
|
202
|
+
}
|