@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,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
|
+
}
|