@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.
Files changed (93) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +788 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +796 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,71 @@
1
+ // Phase 29 (epic E4) Task 6 — the two-layer collab model, in one picture.
2
+ //
3
+ // Carries the whole mental model the collab tour teaches (see the teaching-model
4
+ // DDR): a LIVE layer (cursors · who's here · comments — automatic, no buttons)
5
+ // floating ABOVE the work cycle (Save changes locally → Publish for everyone →
6
+ // Pull changes — visible, has buttons). The two layers are NEVER mixed into one
7
+ // diagram — that split is the whole point. Rendered as the centered tour step's
8
+ // graphic (overlay.jsx `render` field) and embeddable elsewhere (e.g. the wizard
9
+ // success state). Pure presentational; cm-* classes live in 3-shell-maude.css.
10
+
11
+ function Glyph({ name, size = 16 }) {
12
+ const p = {
13
+ cursor: <path d="M3 2.5l9 4.2-3.8 1.2-1.2 3.8z" />,
14
+ people: (<><circle cx="6" cy="6" r="2.2" /><path d="M2.4 13a3.6 3.6 0 0 1 7.2 0" /><path d="M11 4.2a2.2 2.2 0 0 1 0 4.1M11.5 13a3.6 3.6 0 0 0-2-3.2" /></>),
15
+ comment: (<><path d="M2.5 3.5h11v7h-6l-3 2.2V10.5h-2z" /></>),
16
+ save: (<><path d="M3 2.5h7.5L13.5 5.5V13.5H3z" /><path d="M5 2.5V6h5V2.5" /><rect x="5.5" y="9" width="5" height="3" /></>),
17
+ publish: (<><line x1="8" y1="13.4" x2="8" y2="6" /><polyline points="5 9 8 6 11 9" /><polyline points="3 4 3 2.6 13 2.6 13 4" /></>),
18
+ download: (<><line x1="8" y1="2.5" x2="8" y2="10" /><polyline points="4.5 7 8 10.5 11.5 7" /><polyline points="3 12.8 3 13.6 13 13.6 13 12.8" /></>),
19
+ 'arrow-right': (<><line x1="2.5" y1="8" x2="13" y2="8" /><polyline points="9 4 13 8 9 12" /></>),
20
+ }[name];
21
+ return (
22
+ <svg width={size} height={size} viewBox="0 0 16 16" fill={name === 'cursor' ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
23
+ {p}
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ function CycleStep({ icon, label, sub }) {
29
+ return (
30
+ <span className="cm-step">
31
+ <span className="cm-step-icon"><Glyph name={icon} size={16} /></span>
32
+ <span className="cm-step-label">{label}</span>
33
+ <span className="cm-step-sub">{sub}</span>
34
+ </span>
35
+ );
36
+ }
37
+
38
+ export default function CollabModelInfographic() {
39
+ return (
40
+ <div className="cm-info">
41
+ {/* LIVE layer — floats above the work; presented as automatic. */}
42
+ <div className="cm-live">
43
+ <div className="cm-live-hd">
44
+ <span className="cm-live-dot" aria-hidden="true" />
45
+ <span><b>Together</b> · automatic — no buttons</span>
46
+ </div>
47
+ <div className="cm-live-items">
48
+ <span className="cm-live-item"><Glyph name="cursor" size={13} /> cursors</span>
49
+ <span className="cm-live-item"><Glyph name="people" size={13} /> who's here</span>
50
+ <span className="cm-live-item"><Glyph name="comment" size={13} /> comments</span>
51
+ </div>
52
+ <div className="cm-live-note">When you're both here, you see each other instantly.</div>
53
+ </div>
54
+
55
+ <div className="cm-bridge" aria-hidden="true">
56
+ <span className="cm-bridge-line" />
57
+ <span className="cm-bridge-label">the work itself</span>
58
+ <span className="cm-bridge-line" />
59
+ </div>
60
+
61
+ {/* THE WORK — the visible three-verb cycle. */}
62
+ <div className="cm-cycle">
63
+ <CycleStep icon="save" label="Save changes locally" sub="keep a version on your machine" />
64
+ <span className="cm-cyc-arrow" aria-hidden="true"><Glyph name="arrow-right" size={15} /></span>
65
+ <CycleStep icon="publish" label="Publish for everyone" sub="share it with the team" />
66
+ <span className="cm-cyc-arrow" aria-hidden="true"><Glyph name="arrow-right" size={15} /></span>
67
+ <CycleStep icon="download" label="Pull changes" sub="get everyone else's work" />
68
+ </div>
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,334 @@
1
+ // Phase 28 (epic E3) Task 6 — start / pull / share a project.
2
+ //
3
+ // Opened from the IdentityBar account menu. Three views, all in the shell maude DS
4
+ // dialog treatment (solid var(--bg-1) surface + .st-dialog scrim — NOT the canvas
5
+ // `.panel`; inputs use the DS .input/.textarea recipe defined in 3-shell-maude.css):
6
+ // • new — name + Private(default)/Public + description → POST create-repo.
7
+ // • get — "Pull a local copy": pick one of your GitHub projects, choose WHERE on
8
+ // disk to save it, clone it, and open it. Distinct from File ▸ Open
9
+ // Project (a folder you already have). A pulled repo with no Maude
10
+ // design system yet shows a clear "set it up" message (never a crash).
11
+ // • share — invite a teammate by GitHub username → POST invite.
12
+
13
+ import { useEffect, useState } from 'react';
14
+
15
+ import { cloneRepo, createProject, initDesign, invite, listRepos, openLocalProject, pickDirectory } from '../github.js';
16
+
17
+ function Icon({ name, size = 16 }) {
18
+ const p = {
19
+ lock: (
20
+ <>
21
+ <rect x="3.5" y="7" width="9" height="6.5" rx="1.2" />
22
+ <path d="M5.5 7V5.2a2.5 2.5 0 0 1 5 0V7" />
23
+ </>
24
+ ),
25
+ globe: (
26
+ <>
27
+ <circle cx="8" cy="8" r="5.5" />
28
+ <path d="M2.5 8h11M8 2.5c1.7 1.5 2.6 3.5 2.6 5.5S9.7 12.5 8 13.5C6.3 12 5.4 10 5.4 8S6.3 3.5 8 2.5z" />
29
+ </>
30
+ ),
31
+ x: (
32
+ <>
33
+ <line x1="3.5" y1="3.5" x2="12.5" y2="12.5" />
34
+ <line x1="12.5" y1="3.5" x2="3.5" y2="12.5" />
35
+ </>
36
+ ),
37
+ check: <polyline points="3 8.2 6.4 11.5 13 4.2" />,
38
+ plus: (
39
+ <>
40
+ <line x1="8" y1="3" x2="8" y2="13" />
41
+ <line x1="3" y1="8" x2="13" y2="8" />
42
+ </>
43
+ ),
44
+ invite: (
45
+ <>
46
+ <circle cx="6" cy="5.5" r="2.5" />
47
+ <path d="M2 13.5a4 4 0 0 1 8 0" />
48
+ <line x1="13" y1="5" x2="13" y2="9" />
49
+ <line x1="11" y1="7" x2="15" y2="7" />
50
+ </>
51
+ ),
52
+ download: (
53
+ <>
54
+ <line x1="8" y1="2.5" x2="8" y2="10" />
55
+ <polyline points="4.5 7 8 10.5 11.5 7" />
56
+ <polyline points="3 12.8 3 13.6 13 13.6 13 12.8" />
57
+ </>
58
+ ),
59
+ spinner: <path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8" />,
60
+ }[name];
61
+ return (
62
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={name === 'spinner' ? 'cp-spin' : undefined}>
63
+ {p}
64
+ </svg>
65
+ );
66
+ }
67
+
68
+ const TITLES = {
69
+ new: ['Create a new project', 'A private project on GitHub, set up for you.'],
70
+ get: ['Pull a local copy', 'Pick a project, choose where to save it, and open it here.'],
71
+ share: ['Share this project', 'Invite a teammate by their GitHub username.'],
72
+ };
73
+
74
+ export default function CreateProject({ view, identity, onClose }) {
75
+ const [title, sub] = TITLES[view] || TITLES.new;
76
+ return (
77
+ <div className="cp-modal" role="dialog" aria-modal="true" aria-label={title} onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}>
78
+ <div className="cp-scrim" aria-hidden="true" onClick={onClose} />
79
+ <div className="cp-dialog">
80
+ <div className="cp-dialog-hd">
81
+ <span className="cp-dialog-titles">
82
+ <h2>{title}</h2>
83
+ <p>{sub}</p>
84
+ </span>
85
+ <button type="button" className="btn btn--icon" aria-label="Close" onClick={onClose}>
86
+ <Icon name="x" size={15} />
87
+ </button>
88
+ </div>
89
+ {view === 'new' && <NewView identity={identity} onClose={onClose} />}
90
+ {view === 'get' && <GetView />}
91
+ {view === 'share' && <ShareView onClose={onClose} />}
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ function NewView({ identity, onClose }) {
98
+ const [name, setName] = useState('');
99
+ const [isPrivate, setIsPrivate] = useState(true);
100
+ const [desc, setDesc] = useState('');
101
+ const [busy, setBusy] = useState(false);
102
+ const [step, setStep] = useState('');
103
+ const [err, setErr] = useState('');
104
+
105
+ const slug = name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
106
+ const owner = identity?.login || 'you';
107
+
108
+ // New project = create the GitHub repo AND a ready-to-design local project, then
109
+ // open it. You pick where to save it (like a real "new project"), then design
110
+ // and Publish when ready.
111
+ async function submit() {
112
+ setErr('');
113
+ setBusy(true);
114
+ try {
115
+ setStep('Choose where to save it…');
116
+ const parentDir = await pickDirectory();
117
+ if (!parentDir) { setBusy(false); setStep(''); return; } // cancelled
118
+ setStep('Creating your project on GitHub…');
119
+ const r = await createProject({ name, private: isPrivate, description: desc, parentDir });
120
+ if (!(r.ok && r.json?.ok)) {
121
+ setErr(r.json?.error || 'Couldn’t create the project. Try again.');
122
+ setBusy(false);
123
+ setStep('');
124
+ return;
125
+ }
126
+ setStep('Opening it in Maude…');
127
+ await openLocalProject(r.json.path); // switches the app to the new project
128
+ } catch (e) {
129
+ setErr(String(e?.message || e || 'Something went wrong creating the project.'));
130
+ setBusy(false);
131
+ setStep('');
132
+ }
133
+ }
134
+
135
+ return (
136
+ <>
137
+ <div className="cp-body">
138
+ <label className="cp-field">
139
+ <span className="cp-field-label">Project name</span>
140
+ <input className="input cp-input" type="text" value={name} placeholder="Acme Rebrand" aria-label="Project name" onChange={(e) => setName(e.target.value)} />
141
+ {slug && <span className="cp-field-help">Creates <b>github.com/{owner}/{slug}</b></span>}
142
+ </label>
143
+ <div className="cp-field">
144
+ <span className="cp-field-label">Who can see it</span>
145
+ <div className="seg cp-seg" role="group" aria-label="Project visibility">
146
+ <button type="button" aria-pressed={isPrivate} onClick={() => setIsPrivate(true)}><Icon name="lock" size={14} /> Private</button>
147
+ <button type="button" aria-pressed={!isPrivate} onClick={() => setIsPrivate(false)}><Icon name="globe" size={14} /> Public</button>
148
+ </div>
149
+ <span className="cp-field-help">{isPrivate ? 'Only you and people you invite. The safe default.' : 'Anyone on the internet can see this project.'}</span>
150
+ </div>
151
+ <label className="cp-field">
152
+ <span className="cp-field-label">Description <span className="cp-optional">optional</span></span>
153
+ <textarea className="textarea cp-textarea" rows={2} value={desc} placeholder="What is this project for?" aria-label="Project description" onChange={(e) => setDesc(e.target.value)} />
154
+ </label>
155
+ {err && <div className="callout callout--error"><span className="cp-cl-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
156
+ </div>
157
+ <div className="cp-ft">
158
+ <span className="cp-spacer" />
159
+ <button type="button" className="btn btn--ghost" onClick={onClose}>Cancel</button>
160
+ <button type="button" className="btn btn--primary" onClick={submit} disabled={busy || !slug}>
161
+ <Icon name="plus" size={15} /> {busy ? step || 'Creating…' : 'Create project'}
162
+ </button>
163
+ </div>
164
+ </>
165
+ );
166
+ }
167
+
168
+ // "Pull a local copy" — pick a repo, choose a disk folder, clone, open.
169
+ function GetView() {
170
+ const [repos, setRepos] = useState(null);
171
+ const [err, setErr] = useState('');
172
+ const [pulling, setPulling] = useState(null); // full_name being pulled
173
+ const [step, setStep] = useState(''); // human progress line
174
+ const [needsSetup, setNeedsSetup] = useState(null); // { name, path } when the pulled repo has no .design
175
+ const [settingUp, setSettingUp] = useState(false);
176
+
177
+ // The pulled repo has no Maude design system yet — scaffold a bootable one into
178
+ // the clone, then open it (the user can create a real DS with /design:setup-ds).
179
+ async function setupHere() {
180
+ if (!needsSetup) return;
181
+ setErr('');
182
+ setSettingUp(true);
183
+ try {
184
+ const r = await initDesign(needsSetup.path);
185
+ if (!(r.ok && r.json?.ok)) {
186
+ setErr(r.json?.error || 'Couldn’t set it up. Try again.');
187
+ setSettingUp(false);
188
+ return;
189
+ }
190
+ await openLocalProject(needsSetup.path); // switches the app to the new project
191
+ } catch (e) {
192
+ setErr(String(e?.message || e || 'Couldn’t set it up.'));
193
+ setSettingUp(false);
194
+ }
195
+ }
196
+
197
+ useEffect(() => {
198
+ (async () => {
199
+ const r = await listRepos();
200
+ if (r.ok && r.json?.ok) setRepos(r.json.repos || []);
201
+ else { setErr(r.json?.error || 'Couldn’t load your projects.'); setRepos([]); }
202
+ })();
203
+ }, []);
204
+
205
+ async function pull(repo) {
206
+ setErr('');
207
+ setNeedsSetup(null);
208
+ setPulling(repo.full_name);
209
+ try {
210
+ setStep('Choose a folder to save it in…');
211
+ const parentDir = await pickDirectory();
212
+ if (!parentDir) { setPulling(null); setStep(''); return; } // cancelled
213
+ setStep('Downloading your project…');
214
+ const r = await cloneRepo({ cloneUrl: repo.clone_url, parentDir, name: repo.name });
215
+ if (!(r.ok && r.json?.ok)) {
216
+ setErr(r.json?.error || 'Couldn’t download the project. Try again.');
217
+ setPulling(null);
218
+ setStep('');
219
+ return;
220
+ }
221
+ if (r.json.hasDesign === false) {
222
+ // Empty / non-Maude repo — DON'T switch (the dev-server can't serve a project
223
+ // with no .design). Tell the user how to set it up instead of crashing.
224
+ setNeedsSetup({ name: repo.name, path: r.json.path });
225
+ setPulling(null);
226
+ setStep('');
227
+ return;
228
+ }
229
+ setStep('Opening it in Maude…');
230
+ await openLocalProject(r.json.path); // switches the app to the clone
231
+ } catch (e) {
232
+ setErr(String(e?.message || e || 'Something went wrong pulling the project.'));
233
+ setPulling(null);
234
+ setStep('');
235
+ }
236
+ }
237
+
238
+ if (needsSetup) {
239
+ return (
240
+ <>
241
+ <div className="cp-body">
242
+ <div className="callout callout--info">
243
+ <span className="cp-cl-glyph" style={{ color: 'var(--status-info)' }}><Icon name="download" /></span>
244
+ <span>
245
+ <b style={{ color: 'var(--fg-0)' }}>Got “{needsSetup.name}” — it’s not a Maude project yet.</b> Saved to{' '}
246
+ <b>{needsSetup.path}</b>. Set up Maude in it to start designing (you can build a design system after).
247
+ </span>
248
+ </div>
249
+ {err && <div className="callout callout--error"><span className="cp-cl-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
250
+ </div>
251
+ <div className="cp-ft">
252
+ <span className="cp-spacer" />
253
+ <button type="button" className="btn btn--ghost" onClick={() => setNeedsSetup(null)} disabled={settingUp}>Not now</button>
254
+ <button type="button" className="btn btn--primary" onClick={setupHere} disabled={settingUp}>
255
+ <Icon name="download" size={15} /> {settingUp ? 'Setting up…' : 'Set up Maude here'}
256
+ </button>
257
+ </div>
258
+ </>
259
+ );
260
+ }
261
+
262
+ return (
263
+ <div className="cp-body cp-body--list">
264
+ {err && <div className="callout callout--error"><span className="cp-cl-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
265
+ {repos === null && <div className="cp-field-help">Loading your projects…</div>}
266
+ {repos && repos.length === 0 && !err && <div className="cp-field-help">No projects yet — create one to get started.</div>}
267
+ {repos && repos.length > 0 && (
268
+ <div className="cp-repolist" role="group" aria-label="Your projects">
269
+ {repos.map((r) => {
270
+ const isPulling = pulling === r.full_name;
271
+ return (
272
+ <button
273
+ key={r.full_name}
274
+ type="button"
275
+ className={'cp-repo' + (isPulling ? ' is-busy' : '')}
276
+ disabled={!!pulling}
277
+ onClick={() => pull(r)}
278
+ >
279
+ <Icon name={r.private ? 'lock' : 'globe'} size={14} />
280
+ <span className="cp-repo-tx">
281
+ <span className="cp-repo-name">{r.name}</span>
282
+ <span className="cp-repo-meta">{isPulling ? step || 'Working…' : `${r.owner} · updated ${new Date(r.updated_at).toLocaleDateString()}`}</span>
283
+ </span>
284
+ <span className="cp-repo-go">{isPulling ? <Icon name="spinner" size={15} /> : <Icon name="download" size={15} />}</span>
285
+ </button>
286
+ );
287
+ })}
288
+ </div>
289
+ )}
290
+ <div className="cp-field-help">Pulls a fresh copy from GitHub to a folder you choose, then opens it. To open a folder you already have, use <b>File ▸ Open Project</b>.</div>
291
+ </div>
292
+ );
293
+ }
294
+
295
+ function ShareView({ onClose }) {
296
+ const [username, setUsername] = useState('');
297
+ const [busy, setBusy] = useState(false);
298
+ const [err, setErr] = useState('');
299
+ const [invited, setInvited] = useState(null);
300
+
301
+ async function submit() {
302
+ setErr('');
303
+ setBusy(true);
304
+ const r = await invite(username);
305
+ setBusy(false);
306
+ if (r.ok && r.json?.ok) setInvited(r.json.username);
307
+ else setErr(r.json?.error || 'Couldn’t send the invite. Try again.');
308
+ }
309
+
310
+ return (
311
+ <>
312
+ <div className="cp-body">
313
+ <div className="cp-invite">
314
+ <span className="cp-invite-at" aria-hidden="true">@</span>
315
+ <input className="input cp-invite-input" type="text" value={username} placeholder="github-username" aria-label="GitHub username to invite" onChange={(e) => setUsername(e.target.value)} />
316
+ <button type="button" className="btn btn--primary cp-invite-btn" onClick={submit} disabled={busy || !username.trim()}>
317
+ <Icon name="invite" size={15} /> {busy ? 'Inviting…' : 'Invite'}
318
+ </button>
319
+ </div>
320
+ {invited && (
321
+ <div className="callout callout--success">
322
+ <span className="cp-cl-glyph" style={{ color: 'var(--status-success)' }}><Icon name="check" /></span>
323
+ <span><b style={{ color: 'var(--fg-0)' }}>Invited @{invited}.</b> They’ll get a GitHub email and can open this project once they accept.</span>
324
+ </div>
325
+ )}
326
+ {err && <div className="callout callout--error"><span className="cp-cl-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
327
+ </div>
328
+ <div className="cp-ft">
329
+ <span className="cp-spacer" />
330
+ <button type="button" className="btn btn--primary" onClick={onClose}>Done</button>
331
+ </div>
332
+ </>
333
+ );
334
+ }