@1agh/maude 0.30.0 → 0.31.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 (90) 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 +781 -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 +770 -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 +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,767 @@
1
+ // Phase 27 (epic E2) Task 6 — the in-Maude git-awareness panel.
2
+ //
3
+ // Built 1:1 from the approved `.design/ui/GitPanel.tsx` mock (Changes panel ·
4
+ // Save a version · Publish + Get-latest nudge · Nothing-to-save · Publish-
5
+ // rejected), wired to live git state. Vocabulary is load-bearing and NEVER leaks
6
+ // git jargon: Save version (commit) · Publish (push) · Get latest (pull) ·
7
+ // History (log) · Unsaved (dirty M/A/D/U) · Draft (branch). Renders in the
8
+ // shell's right dock (st-rpanel), driven by the `git-status` WS broadcast.
9
+
10
+ import { useEffect, useMemo, useRef, useState } from 'react';
11
+
12
+ import { baseName, buildUnits, supportLabel } from './git-grouping.js';
13
+
14
+ const KIND_OF = { modified: 'M', added: 'A', deleted: 'D', untracked: 'U' };
15
+ const KIND_TITLE = { M: 'Modified', A: 'Added', D: 'Deleted', U: 'Untracked' };
16
+
17
+ function Icon({ name, size = 16, className }) {
18
+ const p = {
19
+ save: (
20
+ <>
21
+ <path d="M3 3h8l2 2v8H3z" />
22
+ <polyline points="5 3 5 6 10 6" />
23
+ <rect x="5.5" y="9" width="5" height="3.5" />
24
+ </>
25
+ ),
26
+ publish: (
27
+ <>
28
+ <line x1="8" y1="13" x2="8" y2="3.5" />
29
+ <polyline points="4.5 7 8 3.5 11.5 7" />
30
+ <polyline points="3 12.8 3 13.6 13 13.6 13 12.8" />
31
+ </>
32
+ ),
33
+ download: (
34
+ <>
35
+ <line x1="8" y1="2.5" x2="8" y2="10" />
36
+ <polyline points="4.5 7 8 10.5 11.5 7" />
37
+ <polyline points="3 12.8 3 13.6 13 13.6 13 12.8" />
38
+ </>
39
+ ),
40
+ history: (
41
+ <>
42
+ <path d="M3.2 8a5 5 0 1 1 1.4 3.5" />
43
+ <polyline points="3.2 11.4 3.2 8 6.6 8" />
44
+ <polyline points="8 5.5 8 8 10 9.3" />
45
+ </>
46
+ ),
47
+ undo: (
48
+ <>
49
+ <path d="M5.5 6H10a3.4 3.4 0 0 1 0 6.8H6.2" />
50
+ <polyline points="5.5 3.6 3 6 5.5 8.4" />
51
+ </>
52
+ ),
53
+ diff: (
54
+ <>
55
+ <rect x="2.5" y="3" width="4.5" height="10" rx="1" />
56
+ <rect x="9" y="3" width="4.5" height="10" rx="1" />
57
+ </>
58
+ ),
59
+ file: (
60
+ <>
61
+ <path d="M4 2h5l3 3v9H4z" />
62
+ <polyline points="9 2 9 5 12 5" />
63
+ </>
64
+ ),
65
+ folder: <path d="M2 4.5h4l1.3 1.5H14V13H2z" />,
66
+ check: <polyline points="3 8.2 6.4 11.5 13 4.2" />,
67
+ chevron: <polyline points="6 4 10 8 6 12" />,
68
+ }[name];
69
+ return (
70
+ <svg
71
+ className={className}
72
+ width={size}
73
+ height={size}
74
+ viewBox="0 0 16 16"
75
+ fill="none"
76
+ stroke="currentColor"
77
+ strokeWidth="1.4"
78
+ strokeLinecap="round"
79
+ strokeLinejoin="round"
80
+ aria-hidden="true"
81
+ >
82
+ {p}
83
+ </svg>
84
+ );
85
+ }
86
+
87
+ function Badge({ kind }) {
88
+ return (
89
+ <span
90
+ className="gp-badge"
91
+ data-kind={kind}
92
+ title={`${KIND_TITLE[kind]} — unsaved`}
93
+ aria-label={`${KIND_TITLE[kind]}, unsaved`}
94
+ >
95
+ {kind}
96
+ </span>
97
+ );
98
+ }
99
+
100
+ /** A checkbox that can show the indeterminate ("some") state — React has no
101
+ * prop for it, so it's set on the DOM node via a ref. */
102
+ function TriCheck({ state, onChange, ariaLabel }) {
103
+ const ref = useRef(null);
104
+ useEffect(() => {
105
+ if (ref.current) ref.current.indeterminate = state === 'some';
106
+ }, [state]);
107
+ return (
108
+ <input
109
+ ref={ref}
110
+ type="checkbox"
111
+ className="gp-check"
112
+ checked={state === 'all'}
113
+ onChange={onChange}
114
+ aria-label={ariaLabel}
115
+ />
116
+ );
117
+ }
118
+
119
+ function timeAgo(iso) {
120
+ try {
121
+ const t = new Date(iso).getTime();
122
+ const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
123
+ if (s < 60) return 'just now';
124
+ const m = Math.floor(s / 60);
125
+ if (m < 60) return `${m}m ago`;
126
+ const h = Math.floor(m / 60);
127
+ if (h < 24) return `${h}h ago`;
128
+ const d = Math.floor(h / 24);
129
+ if (d === 1) return 'yesterday';
130
+ if (d < 7) return `${d} days ago`;
131
+ return new Date(iso).toLocaleDateString();
132
+ } catch {
133
+ return '';
134
+ }
135
+ }
136
+
137
+ export default function GitPanel({
138
+ status,
139
+ project,
140
+ width,
141
+ resizing,
142
+ onClose,
143
+ onCommit,
144
+ onDiscard,
145
+ onPublish,
146
+ onGetLatest,
147
+ loadLog,
148
+ onOpenCanvas,
149
+ onOpenDiff,
150
+ activeCanvas, // repo-relative path of the open canvas/specimen, or null
151
+ onPreviewVersion, // (sha) => open the active canvas at that saved version
152
+ designRel = '.design', // for canvas → annotation-slug matching (grouping)
153
+ // Web studio: awareness only. Keep the changed-files list, Diff and History,
154
+ // but drop every working-tree action (Save / Publish / Get latest / discard /
155
+ // checkboxes) — the developer commits and pushes from their terminal
156
+ // (DDR-119). Native app keeps the full plain-words cycle.
157
+ readOnly = false,
158
+ }) {
159
+ const [tab, setTab] = useState('changes');
160
+ const [message, setMessage] = useState('');
161
+ const [unchecked, setUnchecked] = useState(() => new Set()); // default = all checked
162
+ const [expanded, setExpanded] = useState(() => new Set()); // unit keys with supporting files shown
163
+ const [busy, setBusy] = useState(null);
164
+ const [banner, setBanner] = useState(null); // { variant, title?, text }
165
+ const [log, setLog] = useState(null);
166
+ const [logLoading, setLogLoading] = useState(false);
167
+ // The path the loaded `log` covers: a canvas path → per-file history (its
168
+ // versions are click-to-preview), '' → repo-wide read-only list. `undefined`
169
+ // = nothing loaded yet. (phase-27.1)
170
+ const [logScope, setLogScope] = useState(undefined);
171
+ const selectAllRef = useRef(null);
172
+
173
+ // History is scoped to the open canvas/specimen so each row previews THAT
174
+ // file at that saved version (the resolved "what does a commit row show?"
175
+ // ambiguity). With nothing open, fall back to the repo-wide read-only list.
176
+ const previewable = !!(activeCanvas && onPreviewVersion);
177
+ const historyScope = activeCanvas || '';
178
+ const activeName = activeCanvas ? baseName(activeCanvas) : '';
179
+
180
+ const files = status?.files ?? [];
181
+ const fileKey = files.map((f) => `${f.path}:${f.status}`).join('|');
182
+
183
+ useEffect(() => {
184
+ setUnchecked((prev) => {
185
+ const present = new Set(files.map((f) => f.path));
186
+ const next = new Set();
187
+ for (const p of prev) if (present.has(p)) next.add(p);
188
+ return next.size === prev.size ? prev : next;
189
+ });
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ }, [fileKey]);
192
+
193
+ const checkedPaths = useMemo(
194
+ () => files.map((f) => f.path).filter((p) => !unchecked.has(p)),
195
+ [files, unchecked]
196
+ );
197
+
198
+ const { canvasUnits, otherUnits } = useMemo(
199
+ () => buildUnits(files, designRel),
200
+ [files, designRel]
201
+ );
202
+ // Both section headers only when both kinds are present (a lone "Canvases"
203
+ // header over an all-canvas list is noise).
204
+ const showSectionHeads = canvasUnits.length > 0 && otherUnits.length > 0;
205
+
206
+ // A unit's aggregate check state across all its members (canvas + sidecars).
207
+ const unitState = (u) => {
208
+ const members = [u.primary, ...u.supporting];
209
+ const on = members.filter((m) => !unchecked.has(m.path)).length;
210
+ return on === 0 ? 'none' : on === members.length ? 'all' : 'some';
211
+ };
212
+ const toggleUnit = (u) => {
213
+ const members = [u.primary, ...u.supporting].map((m) => m.path);
214
+ const allOn = unitState(u) === 'all';
215
+ setUnchecked((prev) => {
216
+ const next = new Set(prev);
217
+ for (const p of members) {
218
+ if (allOn) next.add(p);
219
+ else next.delete(p);
220
+ }
221
+ return next;
222
+ });
223
+ };
224
+ const toggleExpand = (key) =>
225
+ setExpanded((prev) => {
226
+ const next = new Set(prev);
227
+ if (next.has(key)) next.delete(key);
228
+ else next.add(key);
229
+ return next;
230
+ });
231
+
232
+ // One canvas (or loose file) + its collapsed supporting files. The unit
233
+ // checkbox is the single control — a canvas and its sidecars Save as one unit
234
+ // (DDR-112); supporting files render read-only when expanded, for transparency.
235
+ const renderUnit = (u) => {
236
+ const f = u.primary;
237
+ const st = unitState(u);
238
+ const hasKids = u.supporting.length > 0;
239
+ const isOpen = expanded.has(u.key);
240
+ const name = baseName(f.path);
241
+ const memberPaths = [f, ...u.supporting].map((m) => m.path);
242
+ return (
243
+ <div className="gp-unit" key={u.key}>
244
+ <div className={'gp-file gp-unit-hd' + (!readOnly && st !== 'none' ? ' is-checked' : '')}>
245
+ {!readOnly && (
246
+ <TriCheck
247
+ state={st}
248
+ ariaLabel={`Include ${name}${hasKids ? ' and its supporting files' : ''} in this version`}
249
+ onChange={() => toggleUnit(u)}
250
+ />
251
+ )}
252
+ <Badge kind={KIND_OF[f.status]} />
253
+ <button
254
+ type="button"
255
+ className="gp-file-text"
256
+ title={f.path}
257
+ onClick={() => onOpenCanvas?.(f.path)}
258
+ >
259
+ <span className="gp-file-name">{name}</span>
260
+ <span className="gp-file-path">
261
+ {hasKids ? `${f.path} · +${u.supporting.length} supporting` : f.path}
262
+ </span>
263
+ </button>
264
+ {hasKids && (
265
+ <button
266
+ type="button"
267
+ className={'gp-disclose' + (isOpen ? ' is-open' : '')}
268
+ aria-expanded={isOpen}
269
+ aria-label={
270
+ isOpen ? `Hide supporting files for ${name}` : `Show supporting files for ${name}`
271
+ }
272
+ onClick={() => toggleExpand(u.key)}
273
+ >
274
+ <Icon name="chevron" size={14} />
275
+ </button>
276
+ )}
277
+ {u.kind === 'canvas' && (
278
+ <button
279
+ type="button"
280
+ className="gp-discard"
281
+ title="Compare before / after"
282
+ aria-label={`Compare ${name}`}
283
+ onClick={() => onOpenDiff?.(f.path)}
284
+ >
285
+ <Icon name="diff" size={14} />
286
+ </button>
287
+ )}
288
+ {!readOnly && (
289
+ <button
290
+ type="button"
291
+ className="gp-discard"
292
+ title={hasKids ? 'Discard this canvas and its supporting files' : 'Discard this change'}
293
+ aria-label={`Discard changes to ${name}`}
294
+ onClick={async () => {
295
+ const msg = hasKids
296
+ ? `Discard your changes to “${name}” and its supporting files? This can't be undone.`
297
+ : `Discard your changes to “${name}”? This can't be undone.`;
298
+ if (!window.confirm(msg)) return;
299
+ await run('discard', () => onDiscard(memberPaths));
300
+ }}
301
+ >
302
+ <Icon name="undo" size={14} />
303
+ </button>
304
+ )}
305
+ </div>
306
+ {hasKids && isOpen && (
307
+ <div className="gp-support" role="group" aria-label={`Supporting files for ${name}`}>
308
+ {u.supporting.map((s) => (
309
+ <div className="gp-support-row" key={s.path} title={s.path}>
310
+ <Badge kind={KIND_OF[s.status]} />
311
+ <span className="gp-support-text">
312
+ <span className="gp-file-name">{supportLabel(s.path)}</span>
313
+ <span className="gp-file-path">{s.path}</span>
314
+ </span>
315
+ </div>
316
+ ))}
317
+ </div>
318
+ )}
319
+ </div>
320
+ );
321
+ };
322
+
323
+ // Select-all checkbox: indeterminate when partial (set via ref — no React prop).
324
+ useEffect(() => {
325
+ if (selectAllRef.current)
326
+ selectAllRef.current.indeterminate =
327
+ checkedPaths.length > 0 && checkedPaths.length < files.length;
328
+ }, [checkedPaths.length, files.length]);
329
+
330
+ async function run(kind, fn, ok) {
331
+ setBusy(kind);
332
+ setBanner(null);
333
+ try {
334
+ const res = (await fn()) || {};
335
+ if (res.ok) {
336
+ if (ok) setBanner(ok);
337
+ // Save version / Publish / Get latest change the commit log — invalidate the
338
+ // scope-cached History so it re-fetches (the scope itself didn't change, so
339
+ // the load-on-scope-change effect wouldn't otherwise pick up the new commit).
340
+ setLogScope(undefined);
341
+ return res;
342
+ }
343
+ if (res.authRequired)
344
+ setBanner({
345
+ variant: 'info',
346
+ text: res.error || 'Sign in with GitHub to publish — coming soon.',
347
+ });
348
+ else if (res.conflict)
349
+ // A push conflict (non-fast-forward) prompts a Get-latest; a Get-latest
350
+ // CONTENT conflict instead opens the DiffView resolver (onGetLatest sets
351
+ // it), so the banner just points there — never the publish copy, and no
352
+ // Get-latest button (that would loop).
353
+ setBanner(
354
+ kind === 'getLatest'
355
+ ? {
356
+ variant: 'info',
357
+ title: 'You both changed this',
358
+ text: 'Pick what to keep in the window that just opened.',
359
+ }
360
+ : {
361
+ variant: 'warn',
362
+ title: "Publish didn't go through",
363
+ text: 'The shared project moved on while you were working. Get the latest, then publish yours on top.',
364
+ getLatest: true,
365
+ }
366
+ );
367
+ else setBanner({ variant: 'error', text: res.error || 'Something went wrong.' });
368
+ return res;
369
+ } finally {
370
+ setBusy(null);
371
+ }
372
+ }
373
+
374
+ function openHistory() {
375
+ setTab('history');
376
+ }
377
+
378
+ // Load (or reload) History whenever it's the active tab and the scope changes
379
+ // — e.g. the user opens a different canvas while History is showing, so the
380
+ // listed versions always match the file the rows will preview.
381
+ useEffect(() => {
382
+ if (tab !== 'history') return;
383
+ if (logScope === historyScope) return; // already current for this scope
384
+ let cancelled = false;
385
+ setLogLoading(true);
386
+ (async () => {
387
+ const entries = (await loadLog(historyScope || undefined)) || [];
388
+ if (cancelled) return;
389
+ setLog(entries);
390
+ setLogScope(historyScope);
391
+ setLogLoading(false);
392
+ })();
393
+ return () => {
394
+ cancelled = true;
395
+ };
396
+ }, [tab, historyScope, logScope, loadLog]);
397
+
398
+ function toggleAll() {
399
+ setUnchecked((prev) => (prev.size === 0 ? new Set(files.map((f) => f.path)) : new Set()));
400
+ }
401
+
402
+ const count = files.length;
403
+ const branch = status?.branch || 'main';
404
+ const unpushed = status?.unpushed || 0;
405
+ const canSave = message.trim().length > 0 && checkedPaths.length > 0 && !busy;
406
+ const notRepo = status && status.repo === false;
407
+ const clean = count === 0;
408
+
409
+ const publishBar = (
410
+ <div className="gp-publishbar">
411
+ <button
412
+ type="button"
413
+ className="btn btn--primary gp-publish"
414
+ data-tour="publish"
415
+ disabled={!!busy}
416
+ onClick={() =>
417
+ run('publish', onPublish, {
418
+ variant: 'success',
419
+ text: 'Published — your team can Get latest.',
420
+ })
421
+ }
422
+ >
423
+ <Icon name="publish" size={15} /> Publish changes
424
+ </button>
425
+ <span className="gp-hint" style={{ textAlign: 'center' }}>
426
+ {unpushed > 0
427
+ ? `Sends your ${unpushed} saved version${unpushed === 1 ? '' : 's'} to the shared project so the team can get them.`
428
+ : 'Sends your saved versions to the shared project so the team can get them.'}
429
+ </span>
430
+ </div>
431
+ );
432
+
433
+ return (
434
+ <aside
435
+ className={'st-rpanel gp-panel' + (resizing ? ' is-resizing' : '')}
436
+ style={width ? { width, flexBasis: width } : undefined}
437
+ aria-label="Changes"
438
+ >
439
+ <div className="gp-head">
440
+ <div className="gp-panel-hd">
441
+ <span className="gp-panel-title">Changes</span>
442
+ {count > 0 && <span className="gp-count">{count} unsaved</span>}
443
+ <span className="gp-spacer" />
444
+ <span className="gp-draft" title="Your project and shared draft">
445
+ <Icon name="folder" size={12} />
446
+ {project ? (
447
+ <>
448
+ <b>{project}</b>
449
+ <span className="gp-sep">/</span>
450
+ </>
451
+ ) : null}
452
+ {branch}
453
+ </span>
454
+ <button type="button" className="gp-x" aria-label="Close" onClick={onClose}>
455
+ ×
456
+ </button>
457
+ </div>
458
+ <div className="gp-tabs" role="tablist" aria-label="Changes and history">
459
+ <button
460
+ type="button"
461
+ role="tab"
462
+ aria-selected={tab === 'changes'}
463
+ className={'gp-tab' + (tab === 'changes' ? ' is-active' : '')}
464
+ onClick={() => setTab('changes')}
465
+ >
466
+ Changes
467
+ </button>
468
+ <button
469
+ type="button"
470
+ role="tab"
471
+ aria-selected={tab === 'history'}
472
+ className={'gp-tab' + (tab === 'history' ? ' is-active' : '')}
473
+ onClick={openHistory}
474
+ >
475
+ History
476
+ </button>
477
+ </div>
478
+ </div>
479
+
480
+ {banner && (
481
+ <div className="gp-pad">
482
+ <div className={`callout callout--${banner.variant}`} role="status" aria-live="polite">
483
+ <div className="gp-callout-col">
484
+ <span>
485
+ {banner.title && (
486
+ <strong style={{ display: 'block', marginBottom: 'var(--space-1)', color: 'var(--fg-0)' }}>
487
+ {banner.title}
488
+ </strong>
489
+ )}
490
+ {banner.text}
491
+ </span>
492
+ {banner.getLatest && (
493
+ <div className="gp-callout-actions">
494
+ <button
495
+ type="button"
496
+ className="btn btn--sm"
497
+ disabled={!!busy}
498
+ onClick={() =>
499
+ run('getLatest', onGetLatest, {
500
+ variant: 'success',
501
+ text: 'Up to date with everyone.',
502
+ })
503
+ }
504
+ >
505
+ <Icon name="download" size={13} /> Get latest
506
+ </button>
507
+ </div>
508
+ )}
509
+ </div>
510
+ <button
511
+ type="button"
512
+ className="gp-x"
513
+ aria-label="Dismiss"
514
+ onClick={() => setBanner(null)}
515
+ >
516
+ ×
517
+ </button>
518
+ </div>
519
+ </div>
520
+ )}
521
+
522
+ {tab === 'changes' ? (
523
+ notRepo ? (
524
+ <div className="gp-empty">
525
+ <span className="gp-empty-glyph">
526
+ <Icon name="folder" size={24} />
527
+ </span>
528
+ <h3>Not versioned yet</h3>
529
+ <p>Once this is a Maude project, your changes show up here to Save and Publish.</p>
530
+ </div>
531
+ ) : clean ? (
532
+ unpushed > 0 ? (
533
+ <>
534
+ <div className="gp-empty">
535
+ <span className="gp-empty-glyph gp-empty-glyph--publish">
536
+ <Icon name="publish" size={24} />
537
+ </span>
538
+ <h3>
539
+ {unpushed} version{unpushed === 1 ? '' : 's'} ready to publish
540
+ </h3>
541
+ <p>
542
+ Everything's saved, but your work isn't shared yet. Publish it so the team can Get
543
+ latest.
544
+ </p>
545
+ <button type="button" className="btn btn--ghost btn--sm" onClick={openHistory}>
546
+ <Icon name="history" size={14} /> View History
547
+ </button>
548
+ </div>
549
+ {!readOnly && publishBar}
550
+ </>
551
+ ) : (
552
+ <div className="gp-empty">
553
+ <span className="gp-empty-glyph">
554
+ <Icon name="check" size={26} />
555
+ </span>
556
+ <h3>Nothing to save</h3>
557
+ <p>Every change is saved. When you edit a canvas, it shows up here.</p>
558
+ {!readOnly && status?.remoteAhead ? (
559
+ <button
560
+ type="button"
561
+ className="btn btn--ghost btn--sm"
562
+ data-tour="pull"
563
+ disabled={!!busy}
564
+ onClick={() =>
565
+ run('getLatest', onGetLatest, {
566
+ variant: 'success',
567
+ text: 'Up to date with everyone.',
568
+ })
569
+ }
570
+ >
571
+ <Icon name="download" size={14} /> Get latest
572
+ </button>
573
+ ) : (
574
+ <button type="button" className="btn btn--ghost btn--sm" onClick={openHistory}>
575
+ <Icon name="history" size={14} /> View History
576
+ </button>
577
+ )}
578
+ </div>
579
+ )
580
+ ) : (
581
+ <>
582
+ {!readOnly && status?.remoteAhead && (
583
+ <div className="gp-pad">
584
+ <div className="callout callout--info gp-nudge" role="status">
585
+ <span className="gp-dot-pulse" aria-hidden="true" />
586
+ <span className="gp-nudge-text">
587
+ <b>
588
+ {status.behind} new change{status.behind === 1 ? '' : 's'} from your team.
589
+ </b>{' '}
590
+ <span>Get the latest before you publish yours.</span>
591
+ </span>
592
+ <button
593
+ type="button"
594
+ className="btn btn--sm"
595
+ disabled={!!busy}
596
+ onClick={() =>
597
+ run('getLatest', onGetLatest, { variant: 'success', text: 'Up to date with everyone.' })
598
+ }
599
+ >
600
+ <Icon name="download" size={14} /> Get latest
601
+ </button>
602
+ </div>
603
+ </div>
604
+ )}
605
+
606
+ <div className="gp-list" role="group" aria-label="Unsaved changes">
607
+ {showSectionHeads && canvasUnits.length > 0 && (
608
+ <div className="gp-group-hd">
609
+ Canvases
610
+ <span className="gp-group-count">· {canvasUnits.length}</span>
611
+ </div>
612
+ )}
613
+ {canvasUnits.map(renderUnit)}
614
+ {otherUnits.length > 0 && (
615
+ <>
616
+ {showSectionHeads && (
617
+ <div className="gp-group-hd">
618
+ Other files
619
+ <span className="gp-group-count">· {otherUnits.length}</span>
620
+ </div>
621
+ )}
622
+ {otherUnits.map(renderUnit)}
623
+ </>
624
+ )}
625
+ </div>
626
+
627
+ {!readOnly && (
628
+ <div className="gp-compose">
629
+ <label className="gp-selectall">
630
+ <input
631
+ ref={selectAllRef}
632
+ type="checkbox"
633
+ className="gp-check"
634
+ checked={checkedPaths.length === files.length && files.length > 0}
635
+ onChange={toggleAll}
636
+ aria-label="Select all changed files"
637
+ />
638
+ {checkedPaths.length} of {count} selected
639
+ </label>
640
+ <textarea
641
+ className="gp-msg"
642
+ placeholder="Describe what changed in this version…"
643
+ aria-label="Describe what changed in this version"
644
+ rows={2}
645
+ value={message}
646
+ onChange={(e) => setMessage(e.target.value)}
647
+ />
648
+ <div className="gp-compose-actions">
649
+ <button
650
+ type="button"
651
+ className="btn btn--primary gp-save"
652
+ data-tour="save-local"
653
+ disabled={!canSave}
654
+ aria-disabled={!canSave}
655
+ onClick={async () => {
656
+ const r = await run('save', () => onCommit(message.trim(), checkedPaths), {
657
+ variant: 'success',
658
+ text: 'Version saved.',
659
+ });
660
+ if (r?.ok) setMessage('');
661
+ }}
662
+ >
663
+ <Icon name="save" size={15} /> Save version
664
+ </button>
665
+ <button
666
+ type="button"
667
+ className="btn btn--ghost btn--sm"
668
+ disabled={!message.trim() || !!busy}
669
+ title="Save every change"
670
+ onClick={async () => {
671
+ const r = await run('saveAll', () => onCommit(message.trim(), undefined), {
672
+ variant: 'success',
673
+ text: 'Version saved.',
674
+ });
675
+ if (r?.ok) setMessage('');
676
+ }}
677
+ >
678
+ Save all
679
+ </button>
680
+ </div>
681
+ {!canSave && (
682
+ <span className="gp-hint">
683
+ Type a message and pick at least one file to save a version.
684
+ </span>
685
+ )}
686
+ </div>
687
+ )}
688
+
689
+ {!readOnly && publishBar}
690
+ {readOnly && (
691
+ <p className="gp-hint gp-ro-hint">
692
+ Save and publish your work from your terminal — this view is read-only.
693
+ </p>
694
+ )}
695
+ </>
696
+ )
697
+ ) : (
698
+ <div
699
+ className="gp-history"
700
+ role="list"
701
+ aria-label={previewable ? `Saved versions of ${activeName}` : 'Version history'}
702
+ >
703
+ {previewable && (
704
+ <p className="gp-history-scope">
705
+ Saved versions of <b>{activeName}</b> — pick one to preview.
706
+ </p>
707
+ )}
708
+ {logLoading ? (
709
+ <div className="gp-empty">
710
+ <p>Loading history…</p>
711
+ </div>
712
+ ) : !log || log.length === 0 ? (
713
+ <div className="gp-empty">
714
+ <span className="gp-empty-glyph">
715
+ <Icon name="history" size={24} />
716
+ </span>
717
+ <h3>No saved versions yet</h3>
718
+ <p>
719
+ {previewable
720
+ ? `Save a version of ${activeName} and it'll show up here.`
721
+ : "Save a version and it'll show up here."}
722
+ </p>
723
+ </div>
724
+ ) : (
725
+ log.map((c) => {
726
+ const body = (
727
+ <>
728
+ <span className="gp-version-rail">
729
+ <span className="gp-version-node" />
730
+ </span>
731
+ <span className="gp-version-body">
732
+ <span className="gp-version-msg">{c.message || '(no message)'}</span>
733
+ <span className="gp-version-meta">
734
+ {c.author} · {c.sha.slice(0, 7)}
735
+ </span>
736
+ </span>
737
+ <span className="gp-version-when">{timeAgo(c.date)}</span>
738
+ </>
739
+ );
740
+ return previewable ? (
741
+ <button
742
+ type="button"
743
+ className="gp-version gp-version--clickable"
744
+ key={c.sha}
745
+ onClick={() => onPreviewVersion(c.sha)}
746
+ title={`Preview ${activeName} at this saved version`}
747
+ >
748
+ {body}
749
+ <span className="gp-version-cue" aria-hidden="true">
750
+ <Icon name="diff" size={13} /> Preview
751
+ </span>
752
+ </button>
753
+ ) : (
754
+ <div className="gp-version" role="listitem" key={c.sha}>
755
+ {body}
756
+ </div>
757
+ );
758
+ })
759
+ )}
760
+ {!previewable && log && log.length > 0 && (
761
+ <p className="gp-history-hint">Open a canvas to preview a saved version.</p>
762
+ )}
763
+ </div>
764
+ )}
765
+ </aside>
766
+ );
767
+ }