@1agh/maude 0.36.0 → 0.37.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/apps/studio/canvas-shell.tsx +73 -2
- package/apps/studio/client/app.jsx +138 -10
- package/apps/studio/client/github.js +6 -0
- package/apps/studio/client/panels/CreateProject.jsx +57 -23
- package/apps/studio/client/panels/GitPanel.jsx +1 -1
- package/apps/studio/client/panels/IdentityBar.jsx +43 -2
- package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
- package/apps/studio/client/tour/collab-tour.js +3 -3
- package/apps/studio/commands/edit-source-command.ts +118 -0
- package/apps/studio/dist/client.bundle.js +53617 -21
- package/apps/studio/github/endpoints.ts +42 -0
- package/apps/studio/http.ts +15 -0
- package/apps/studio/scaffold-design.ts +95 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +3 -0
- package/apps/studio/test/edit-source-command.test.ts +119 -0
- package/apps/studio/test/sync-agent.test.ts +9 -2
- package/apps/studio/test/use-undo-stack.test.tsx +30 -0
- package/apps/studio/undo-stack.ts +10 -0
- package/apps/studio/use-undo-stack.tsx +21 -1
- package/apps/studio/whats-new.json +18 -0
- package/package.json +11 -9
|
@@ -178,26 +178,26 @@ function AiReadiness() {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
// ── A · Welcome (door picker) ────────────────────────────────────────────────
|
|
181
|
-
function Welcome({ onGithub, onLocal, onHub, signing }) {
|
|
181
|
+
function Welcome({ onGithub, onLocal, onHub, signing, signedIn, identity }) {
|
|
182
182
|
return (
|
|
183
183
|
<main className="ob-main">
|
|
184
184
|
<header className="ob-head">
|
|
185
185
|
<span className="ob-eyebrow">Welcome</span>
|
|
186
186
|
<h1>How would you like to start?</h1>
|
|
187
|
-
<p>Most people sign in with GitHub — it's the simplest way to work with a team
|
|
187
|
+
<p>{signedIn ? `You're signed in as @${identity?.login || 'GitHub'}. Open a project, or start a new one.` : "Most people sign in with GitHub — it's the simplest way to work with a team."}</p>
|
|
188
188
|
</header>
|
|
189
189
|
<div className="ob-doors">
|
|
190
|
-
<button type="button" className="ob-door ob-door--primary" aria-label="Continue with GitHub — recommended" onClick={onGithub} disabled={signing}>
|
|
190
|
+
<button type="button" data-testid="ob-door-github" className="ob-door ob-door--primary" aria-label="Continue with GitHub — recommended" onClick={onGithub} disabled={signing}>
|
|
191
191
|
<span className="ob-door-icon ob-door-icon--gh"><GitHubMark size={26} /></span>
|
|
192
192
|
<span className="ob-door-tx">
|
|
193
193
|
<span className="ob-door-title">Continue with GitHub<span className="ob-door-tag">Recommended</span></span>
|
|
194
194
|
<span className="ob-door-sub">Start a shared project, or open one a teammate shared with you.</span>
|
|
195
195
|
</span>
|
|
196
196
|
<span className="ob-door-cta">
|
|
197
|
-
<span className="btn btn--primary ob-door-btn"><GitHubMark size={15} /> {signing ? 'Starting…' : 'Sign in with GitHub'}</span>
|
|
197
|
+
<span className="btn btn--primary ob-door-btn"><GitHubMark size={15} /> {signing ? 'Starting…' : signedIn ? 'Continue' : 'Sign in with GitHub'}</span>
|
|
198
198
|
</span>
|
|
199
199
|
</button>
|
|
200
|
-
<button type="button" className="ob-door" aria-label="Open a folder on this computer" onClick={onLocal}>
|
|
200
|
+
<button type="button" data-testid="ob-door-local" className="ob-door" aria-label="Open a folder on this computer" onClick={onLocal}>
|
|
201
201
|
<span className="ob-door-icon"><Icon name="folder-open" size={22} /></span>
|
|
202
202
|
<span className="ob-door-tx">
|
|
203
203
|
<span className="ob-door-title">Open a folder on this computer</span>
|
|
@@ -205,7 +205,7 @@ function Welcome({ onGithub, onLocal, onHub, signing }) {
|
|
|
205
205
|
</span>
|
|
206
206
|
<span className="ob-door-go" aria-hidden="true"><Icon name="chevron-right" size={16} /></span>
|
|
207
207
|
</button>
|
|
208
|
-
<button type="button" className="ob-door ob-door--advanced" aria-label="Connect to a team hub — advanced" onClick={onHub}>
|
|
208
|
+
<button type="button" data-testid="ob-door-hub" className="ob-door ob-door--advanced" aria-label="Connect to a team hub — advanced" onClick={onHub}>
|
|
209
209
|
<span className="ob-door-icon ob-door-icon--quiet"><Icon name="server" size={18} /></span>
|
|
210
210
|
<span className="ob-door-tx">
|
|
211
211
|
<span className="ob-door-title">Connect to a team hub <span className="ob-door-adv">Advanced</span></span>
|
|
@@ -214,7 +214,7 @@ function Welcome({ onGithub, onLocal, onHub, signing }) {
|
|
|
214
214
|
<span className="ob-door-go" aria-hidden="true"><Icon name="chevron-right" size={15} /></span>
|
|
215
215
|
</button>
|
|
216
216
|
</div>
|
|
217
|
-
<p className="ob-foot-note">
|
|
217
|
+
<p className="ob-foot-note">Browse, version, and collaborate — all in the app, no terminal.</p>
|
|
218
218
|
<AiReadiness />
|
|
219
219
|
<CrashOptIn />
|
|
220
220
|
</main>
|
|
@@ -223,7 +223,7 @@ function Welcome({ onGithub, onLocal, onHub, signing }) {
|
|
|
223
223
|
|
|
224
224
|
function BackBar({ onBack }) {
|
|
225
225
|
return (
|
|
226
|
-
<button type="button" className="ob-back" onClick={onBack}>
|
|
226
|
+
<button type="button" data-testid="ob-back" className="ob-back" onClick={onBack}>
|
|
227
227
|
<Icon name="back" size={14} /> Back
|
|
228
228
|
</button>
|
|
229
229
|
);
|
|
@@ -272,7 +272,7 @@ function GitHubDoor({ identity, onBack }) {
|
|
|
272
272
|
<h1>Open a project, or start a new one</h1>
|
|
273
273
|
<p>Pick up a shared project below, or create a fresh one — it's private until you invite someone.</p>
|
|
274
274
|
</header>
|
|
275
|
-
<button type="button" className="ob-create" aria-label="Start a new project" onClick={() => setCreating(true)}>
|
|
275
|
+
<button type="button" data-testid="ob-github-create" className="ob-create" aria-label="Start a new project" onClick={() => setCreating(true)}>
|
|
276
276
|
<span className="ob-create-icon"><Icon name="plus" size={20} /></span>
|
|
277
277
|
<span className="ob-create-tx">
|
|
278
278
|
<span className="ob-create-title">Start a new project</span>
|
|
@@ -280,12 +280,12 @@ function GitHubDoor({ identity, onBack }) {
|
|
|
280
280
|
</span>
|
|
281
281
|
<span className="btn btn--primary ob-create-btn"><Icon name="arrow-right" size={15} /> Create</span>
|
|
282
282
|
</button>
|
|
283
|
-
<div className="ob-section-label">
|
|
283
|
+
<div className="ob-section-label">Your projects</div>
|
|
284
284
|
{err && <div className="callout callout--error ob-callout"><span className="ob-callout-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
|
|
285
285
|
{repos === null && <div className="ob-foot-note">Loading your projects…</div>}
|
|
286
|
-
{repos && repos.length === 0 && !err && <div className="ob-foot-note">No
|
|
286
|
+
{repos && repos.length === 0 && !err && <div className="ob-foot-note">No projects yet — create one to get started.</div>}
|
|
287
287
|
{repos && repos.length > 0 && (
|
|
288
|
-
<div className="ob-repolist" role="group" aria-label="
|
|
288
|
+
<div className="ob-repolist" role="group" aria-label="Projects you can open">
|
|
289
289
|
{repos.map((r) => {
|
|
290
290
|
const isBusy = busy === r.full_name;
|
|
291
291
|
return (
|
|
@@ -417,12 +417,12 @@ function LocalDoor({ onBack }) {
|
|
|
417
417
|
{err && <div className="callout callout--error ob-callout"><span className="ob-callout-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
|
|
418
418
|
<div className="ob-form-actions">
|
|
419
419
|
<button type="button" className="btn btn--ghost" onClick={() => setNeedsSetup(null)} disabled={busy}>Pick a different folder</button>
|
|
420
|
-
<button type="button" className="btn btn--primary" onClick={setupHere} disabled={busy}><Icon name="folder" size={15} /> {busy ? 'Setting up…' : 'Set up Maude here'}</button>
|
|
420
|
+
<button type="button" data-testid="ob-local-setup" className="btn btn--primary" onClick={setupHere} disabled={busy}><Icon name="folder" size={15} /> {busy ? 'Setting up…' : 'Set up Maude here'}</button>
|
|
421
421
|
</div>
|
|
422
422
|
</>
|
|
423
423
|
) : (
|
|
424
424
|
<>
|
|
425
|
-
<button type="button" className="ob-drop" onClick={choose} disabled={busy} aria-label="Choose a project folder">
|
|
425
|
+
<button type="button" data-testid="ob-local-choose" className="ob-drop" onClick={choose} disabled={busy} aria-label="Choose a project folder">
|
|
426
426
|
<span className="ob-drop-glyph"><Icon name="folder-open" size={34} /></span>
|
|
427
427
|
<span className="ob-drop-title">{busy ? 'Opening…' : 'Choose a project folder'}</span>
|
|
428
428
|
<span className="ob-drop-or">click to browse</span>
|
|
@@ -506,11 +506,11 @@ function DeviceCodeModal({ device, onClose }) {
|
|
|
506
506
|
return (
|
|
507
507
|
<div className="gi-modal" role="dialog" aria-modal="true" aria-label="Sign in with GitHub" onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
|
508
508
|
<div className="gi-scrim" aria-hidden="true" onClick={onClose} />
|
|
509
|
-
<div className="gi-dialog gi-dialog--code">
|
|
509
|
+
<div className="gi-dialog gi-dialog--code" data-testid="ob-device-modal">
|
|
510
510
|
<div className="gi-dc-head">
|
|
511
511
|
<span className="gi-dc-marks"><GitHubMark size={26} /></span>
|
|
512
512
|
<h2>Sign in with GitHub</h2>
|
|
513
|
-
<p>Maude opened GitHub in your browser. Enter
|
|
513
|
+
<p>Maude opened GitHub in your browser. Enter the code there to connect — you'll grant Maude access to create and manage your project repos.</p>
|
|
514
514
|
</div>
|
|
515
515
|
<ol className="gi-dc-steps">
|
|
516
516
|
<li>
|
|
@@ -523,7 +523,7 @@ function DeviceCodeModal({ device, onClose }) {
|
|
|
523
523
|
<li><span className="gi-dc-step-n">2</span><span className="gi-dc-step-tx">Enter this code to connect Maude</span></li>
|
|
524
524
|
</ol>
|
|
525
525
|
<div className="gi-code">
|
|
526
|
-
<span className="gi-code-val">{device.user_code}</span>
|
|
526
|
+
<span className="gi-code-val" data-testid="ob-device-code">{device.user_code}</span>
|
|
527
527
|
<button type="button" className="btn btn--ghost gi-code-copy" onClick={copyCode} aria-label="Copy the code"><Icon name="copy" size={15} /> {copied ? 'Copied' : 'Copy'}</button>
|
|
528
528
|
</div>
|
|
529
529
|
<div className="gi-dc-status" aria-live="polite"><span className="gi-pulse" aria-hidden="true" /><span>Waiting for you to authorize in your browser…</span></div>
|
|
@@ -542,6 +542,7 @@ export default function OnboardingWizard() {
|
|
|
542
542
|
const [device, setDevice] = useState(null);
|
|
543
543
|
const [err, setErr] = useState('');
|
|
544
544
|
const unlistenRef = useRef(null);
|
|
545
|
+
const cancelledRef = useRef(false);
|
|
545
546
|
|
|
546
547
|
// If already signed in (re-onboarding), skip the sign-in step on the GitHub door.
|
|
547
548
|
useEffect(() => {
|
|
@@ -560,16 +561,18 @@ export default function OnboardingWizard() {
|
|
|
560
561
|
|
|
561
562
|
async function handleGithub() {
|
|
562
563
|
if (signedIn) { setDoor('github'); return; }
|
|
564
|
+
cancelledRef.current = false;
|
|
563
565
|
setErr(''); setSigning(true); setDevice(null);
|
|
564
566
|
try {
|
|
565
567
|
unlistenRef.current = onDeviceCode((p) => setDevice(p));
|
|
566
568
|
const login = await signIn();
|
|
569
|
+
if (cancelledRef.current) return; // user hit Cancel — don't auto-advance on a late resolve
|
|
567
570
|
const r = await fetchIdentity();
|
|
568
571
|
setIdentity(r.ok && r.json?.ok ? { login: r.json.login, name: r.json.name } : { login });
|
|
569
572
|
setSignedIn(true);
|
|
570
573
|
setDoor('github');
|
|
571
574
|
} catch (e) {
|
|
572
|
-
setErr(String(e?.message || e || "Sign-in didn't finish. Please try again."));
|
|
575
|
+
if (!cancelledRef.current) setErr(String(e?.message || e || "Sign-in didn't finish. Please try again."));
|
|
573
576
|
} finally {
|
|
574
577
|
setSigning(false);
|
|
575
578
|
setDevice(null);
|
|
@@ -581,10 +584,10 @@ export default function OnboardingWizard() {
|
|
|
581
584
|
if (!native) return null;
|
|
582
585
|
|
|
583
586
|
return (
|
|
584
|
-
<div className="ob-overlay" role="dialog" aria-modal="true" aria-label="Welcome to Maude">
|
|
587
|
+
<div className="ob-overlay" data-testid="onboarding-wizard" role="dialog" aria-modal="true" aria-label="Welcome to Maude">
|
|
585
588
|
<div className="ob-shell">
|
|
586
589
|
<Rail signedInAs={signedIn ? identity?.login : null} />
|
|
587
|
-
{door === 'welcome' && <Welcome signing={signing} onGithub={handleGithub} onLocal={() => setDoor('local')} onHub={() => setDoor('hub')} />}
|
|
590
|
+
{door === 'welcome' && <Welcome signing={signing} signedIn={signedIn} identity={identity} onGithub={handleGithub} onLocal={() => setDoor('local')} onHub={() => setDoor('hub')} />}
|
|
588
591
|
{door === 'github' && <GitHubDoor identity={identity} onBack={() => setDoor('welcome')} />}
|
|
589
592
|
{door === 'local' && <LocalDoor onBack={() => setDoor('welcome')} />}
|
|
590
593
|
{door === 'hub' && <HubDoor onBack={() => setDoor('welcome')} />}
|
|
@@ -592,7 +595,7 @@ export default function OnboardingWizard() {
|
|
|
592
595
|
{err && door === 'welcome' && (
|
|
593
596
|
<div className="ob-toast callout callout--error" role="alert"><span className="ob-callout-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>
|
|
594
597
|
)}
|
|
595
|
-
{device && <DeviceCodeModal device={device} onClose={() => setDevice(null)} />}
|
|
598
|
+
{device && <DeviceCodeModal device={device} onClose={() => { cancelledRef.current = true; setSigning(false); setDevice(null); }} />}
|
|
596
599
|
</div>
|
|
597
600
|
);
|
|
598
601
|
}
|
|
@@ -417,7 +417,7 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
|
|
|
417
417
|
<span className="rb-pop-icon"><Icon name="plus" size={14} /></span>
|
|
418
418
|
<span className="rb-pop-tx">
|
|
419
419
|
<span className="rb-pop-name">New branch</span>
|
|
420
|
-
<span className="rb-pop-sub">a separate line of work off
|
|
420
|
+
<span className="rb-pop-sub">a separate line of work off what you're looking at now</span>
|
|
421
421
|
</span>
|
|
422
422
|
</button>
|
|
423
423
|
</div>
|
|
@@ -435,11 +435,11 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
|
|
|
435
435
|
<button type="button" className="btn btn--ghost btn--sm" onClick={() => { setNewDraft(false); setDraftName(''); setErr(''); }} disabled={busy}>Cancel</button>
|
|
436
436
|
<button type="button" data-testid="switcher-new-branch-create" className="btn btn--primary btn--sm" onClick={createDraft} disabled={busy || !slug}><Icon name="draft" size={13} /> {busy ? 'Creating…' : 'Create branch'}</button>
|
|
437
437
|
</div>
|
|
438
|
-
<p className="rb-newdraft-hint">A branch is your own line of work off
|
|
438
|
+
<p className="rb-newdraft-hint">A branch is your own line of work off what you're looking at now. Merge it into {sharedName} when you're happy, or throw it away — nothing else changes.</p>
|
|
439
439
|
</div>
|
|
440
440
|
)}
|
|
441
441
|
|
|
442
|
-
{switching ? (
|
|
442
|
+
{(switching || folding) ? (
|
|
443
443
|
<div className="rb-switching" role="status" aria-live="polite">
|
|
444
444
|
<Icon name="spinner" size={14} className="rb-spin" />
|
|
445
445
|
<span>{folding ? <>Merging <b>{folding}</b> → {sharedName}…</> : downloading ? <>Downloading <b>{switching}</b>…</> : <>Opening <b>{switching}</b>…</>}</span>
|
|
@@ -466,7 +466,7 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
|
|
|
466
466
|
<span className="rb-sheet-icon"><Icon name="arrow-up-to-line" size={20} /></span>
|
|
467
467
|
<h2 className="rb-sheet-title" id="rb-sheet-title">Merge this branch → {sharedName}</h2>
|
|
468
468
|
<p className="rb-sheet-body" id="rb-sheet-body">Everything in <b>“{currentDraft?.name || branch}”</b> becomes part of <b>{sharedName}</b> — the default branch everyone shares.</p>
|
|
469
|
-
<p className="rb-sheet-meta">Teammates pick it up the next time they Get latest, and
|
|
469
|
+
<p className="rb-sheet-meta">Your work is now part of {sharedName}, kept in History. Teammates pick it up the next time they Get latest, and the empty branch is cleared away — nothing is lost.</p>
|
|
470
470
|
{err && <p className="rb-newdraft-err">{err}</p>}
|
|
471
471
|
<div className="rb-sheet-actions">
|
|
472
472
|
<button type="button" className="btn btn--ghost" onClick={() => { setFoldConfirm(false); setErr(''); }}>Cancel</button>
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// the one honest hard thing, centered.
|
|
15
15
|
//
|
|
16
16
|
// Vocabulary is the canonical set ONLY (teaching-model table) — no raw git terms:
|
|
17
|
-
// Save
|
|
17
|
+
// Save version · Publish · Get latest · branch · Shared version (matches the shipped buttons).
|
|
18
18
|
|
|
19
19
|
import CollabModelInfographic from '../panels/CollabModelInfographic.jsx';
|
|
20
20
|
|
|
@@ -43,8 +43,8 @@ export const COLLAB_TOUR = [
|
|
|
43
43
|
{
|
|
44
44
|
target: "[data-tour='pull']",
|
|
45
45
|
changes: true,
|
|
46
|
-
title: '
|
|
47
|
-
body: 'When teammates publish,
|
|
46
|
+
title: 'Get latest',
|
|
47
|
+
body: 'When teammates publish, Get latest brings their work onto your computer so you’re both looking at the same thing.',
|
|
48
48
|
placement: 'left',
|
|
49
49
|
},
|
|
50
50
|
{
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/edit-source-command.ts — undo entry for inline source edits
|
|
3
|
+
* @scope apps/studio/commands/edit-source-command.ts
|
|
4
|
+
* @purpose Reversible record of a single in-canvas direct edit that writes
|
|
5
|
+
* the canvas `.tsx` source: an inspector CSS commit/reset
|
|
6
|
+
* (`/_api/edit-css`), an inline text rewrite (`/_api/edit-text`), or
|
|
7
|
+
* a custom HTML-attribute edit (`/_api/edit-attr`). Before this
|
|
8
|
+
* command existed these edits were covered by NEITHER the Cmd+Z
|
|
9
|
+
* undo stack NOR the `_history` snapshot stack, so they were
|
|
10
|
+
* unrecoverable in-app (the reported bug). See the undo/redo
|
|
11
|
+
* coverage RCA: `.ai/logs/rca/issue-undo-redo-coverage-gaps.md`.
|
|
12
|
+
*
|
|
13
|
+
* Why a single `edit-source` kind for all three ops. Each is invertible
|
|
14
|
+
* through the SAME endpoint by re-applying the prior value — `editCss(prop,
|
|
15
|
+
* before)` undoes `editCss(prop, after)`; `null` means "remove the inline
|
|
16
|
+
* prop / attr" (the reset path) or "field was unset, undo by reset". Storing
|
|
17
|
+
* the op + before/after as a flat record keeps one builder + one sink.
|
|
18
|
+
*
|
|
19
|
+
* Origin split (DDR-054). This command runs INSIDE the untrusted canvas iframe
|
|
20
|
+
* (where the undo stack lives), but `/_api/edit-*` are main-origin-only — the
|
|
21
|
+
* iframe cannot fetch them. So `do()` / `undo()` route through the injected
|
|
22
|
+
* `editSourceApplyFn` sink, which posts `dgn:'apply-edit'` to the parent shell;
|
|
23
|
+
* the shell performs the privileged write. Records are built per iframe mount
|
|
24
|
+
* from a serializable `CommandRecord` (DDR-050 rev 2) so the stack survives the
|
|
25
|
+
* `mode:'module'` HMR reload that the source write triggers.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { CommandRecord, EditCommand } from '../undo-stack.ts';
|
|
29
|
+
import { registerCommand } from '../undo-stack.ts';
|
|
30
|
+
|
|
31
|
+
/** Which main-origin edit route this record re-applies through. */
|
|
32
|
+
export type EditSourceOp = 'css' | 'text' | 'attr';
|
|
33
|
+
|
|
34
|
+
export interface EditSourcePayload {
|
|
35
|
+
op: EditSourceOp;
|
|
36
|
+
/** Canvas file path (repo- or designRoot-relative — same string the edit POST used). */
|
|
37
|
+
canvas: string;
|
|
38
|
+
/** `data-cd-id` of the target element. */
|
|
39
|
+
id: string;
|
|
40
|
+
/**
|
|
41
|
+
* CSS property name (`op:'css'`) or HTML attribute name (`op:'attr'`).
|
|
42
|
+
* Empty string for `op:'text'` (the whole text node is the target).
|
|
43
|
+
*/
|
|
44
|
+
key: string;
|
|
45
|
+
/** Value before the edit. `null` = the prop/attr was unset (undo ⇒ reset). */
|
|
46
|
+
before: string | null;
|
|
47
|
+
/** Value after the edit. `null` = the edit removed the prop/attr (the reset path). */
|
|
48
|
+
after: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One application of an inline edit. The production binding (canvas-shell.tsx)
|
|
53
|
+
* posts `dgn:'apply-edit'` to the parent shell; tests pass a spy. `value` null
|
|
54
|
+
* means "reset" — remove the inline prop / attr (for `op:'text'` a null value
|
|
55
|
+
* never occurs, text always has a body).
|
|
56
|
+
*/
|
|
57
|
+
export type EditSourceApplyFn = (apply: {
|
|
58
|
+
op: EditSourceOp;
|
|
59
|
+
canvas: string;
|
|
60
|
+
id: string;
|
|
61
|
+
key: string;
|
|
62
|
+
value: string | null;
|
|
63
|
+
}) => void | Promise<void>;
|
|
64
|
+
|
|
65
|
+
export const EDIT_SOURCE_KIND = 'edit-source';
|
|
66
|
+
|
|
67
|
+
export interface EditSourceCommandInit {
|
|
68
|
+
payload: EditSourcePayload;
|
|
69
|
+
applyFn: EditSourceApplyFn;
|
|
70
|
+
label?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createEditSourceCommand(init: EditSourceCommandInit): EditCommand {
|
|
74
|
+
const { payload, applyFn } = init;
|
|
75
|
+
const apply = (value: string | null) =>
|
|
76
|
+
applyFn({ op: payload.op, canvas: payload.canvas, id: payload.id, key: payload.key, value });
|
|
77
|
+
return {
|
|
78
|
+
kind: EDIT_SOURCE_KIND,
|
|
79
|
+
label: init.label ?? defaultLabel(payload),
|
|
80
|
+
async do() {
|
|
81
|
+
await apply(payload.after);
|
|
82
|
+
},
|
|
83
|
+
async undo() {
|
|
84
|
+
await apply(payload.before);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build a persistable record. The caller (the shell's CssKnobs for css/attr,
|
|
91
|
+
* canvas-shell's text commit for text) has already applied the edit once, so
|
|
92
|
+
* this record is pushed onto the stack via `useUndoStack().record()` — which
|
|
93
|
+
* appends WITHOUT re-running `do()` — not via `push()`.
|
|
94
|
+
*/
|
|
95
|
+
export function buildEditSourceRecord(
|
|
96
|
+
payload: EditSourcePayload
|
|
97
|
+
): CommandRecord<EditSourcePayload> {
|
|
98
|
+
return { kind: EDIT_SOURCE_KIND, label: defaultLabel(payload), payload };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
// Registry
|
|
103
|
+
|
|
104
|
+
registerCommand<EditSourcePayload>(EDIT_SOURCE_KIND, (record, sinks) => {
|
|
105
|
+
const applyFn = sinks.editSourceApplyFn as EditSourceApplyFn | undefined;
|
|
106
|
+
if (!applyFn) return null;
|
|
107
|
+
return createEditSourceCommand({ payload: record.payload, applyFn, label: record.label });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// Internals
|
|
112
|
+
|
|
113
|
+
function defaultLabel(p: EditSourcePayload): string {
|
|
114
|
+
if (p.op === 'text') return 'edit text';
|
|
115
|
+
const verb = p.after == null ? 'reset' : 'edit';
|
|
116
|
+
const what = p.op === 'attr' ? `@${p.key}` : p.key;
|
|
117
|
+
return `${verb} ${what}`;
|
|
118
|
+
}
|