@1agh/maude 0.30.0 → 0.32.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/README.md +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +788 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +796 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
- package/apps/studio/client/panels/ReadinessList.jsx +189 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +625 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +821 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +29097 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5816 -1614
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +399 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/readiness.ts +220 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +95 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/readiness.test.ts +127 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +82 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +30 -8
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// First-open AI-editing readiness list (DDR-128). Reads GET /_api/preflight and
|
|
2
|
+
// renders one row per dependency (claude · maude · plugins · agent-browser) with a
|
|
3
|
+
// status glyph, a one-line detail, and a copy-paste remediation when something's
|
|
4
|
+
// missing. Detect-and-guide only — it never installs or mutates anything.
|
|
5
|
+
//
|
|
6
|
+
// Shared by the onboarding wizard (a non-blocking strip) and the ChatPanel
|
|
7
|
+
// not-connected explainer (where a user actually hits the wall). Both also use it
|
|
8
|
+
// as the persistent re-check surface — `refresh()` re-probes without a reinstall.
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
/** Fetch + cache the readiness report. `refresh()` re-probes (the re-check button). */
|
|
13
|
+
export function useReadiness(enabled = true) {
|
|
14
|
+
const [report, setReport] = useState(null); // { ready, items } | null
|
|
15
|
+
const [loading, setLoading] = useState(enabled);
|
|
16
|
+
|
|
17
|
+
const refresh = useCallback(() => {
|
|
18
|
+
setLoading(true);
|
|
19
|
+
return fetch('/_api/preflight')
|
|
20
|
+
.then((r) => r.json())
|
|
21
|
+
.then((d) => {
|
|
22
|
+
setReport(d && Array.isArray(d.items) ? d : null);
|
|
23
|
+
setLoading(false);
|
|
24
|
+
return d;
|
|
25
|
+
})
|
|
26
|
+
.catch(() => {
|
|
27
|
+
setReport(null);
|
|
28
|
+
setLoading(false);
|
|
29
|
+
return null;
|
|
30
|
+
});
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!enabled) return undefined;
|
|
35
|
+
let alive = true;
|
|
36
|
+
fetch('/_api/preflight')
|
|
37
|
+
.then((r) => r.json())
|
|
38
|
+
.then((d) => alive && (setReport(d && Array.isArray(d.items) ? d : null), setLoading(false)))
|
|
39
|
+
.catch(() => alive && (setReport(null), setLoading(false)));
|
|
40
|
+
return () => {
|
|
41
|
+
alive = false;
|
|
42
|
+
};
|
|
43
|
+
}, [enabled]);
|
|
44
|
+
|
|
45
|
+
return { report, loading, refresh };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function StatusIcon({ status }) {
|
|
49
|
+
if (status === 'present')
|
|
50
|
+
return (
|
|
51
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
52
|
+
<polyline points="3 8.4 6.4 11.8 13 4.4" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
if (status === 'unknown')
|
|
56
|
+
return (
|
|
57
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
58
|
+
<path d="M6 6a2 2 0 1 1 2.6 1.9c-.4.2-.6.5-.6 1V10" />
|
|
59
|
+
<circle cx="8" cy="12.4" r="0.6" fill="currentColor" stroke="none" />
|
|
60
|
+
</svg>
|
|
61
|
+
);
|
|
62
|
+
return (
|
|
63
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
64
|
+
<circle cx="8" cy="8" r="5.5" />
|
|
65
|
+
<line x1="8" y1="5" x2="8" y2="8.6" />
|
|
66
|
+
</svg>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Render `code`-fenced spans inside a string as <code>, the rest as text. */
|
|
71
|
+
function Inline({ text }) {
|
|
72
|
+
const parts = [];
|
|
73
|
+
const re = /`([^`]+)`/g;
|
|
74
|
+
let last = 0;
|
|
75
|
+
let m;
|
|
76
|
+
while ((m = re.exec(text)) !== null) {
|
|
77
|
+
if (m.index > last) parts.push(text.slice(last, m.index));
|
|
78
|
+
parts.push(<code key={parts.length}>{m[1]}</code>);
|
|
79
|
+
last = m.index + m[0].length;
|
|
80
|
+
}
|
|
81
|
+
if (last < text.length) parts.push(text.slice(last));
|
|
82
|
+
return <>{parts}</>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function Row({ item }) {
|
|
86
|
+
const [copied, setCopied] = useState(false);
|
|
87
|
+
const copyFix = () => {
|
|
88
|
+
const cmds = item.remediation?.replace(/`/g, '') ?? '';
|
|
89
|
+
navigator.clipboard?.writeText(cmds).then(
|
|
90
|
+
() => {
|
|
91
|
+
setCopied(true);
|
|
92
|
+
setTimeout(() => setCopied(false), 1500);
|
|
93
|
+
},
|
|
94
|
+
() => {}
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
return (
|
|
98
|
+
<li className={`rdy-row rdy-row--${item.status}${item.required ? '' : ' rdy-row--opt'}`}>
|
|
99
|
+
<span className="rdy-ic" aria-hidden="true">
|
|
100
|
+
<StatusIcon status={item.status} />
|
|
101
|
+
</span>
|
|
102
|
+
<span className="rdy-tx">
|
|
103
|
+
<span className="rdy-label">{item.label}</span>
|
|
104
|
+
<span className="rdy-detail">
|
|
105
|
+
<Inline text={item.detail} />
|
|
106
|
+
</span>
|
|
107
|
+
{item.remediation ? (
|
|
108
|
+
<span className="rdy-fix">
|
|
109
|
+
<span className="rdy-fix-tx">
|
|
110
|
+
<Inline text={item.remediation} />
|
|
111
|
+
</span>
|
|
112
|
+
<button type="button" className="rdy-copy" onClick={copyFix} aria-label={`Copy the fix for ${item.label}`}>
|
|
113
|
+
{copied ? 'Copied' : 'Copy'}
|
|
114
|
+
</button>
|
|
115
|
+
</span>
|
|
116
|
+
) : null}
|
|
117
|
+
</span>
|
|
118
|
+
</li>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default function ReadinessList({ report, loading, refresh }) {
|
|
123
|
+
return (
|
|
124
|
+
<div className="rdy">
|
|
125
|
+
<ul className="rdy-list">
|
|
126
|
+
{report?.items?.map((it) => (
|
|
127
|
+
<Row key={it.id} item={it} />
|
|
128
|
+
))}
|
|
129
|
+
</ul>
|
|
130
|
+
{refresh ? (
|
|
131
|
+
<div className="rdy-foot">
|
|
132
|
+
<button type="button" className="btn btn--ghost btn--sm rdy-recheck" onClick={refresh} disabled={loading}>
|
|
133
|
+
{loading ? 'Checking…' : 'Re-check'}
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Standalone readiness modal reachable any time from Help ▸ Check AI editing
|
|
142
|
+
// readiness… (DDR-128, T6 — the persistent re-check surface, not gated on
|
|
143
|
+
// first-run or a disconnected chat). Reuses the shared help-modal chrome (backdrop
|
|
144
|
+
// + header + body) so it matches the What's New / Help dialogs, and the same
|
|
145
|
+
// ReadinessList. Self-contained: probes only while open (the hook's `enabled` gate),
|
|
146
|
+
// re-probes via the list's Re-check button.
|
|
147
|
+
export function ReadinessDialog({ open, onClose }) {
|
|
148
|
+
const { report, loading, refresh } = useReadiness(open);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!open) return undefined;
|
|
151
|
+
const onKey = (e) => e.key === 'Escape' && onClose();
|
|
152
|
+
window.addEventListener('keydown', onKey);
|
|
153
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
154
|
+
}, [open, onClose]);
|
|
155
|
+
if (!open) return null;
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
className="help-modal-backdrop"
|
|
159
|
+
role="presentation"
|
|
160
|
+
onMouseDown={(e) => {
|
|
161
|
+
if (e.target === e.currentTarget) onClose();
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
<div className="help-modal rdy-modal" role="dialog" aria-modal="true" aria-labelledby="rdy-modal-title">
|
|
165
|
+
<header className="help-modal-hd">
|
|
166
|
+
<span className="title" id="rdy-modal-title">
|
|
167
|
+
Check AI editing readiness
|
|
168
|
+
</span>
|
|
169
|
+
<button type="button" className="help-modal-close" aria-label="Close (Esc)" onClick={onClose}>
|
|
170
|
+
×
|
|
171
|
+
</button>
|
|
172
|
+
</header>
|
|
173
|
+
<div className="help-modal-body">
|
|
174
|
+
<p className="rdy-modal-note">
|
|
175
|
+
The canvas browser, version history, and sharing work with no setup. AI editing
|
|
176
|
+
additionally drives a Claude Code you have installed — here's what it needs:
|
|
177
|
+
</p>
|
|
178
|
+
{report ? (
|
|
179
|
+
<ReadinessList report={report} loading={loading} refresh={refresh} />
|
|
180
|
+
) : (
|
|
181
|
+
<p className="rdy-modal-note">
|
|
182
|
+
{loading ? 'Checking…' : "Couldn't reach the readiness probe."}
|
|
183
|
+
</p>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Phase 29 (epic E4) Task 4 — the persistent project + draft switcher.
|
|
2
|
+
//
|
|
3
|
+
// REDESIGNED to match .design/ui/RepoBranchSwitcher.tsx: not a top header — a
|
|
4
|
+
// compact ONE-LINE dock at the BOTTOM of the sidebar (mounted directly above the
|
|
5
|
+
// IdentityBar avatar, so the two form one bottom dock — the gi-rail/gi-menu
|
|
6
|
+
// anatomy). The trigger reads "📁 <project> · <version> ⌃" and opens ONE popup
|
|
7
|
+
// UPWARD with a Project section (recents + Open another folder…) and a Version
|
|
8
|
+
// section (Shared version / drafts / the fold-back CTA / New draft).
|
|
9
|
+
//
|
|
10
|
+
// Vocabulary contract: a git branch is a "draft", main/master is the "Shared
|
|
11
|
+
// version" — no branch/checkout/main/merge jargon. Project switch → open_local_project
|
|
12
|
+
// (Tauri) reloads the webview; draft switch → POST /_api/git/checkout then reload
|
|
13
|
+
// (the git-lifecycle HEAD-watcher flushes Yjs first — DDR-051, not duplicated here);
|
|
14
|
+
// "Add this draft to the Shared version" → POST /_api/git/fold. Renders nothing
|
|
15
|
+
// until the project is a git repo. CSS (rb-*) in 3-shell-maude.css.
|
|
16
|
+
|
|
17
|
+
import { useEffect, useRef, useState } from 'react';
|
|
18
|
+
|
|
19
|
+
import { appRecentProjects, isNativeApp, openLocalProject, pickDirectory } from '../github.js';
|
|
20
|
+
|
|
21
|
+
const SHARED = new Set(['main', 'master']);
|
|
22
|
+
|
|
23
|
+
function Icon({ name, size = 16, className }) {
|
|
24
|
+
const p = {
|
|
25
|
+
check: <polyline points="3 8.2 6.4 11.5 13 4.2" />,
|
|
26
|
+
'chevron-up': <polyline points="3.5 10 8 5.5 12.5 10" />,
|
|
27
|
+
'chevron-down': <polyline points="3.5 6 8 10.5 12.5 6" />,
|
|
28
|
+
'chevron-right': <polyline points="6 3.5 10.5 8 6 12.5" />,
|
|
29
|
+
folder: <path d="M2 4.5h4l1.3 1.5H14V13H2z" />,
|
|
30
|
+
'folder-open': (<><path d="M2 4.5h4l1.3 1.5H14" /><path d="M2 6h12.5l-1.4 7H3.4z" /></>),
|
|
31
|
+
share: (<><circle cx="4" cy="8" r="1.6" /><circle cx="12" cy="4" r="1.6" /><circle cx="12" cy="12" r="1.6" /><line x1="5.4" y1="7.2" x2="10.6" y2="4.6" /><line x1="5.4" y1="8.8" x2="10.6" y2="11.4" /></>),
|
|
32
|
+
draft: (<><path d="M3 11.5 11 3.5l1.5 1.5L4.5 13l-2 .5z" /><line x1="9.5" y1="5" x2="11" y2="6.5" /></>),
|
|
33
|
+
// git-branch glyph — only the web read-only badge uses it (git vocab there).
|
|
34
|
+
branch: (<><circle cx="4.5" cy="4" r="1.4" /><circle cx="4.5" cy="12" r="1.4" /><circle cx="11.5" cy="6.5" r="1.4" /><line x1="4.5" y1="5.4" x2="4.5" y2="10.6" /><path d="M4.5 8.6h2.6a3 3 0 0 0 3-1.6" /></>),
|
|
35
|
+
plus: (<><line x1="8" y1="3" x2="8" y2="13" /><line x1="3" y1="8" x2="13" y2="8" /></>),
|
|
36
|
+
// "lift this draft up into the Shared version" — the fold-back action.
|
|
37
|
+
'arrow-up-to-line': (<><line x1="3.5" y1="3" x2="12.5" y2="3" /><line x1="8" y1="13" x2="8" y2="6" /><polyline points="5 8.5 8 5.5 11 8.5" /></>),
|
|
38
|
+
spinner: <path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8" />,
|
|
39
|
+
}[name];
|
|
40
|
+
return (
|
|
41
|
+
<svg className={className} width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
42
|
+
{p}
|
|
43
|
+
</svg>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function basename(p) {
|
|
48
|
+
return String(p).replace(/[/\\]+$/, '').split(/[/\\]/).pop() || String(p);
|
|
49
|
+
}
|
|
50
|
+
function slugify(s) {
|
|
51
|
+
return s.trim().toLowerCase().replace(/[^a-z0-9._/-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
52
|
+
}
|
|
53
|
+
async function getJson(url) {
|
|
54
|
+
const r = await fetch(url);
|
|
55
|
+
return r.json();
|
|
56
|
+
}
|
|
57
|
+
async function postJson(url, body) {
|
|
58
|
+
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
59
|
+
let json = null;
|
|
60
|
+
try { json = await r.json(); } catch { /* no body */ }
|
|
61
|
+
return { ok: r.ok, status: r.status, json };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function RepoBranchSwitcher({ project, liveBranch }) {
|
|
65
|
+
const native = isNativeApp();
|
|
66
|
+
const [status, setStatus] = useState(null); // { repo, branch }
|
|
67
|
+
const [branches, setBranches] = useState([]);
|
|
68
|
+
const [recents, setRecents] = useState([]);
|
|
69
|
+
const [open, setOpen] = useState(false);
|
|
70
|
+
const [newDraft, setNewDraft] = useState(false);
|
|
71
|
+
const [draftName, setDraftName] = useState('');
|
|
72
|
+
const [foldConfirm, setFoldConfirm] = useState(false);
|
|
73
|
+
const [folding, setFolding] = useState('');
|
|
74
|
+
const [busy, setBusy] = useState(false);
|
|
75
|
+
const [switching, setSwitching] = useState('');
|
|
76
|
+
const [err, setErr] = useState('');
|
|
77
|
+
const rootRef = useRef(null);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
let alive = true;
|
|
81
|
+
(async () => {
|
|
82
|
+
try {
|
|
83
|
+
const s = await getJson('/_api/git/status');
|
|
84
|
+
if (!alive) return;
|
|
85
|
+
setStatus(s);
|
|
86
|
+
// Branches drive the native dock's draft picker only — the web badge is
|
|
87
|
+
// read-only, so skip the extra request there.
|
|
88
|
+
if (s.repo && native) {
|
|
89
|
+
const b = await getJson('/_api/git/branches');
|
|
90
|
+
if (alive) setBranches(b.branches || []);
|
|
91
|
+
}
|
|
92
|
+
} catch { /* not a repo / offline */ }
|
|
93
|
+
})();
|
|
94
|
+
if (native) appRecentProjects().then((r) => alive && setRecents(r || [])).catch(() => {});
|
|
95
|
+
return () => { alive = false; };
|
|
96
|
+
}, [native]);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!open && !newDraft) return undefined;
|
|
100
|
+
const onDoc = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) { setOpen(false); setNewDraft(false); } };
|
|
101
|
+
document.addEventListener('mousedown', onDoc);
|
|
102
|
+
return () => document.removeEventListener('mousedown', onDoc);
|
|
103
|
+
}, [open, newDraft]);
|
|
104
|
+
|
|
105
|
+
// Only meaningful for a versioned project (drafts need git).
|
|
106
|
+
if (!status?.repo) return null;
|
|
107
|
+
|
|
108
|
+
// Prefer the live branch from the git-status broadcast (kept current as the
|
|
109
|
+
// dev runs `git checkout` in their terminal) over the one-shot mount fetch.
|
|
110
|
+
const branch = liveBranch || status.branch || 'main';
|
|
111
|
+
const onShared = SHARED.has(branch);
|
|
112
|
+
const sharedName = branches.find((b) => SHARED.has(b.name))?.name || 'main';
|
|
113
|
+
const drafts = branches.filter((b) => !SHARED.has(b.name));
|
|
114
|
+
const otherDrafts = drafts.filter((b) => b.name !== branch);
|
|
115
|
+
const projectName = project || basename(recents[0] || 'Project');
|
|
116
|
+
|
|
117
|
+
// Web studio (browser, CLI-launched inside a repo): the switcher is awareness
|
|
118
|
+
// only. The developer owns the workspace from their terminal — switching repos
|
|
119
|
+
// and branches happens there — so render a compact, read-only "📁 project ·
|
|
120
|
+
// branch: X" badge in git vocab (not "draft"/"Shared version"), kept live by
|
|
121
|
+
// the same git-status broadcast that drives the Changes panel. No actions: a
|
|
122
|
+
// UI checkout would rewrite the dev's working tree under their hands and
|
|
123
|
+
// collide with their editor (DDR-119 — native owns the workspace; web is a
|
|
124
|
+
// repo-bound companion).
|
|
125
|
+
if (!native) {
|
|
126
|
+
return (
|
|
127
|
+
<div className="rb-dock-wrap">
|
|
128
|
+
<div className="rb-dock rb-dock--ro">
|
|
129
|
+
<span
|
|
130
|
+
className="rb-trigger rb-trigger--ro"
|
|
131
|
+
title={`${projectName} · branch: ${branch} — switch branches in your terminal`}
|
|
132
|
+
>
|
|
133
|
+
<span className="rb-trigger-icon"><Icon name="folder" size={14} /></span>
|
|
134
|
+
<span className="rb-trigger-proj">{projectName}</span>
|
|
135
|
+
<span className="rb-trigger-sep" aria-hidden="true">·</span>
|
|
136
|
+
<span className="rb-trigger-ver rb-trigger-ver--ro">
|
|
137
|
+
<Icon name="branch" size={12} />
|
|
138
|
+
<span className="rb-trigger-ver-name">branch: {branch}</span>
|
|
139
|
+
</span>
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function switchDraft(name) {
|
|
147
|
+
setOpen(false);
|
|
148
|
+
if (name === branch) return;
|
|
149
|
+
setSwitching(name === sharedName ? 'the shared version' : name);
|
|
150
|
+
setErr('');
|
|
151
|
+
const r = await postJson('/_api/git/checkout', { name });
|
|
152
|
+
if (r.ok && r.json?.ok) window.location.reload();
|
|
153
|
+
else { setErr(r.json?.error || 'Could not switch.'); setSwitching(''); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function createDraft() {
|
|
157
|
+
const name = slugify(draftName);
|
|
158
|
+
if (!name) return;
|
|
159
|
+
setBusy(true); setErr('');
|
|
160
|
+
const r = await postJson('/_api/git/branch', { name });
|
|
161
|
+
if (r.ok && r.json?.ok) window.location.reload();
|
|
162
|
+
else { setErr(r.json?.error || 'Could not create the draft.'); setBusy(false); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// "Add this draft to the Shared version" (Task 7) — merge + publish, then remove.
|
|
166
|
+
async function foldDraft() {
|
|
167
|
+
setFoldConfirm(false);
|
|
168
|
+
setFolding(branch);
|
|
169
|
+
setErr('');
|
|
170
|
+
const r = await postJson('/_api/git/fold', { name: branch });
|
|
171
|
+
if (r.ok && r.json?.ok) window.location.reload();
|
|
172
|
+
else {
|
|
173
|
+
setErr(r.status === 401 ? 'Sign in with GitHub to publish the Shared version.' : r.json?.error || 'Could not add the draft.');
|
|
174
|
+
setFolding('');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function switchRepo(path) {
|
|
179
|
+
setOpen(false);
|
|
180
|
+
if (!native) return;
|
|
181
|
+
setSwitching(basename(path));
|
|
182
|
+
try { await openLocalProject(path); }
|
|
183
|
+
catch (e) { setErr(String(e?.message || e || 'Could not open that project.')); setSwitching(''); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function openAnother() {
|
|
187
|
+
setOpen(false);
|
|
188
|
+
if (!native) return;
|
|
189
|
+
try {
|
|
190
|
+
const dir = await pickDirectory();
|
|
191
|
+
if (!dir) return;
|
|
192
|
+
setSwitching(basename(dir));
|
|
193
|
+
await openLocalProject(dir);
|
|
194
|
+
} catch (e) { setErr(String(e?.message || e || 'Could not open that folder.')); setSwitching(''); }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const slug = slugify(draftName);
|
|
198
|
+
const currentDraft = onShared ? null : branches.find((b) => b.current);
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div className="rb-dock-wrap">
|
|
202
|
+
<div className="rb-dock" ref={rootRef}>
|
|
203
|
+
{open && (
|
|
204
|
+
<div className="rb-pop rb-pop--up" id="rb-switch-pop" role="menu" aria-label="Switch project or version">
|
|
205
|
+
{/* ── Project ── */}
|
|
206
|
+
<div className="rb-pop-hd">Project</div>
|
|
207
|
+
{native && recents.length > 0 ? (
|
|
208
|
+
recents.map((p, i) => (
|
|
209
|
+
<button type="button" key={p} className={'rb-pop-item' + (i === 0 ? ' is-current' : '')} role="menuitem" onClick={() => switchRepo(p)}>
|
|
210
|
+
<span className="rb-pop-icon"><Icon name="folder" size={14} /></span>
|
|
211
|
+
<span className="rb-pop-tx">
|
|
212
|
+
<span className="rb-pop-name">{basename(p)}</span>
|
|
213
|
+
<span className="rb-pop-sub">{p}</span>
|
|
214
|
+
</span>
|
|
215
|
+
{i === 0 ? <Icon name="check" size={14} className="rb-pop-check" /> : null}
|
|
216
|
+
</button>
|
|
217
|
+
))
|
|
218
|
+
) : (
|
|
219
|
+
<div className="rb-pop-sub" style={{ padding: 'var(--space-2) var(--space-3)' }}>{native ? 'No other recent projects.' : 'Open another project from the desktop app.'}</div>
|
|
220
|
+
)}
|
|
221
|
+
{native && (
|
|
222
|
+
<button type="button" className="rb-pop-item rb-pop-item--action" role="menuitem" onClick={openAnother}>
|
|
223
|
+
<span className="rb-pop-icon"><Icon name="folder-open" size={14} /></span>
|
|
224
|
+
<span className="rb-pop-tx"><span className="rb-pop-name">Open another folder…</span></span>
|
|
225
|
+
</button>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
<div className="rb-pop-sep" />
|
|
229
|
+
<div className="rb-pop-hd">Version</div>
|
|
230
|
+
|
|
231
|
+
{onShared ? (
|
|
232
|
+
<>
|
|
233
|
+
<button type="button" className="rb-pop-item is-current" role="menuitem" aria-current="true" onClick={() => switchDraft(sharedName)}>
|
|
234
|
+
<span className="rb-pop-icon rb-pop-icon--shared"><Icon name="share" size={14} /></span>
|
|
235
|
+
<span className="rb-pop-tx">
|
|
236
|
+
<span className="rb-pop-name">Shared version</span>
|
|
237
|
+
<span className="rb-pop-sub">what everyone sees</span>
|
|
238
|
+
</span>
|
|
239
|
+
<Icon name="check" size={14} className="rb-pop-check" />
|
|
240
|
+
</button>
|
|
241
|
+
{drafts.length > 0 && <div className="rb-pop-grouplabel">Drafts</div>}
|
|
242
|
+
{drafts.map((b) => (
|
|
243
|
+
<button type="button" key={b.name} className="rb-pop-item" role="menuitem" onClick={() => switchDraft(b.name)}>
|
|
244
|
+
<span className="rb-pop-icon rb-pop-icon--draft"><Icon name="draft" size={14} /></span>
|
|
245
|
+
<span className="rb-pop-tx"><span className="rb-pop-name">{b.name}</span></span>
|
|
246
|
+
</button>
|
|
247
|
+
))}
|
|
248
|
+
</>
|
|
249
|
+
) : (
|
|
250
|
+
<>
|
|
251
|
+
{/* On a draft — it's the current row, and the one strong action is
|
|
252
|
+
folding it back into the Shared version (Task 7). */}
|
|
253
|
+
<button type="button" className="rb-pop-item is-current" role="menuitem" aria-current="true">
|
|
254
|
+
<span className="rb-pop-icon rb-pop-icon--draft"><Icon name="draft" size={14} /></span>
|
|
255
|
+
<span className="rb-pop-tx">
|
|
256
|
+
<span className="rb-pop-name">{currentDraft?.name || branch}</span>
|
|
257
|
+
<span className="rb-pop-sub">your draft</span>
|
|
258
|
+
</span>
|
|
259
|
+
<Icon name="check" size={14} className="rb-pop-check" />
|
|
260
|
+
</button>
|
|
261
|
+
<button type="button" className="rb-fold" role="menuitem" onClick={() => { setOpen(false); setFoldConfirm(true); }}>
|
|
262
|
+
<span className="rb-fold-icon"><Icon name="arrow-up-to-line" size={15} /></span>
|
|
263
|
+
<span className="rb-fold-tx">
|
|
264
|
+
<span className="rb-fold-title">Add this draft to the Shared version</span>
|
|
265
|
+
<span className="rb-fold-sub">make it the version everyone works from</span>
|
|
266
|
+
</span>
|
|
267
|
+
</button>
|
|
268
|
+
<div className="rb-pop-grouplabel">Switch to</div>
|
|
269
|
+
<button type="button" className="rb-pop-item" role="menuitem" onClick={() => switchDraft(sharedName)}>
|
|
270
|
+
<span className="rb-pop-icon rb-pop-icon--shared"><Icon name="share" size={14} /></span>
|
|
271
|
+
<span className="rb-pop-tx">
|
|
272
|
+
<span className="rb-pop-name">Shared version</span>
|
|
273
|
+
<span className="rb-pop-sub">what everyone sees</span>
|
|
274
|
+
</span>
|
|
275
|
+
</button>
|
|
276
|
+
{otherDrafts.map((b) => (
|
|
277
|
+
<button type="button" key={b.name} className="rb-pop-item" role="menuitem" onClick={() => switchDraft(b.name)}>
|
|
278
|
+
<span className="rb-pop-icon rb-pop-icon--draft"><Icon name="draft" size={14} /></span>
|
|
279
|
+
<span className="rb-pop-tx"><span className="rb-pop-name">{b.name}</span></span>
|
|
280
|
+
</button>
|
|
281
|
+
))}
|
|
282
|
+
</>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
<button type="button" className="rb-pop-item rb-pop-item--action" role="menuitem" onClick={() => { setOpen(false); setNewDraft(true); }}>
|
|
286
|
+
<span className="rb-pop-icon"><Icon name="plus" size={14} /></span>
|
|
287
|
+
<span className="rb-pop-tx">
|
|
288
|
+
<span className="rb-pop-name">New draft</span>
|
|
289
|
+
<span className="rb-pop-sub">a separate line of work, just yours for now</span>
|
|
290
|
+
</span>
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{newDraft && (
|
|
296
|
+
<div className="rb-newdraft rb-newdraft--up">
|
|
297
|
+
<label className="rb-newdraft-field">
|
|
298
|
+
<span className="rb-newdraft-label">Name your draft</span>
|
|
299
|
+
<input className="input rb-newdraft-input" type="text" value={draftName} placeholder="Nav redesign" aria-label="Draft name" autoFocus onChange={(e) => setDraftName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && slug) createDraft(); if (e.key === 'Escape') { setNewDraft(false); setDraftName(''); } }} />
|
|
300
|
+
{slug && <span className="rb-pop-sub">Creates a draft called <b>{slug}</b></span>}
|
|
301
|
+
</label>
|
|
302
|
+
{err && <span className="rb-newdraft-err">{err}</span>}
|
|
303
|
+
<div className="rb-newdraft-actions">
|
|
304
|
+
<button type="button" className="btn btn--ghost btn--sm" onClick={() => { setNewDraft(false); setDraftName(''); setErr(''); }} disabled={busy}>Cancel</button>
|
|
305
|
+
<button type="button" className="btn btn--primary btn--sm" onClick={createDraft} disabled={busy || !slug}><Icon name="draft" size={13} /> {busy ? 'Creating…' : 'Create draft'}</button>
|
|
306
|
+
</div>
|
|
307
|
+
<p className="rb-newdraft-hint">A draft is your own copy to try things in. Add it to the Shared version when you're happy, or throw it away — nothing else changes.</p>
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
|
|
311
|
+
{switching ? (
|
|
312
|
+
<div className="rb-switching" role="status" aria-live="polite">
|
|
313
|
+
<Icon name="spinner" size={14} className="rb-spin" />
|
|
314
|
+
<span>{folding ? <>Adding <b>{folding}</b> to the Shared version…</> : <>Opening <b>{switching}</b>…</>}</span>
|
|
315
|
+
</div>
|
|
316
|
+
) : (
|
|
317
|
+
<button type="button" className={'rb-trigger' + (open ? ' is-open' : '')} aria-expanded={open} aria-haspopup="menu" aria-controls="rb-switch-pop" onClick={() => { setOpen((v) => !v); setNewDraft(false); }} title={`${projectName} · ${onShared ? 'Shared version' : branch}`}>
|
|
318
|
+
<span className="rb-trigger-icon"><Icon name="folder" size={14} /></span>
|
|
319
|
+
<span className="rb-trigger-proj">{projectName}</span>
|
|
320
|
+
<span className="rb-trigger-sep" aria-hidden="true">·</span>
|
|
321
|
+
<span className={'rb-trigger-ver' + (onShared ? '' : ' is-draft')}>
|
|
322
|
+
<Icon name={onShared ? 'share' : 'draft'} size={12} />
|
|
323
|
+
<span className="rb-trigger-ver-name">{onShared ? 'Shared version' : branch}</span>
|
|
324
|
+
</span>
|
|
325
|
+
<Icon name="chevron-up" size={13} className="rb-trigger-caret" />
|
|
326
|
+
</button>
|
|
327
|
+
)}
|
|
328
|
+
{err && !newDraft && !switching && <div className="rb-switcher-err" role="alert">{err}</div>}
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{/* Fold-back confirm — the one modal in this surface. Plain words, no merge UI. */}
|
|
332
|
+
{foldConfirm && (
|
|
333
|
+
<div className="rb-scrim" role="presentation" onClick={() => setFoldConfirm(false)}>
|
|
334
|
+
<div className="rb-sheet" role="dialog" aria-modal="true" aria-labelledby="rb-sheet-title" aria-describedby="rb-sheet-body" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Escape') setFoldConfirm(false); }}>
|
|
335
|
+
<span className="rb-sheet-icon"><Icon name="arrow-up-to-line" size={20} /></span>
|
|
336
|
+
<h2 className="rb-sheet-title" id="rb-sheet-title">Add this draft to the Shared version</h2>
|
|
337
|
+
<p className="rb-sheet-body" id="rb-sheet-body">Everything in <b>“{currentDraft?.name || branch}”</b> will become part of the Shared version everyone sees.</p>
|
|
338
|
+
<p className="rb-sheet-meta">Everyone else picks it up the next time they Pull changes, and this draft is then removed. Nothing else changes.</p>
|
|
339
|
+
{err && <p className="rb-newdraft-err">{err}</p>}
|
|
340
|
+
<div className="rb-sheet-actions">
|
|
341
|
+
<button type="button" className="btn btn--ghost" onClick={() => { setFoldConfirm(false); setErr(''); }}>Cancel</button>
|
|
342
|
+
<button type="button" className="btn btn--primary" onClick={foldDraft}><Icon name="arrow-up-to-line" size={15} /> Add to the Shared version</button>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
}
|