@1agh/maude 1.2.0 → 1.3.1
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 +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +732 -38
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +468 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +783 -245
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +36 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1594 -1562
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +300 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +172 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1174 -21
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +315 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +83 -1
- package/apps/studio/sync/transaction-client.ts +486 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +137 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +95 -7
- package/plugins/flow/dependencies.json +3 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { notify } from '../notifications.tsx';
|
|
3
|
+
|
|
4
|
+
/** Modal keyboard handling shared by the share and file-link decisions. */
|
|
5
|
+
export function useLinkDialogFocus(onClose) {
|
|
6
|
+
const ref = useRef(null);
|
|
7
|
+
const closeRef = useRef(onClose);
|
|
8
|
+
closeRef.current = onClose;
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const previous = document.activeElement;
|
|
11
|
+
const el = ref.current;
|
|
12
|
+
const controls = () => [...el.querySelectorAll('button:not(:disabled), a[href], input:not(:disabled)')];
|
|
13
|
+
controls()[0]?.focus();
|
|
14
|
+
const key = (event) => {
|
|
15
|
+
if (event.key === 'Escape') {
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
event.stopImmediatePropagation();
|
|
18
|
+
closeRef.current();
|
|
19
|
+
} else if (event.key === 'Tab') {
|
|
20
|
+
const items = controls();
|
|
21
|
+
const first = items[0];
|
|
22
|
+
const last = items.at(-1);
|
|
23
|
+
if (event.shiftKey && (document.activeElement === first || !el.contains(document.activeElement))) {
|
|
24
|
+
event.preventDefault(); last?.focus();
|
|
25
|
+
} else if (!event.shiftKey && (document.activeElement === last || !el.contains(document.activeElement))) {
|
|
26
|
+
event.preventDefault(); first?.focus();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
document.addEventListener('keydown', key, true);
|
|
31
|
+
return () => { document.removeEventListener('keydown', key, true); previous?.focus?.(); };
|
|
32
|
+
}, []);
|
|
33
|
+
return ref;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function copyShareLink(value) {
|
|
37
|
+
try {
|
|
38
|
+
await navigator.clipboard.writeText(value);
|
|
39
|
+
notify({ title: 'Link copied', kind: 'success' });
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
notify({ title: 'Could not copy link', description: 'Select the address and copy it manually.', kind: 'error' });
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ShareDialog({ target, links, shell, onClose, Icon }) {
|
|
48
|
+
const ref = useLinkDialogFocus(onClose);
|
|
49
|
+
const [copied, setCopied] = useState(null);
|
|
50
|
+
const timer = useRef(null);
|
|
51
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
52
|
+
const copy = async (kind, value) => {
|
|
53
|
+
if (await copyShareLink(value)) {
|
|
54
|
+
setCopied(kind);
|
|
55
|
+
clearTimeout(timer.current);
|
|
56
|
+
timer.current = setTimeout(() => setCopied(null), 1500);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return (
|
|
60
|
+
<div className="st-scrim" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
61
|
+
<div ref={ref} className="st-dialog st-share-dialog" role="dialog" aria-modal="true" aria-label={`Share ${target.label}`} data-testid="share-dialog">
|
|
62
|
+
<div className="st-dialog-hd">
|
|
63
|
+
<span className="st-dialog-title">Share {target.label}</span>
|
|
64
|
+
<button type="button" className="st-iconbtn" aria-label="Close share dialog" onClick={onClose}><Icon name="x" size={15} /></button>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="st-dialog-bd">
|
|
67
|
+
<p className="st-share-path">{target.rel}</p>
|
|
68
|
+
{!links.web && <p className="st-dialog-note">Connect this project to Maude Cloud to get a web link.</p>}
|
|
69
|
+
{[
|
|
70
|
+
['web', 'Web link', links.web],
|
|
71
|
+
['app', 'Open in Maude app', links.app],
|
|
72
|
+
['local', 'Local link (this Mac only)', links.local],
|
|
73
|
+
].filter(([, , value]) => value).map(([kind, label, value]) => (
|
|
74
|
+
<div key={kind} className="st-share-link">
|
|
75
|
+
<label className="st-dialog-lbl" htmlFor={`share-${kind}-url`}>{label}</label>
|
|
76
|
+
<div className="st-dialog-row">
|
|
77
|
+
<input id={`share-${kind}-url`} data-testid={`share-${kind}-url`} readOnly value={value} onFocus={(e) => e.target.select()} />
|
|
78
|
+
<button type="button" className="btn btn--ghost" aria-label={`Copy ${label}`} data-testid={`share-copy-${kind}`} onClick={() => copy(kind, value)}>
|
|
79
|
+
<Icon name={copied === kind ? 'check' : 'link'} size={14} />{copied === kind ? 'Copied' : 'Copy'}
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
{kind === 'app' && links.appIsLocalOnly && <p className="st-share-hint">Opens the folder on this Mac only.</p>}
|
|
83
|
+
{kind === 'app' && shell !== 'native' && <a href={value} data-testid="share-open-app">Open in app</a>}
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
{links.web && <p className="st-share-hint">People need access to this project to open the link.</p>}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Shell addresses use the same design-root-relative file identity as canvas URLs.
|
|
2
|
+
// RETURN_RULES twin: apps/hub/src/return-to.mjs; native: project_resolve.rs.
|
|
3
|
+
const PROJECT_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
4
|
+
|
|
5
|
+
/** Validate an already decoded file identity. Never decode a filesystem path. */
|
|
6
|
+
export function normalizeOpenPath(raw, designRel = '.design') {
|
|
7
|
+
if (typeof raw !== 'string' || !raw || raw.length > 512) return null;
|
|
8
|
+
if (/^[a-z]:/i.test(raw) || raw.includes('\\')) return null;
|
|
9
|
+
if ([...raw].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) return null;
|
|
10
|
+
const prefix = `${designRel.replace(/^\.\//, '').replace(/\/$/, '')}/`;
|
|
11
|
+
const rel = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
|
|
12
|
+
return rel.split('/').some((s) => s === '' || s === '.' || s === '..') ? null : rel;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Decode a URL value exactly once, then validate. */
|
|
16
|
+
export function normalizeOpenParam(raw, designRel = '.design') {
|
|
17
|
+
if (typeof raw !== 'string') return null;
|
|
18
|
+
try {
|
|
19
|
+
return normalizeOpenPath(decodeURIComponent(raw), designRel);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readOpenParam(location, designRel = '.design') {
|
|
26
|
+
const values = new URLSearchParams(location.search).getAll('open');
|
|
27
|
+
// URLSearchParams already decoded the value; a second decode changes filenames.
|
|
28
|
+
return values.length === 1 ? normalizeOpenPath(values[0], designRel) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const encodeOpenPath = (rel) => rel.split('/').map(encodeURIComponent).join('/');
|
|
32
|
+
|
|
33
|
+
export function withOpenParam(location, rel) {
|
|
34
|
+
const path = normalizeOpenPath(rel);
|
|
35
|
+
return path ? `?open=${encodeOpenPath(path)}` : location.pathname;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function httpUrl(raw) {
|
|
39
|
+
try {
|
|
40
|
+
const u = new URL(raw);
|
|
41
|
+
return /^https?:$/.test(u.protocol) && !u.username && !u.password ? u : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function projectIdFromHubUrl(raw) {
|
|
48
|
+
const host = httpUrl(raw)?.hostname;
|
|
49
|
+
if (!host || host.includes(':') || /^[\d.]+$/.test(host) || host.endsWith('.localhost')) return null;
|
|
50
|
+
const labels = host.split('.');
|
|
51
|
+
return labels.length >= 3 && PROJECT_RE.test(labels[0]) ? labels[0] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function projectSlug(name) {
|
|
55
|
+
const slug = String(name ?? '').split('/').pop().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40).replace(/-$/, '');
|
|
56
|
+
return PROJECT_RE.test(slug) ? slug : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A configured hub identity takes precedence over the folder's fallback name. */
|
|
60
|
+
export function localIdentityMatches(local, project) {
|
|
61
|
+
const linked = projectIdFromHubUrl(local.linkedHub?.url);
|
|
62
|
+
return linked ? linked === project : projectSlug(local.project) === project;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildShareLinks({ rel, shell, location, linkedHubUrl, project, localUrl }) {
|
|
66
|
+
const path = normalizeOpenPath(rel);
|
|
67
|
+
const result = { web: null, app: null, local: null, projectLabel: project ?? '', appIsLocalOnly: false };
|
|
68
|
+
if (!path) return result;
|
|
69
|
+
const query = `?open=${encodeOpenPath(path)}`;
|
|
70
|
+
const publicUrl = (value) => {
|
|
71
|
+
const url = httpUrl(value);
|
|
72
|
+
return url && !/^(localhost|127\.[\d.]+|\[::1\])$/.test(url.hostname) && !url.hostname.endsWith('.localhost') ? url : null;
|
|
73
|
+
};
|
|
74
|
+
const hub = publicUrl(linkedHubUrl);
|
|
75
|
+
const cloud = shell === 'cloud' ? publicUrl(location?.origin) : null;
|
|
76
|
+
const publicId = projectIdFromHubUrl(cloud?.origin ?? hub?.origin);
|
|
77
|
+
const id = publicId ?? projectSlug(project);
|
|
78
|
+
result.projectLabel = id ?? project ?? '';
|
|
79
|
+
result.web = cloud ? `${cloud.origin}${location.pathname}${query}` : hub ? `${hub.origin}/${query}` : null;
|
|
80
|
+
result.app = id ? `maude://open/${id}${query}` : null;
|
|
81
|
+
result.appIsLocalOnly = !!id && !publicId;
|
|
82
|
+
const local = shell !== 'cloud' && httpUrl(localUrl);
|
|
83
|
+
result.local = local ? `${local.origin}/${query}` : null;
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseFileDeepLink(raw) {
|
|
88
|
+
const m = /^maude:\/\/open\/([a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?)\/?\?([^#]*)$/.exec(String(raw ?? ''));
|
|
89
|
+
if (!m) return null;
|
|
90
|
+
const params = new URLSearchParams(m[2]);
|
|
91
|
+
if ([...params.keys()].some((key) => key !== 'open')) return null;
|
|
92
|
+
const rel = readOpenParam({ search: m[2] });
|
|
93
|
+
return rel ? { project: m[1], rel } : null;
|
|
94
|
+
}
|
|
@@ -584,25 +584,7 @@
|
|
|
584
584
|
.st-pal-icon { width: 26px; height: 26px; border-radius: var(--radius-sm); background: var(--bg-4); display: grid; place-items: center; color: var(--fg-1); flex: none; }
|
|
585
585
|
.st-pal-item.is-active .st-pal-icon { background: var(--accent); color: var(--accent-fg); }
|
|
586
586
|
|
|
587
|
-
/* ─── What's-new toast (Plan C P6) ─────────────────────────────────────────
|
|
588
|
-
* Lifted from Studio.css `.st-toast*`; repositioned fixed bottom-left for the
|
|
589
|
-
* real shell (the mockup floated it inside the stage). Mirrors ai-banner. */
|
|
590
|
-
.st-toast {
|
|
591
|
-
/* bottom raised above the in-canvas zoom pill (`.dc-zoom-tb`, bottom-left). */
|
|
592
|
-
position: fixed; left: 16px; bottom: 84px; width: 320px; z-index: 8000;
|
|
593
|
-
background: var(--bg-2); border: 1px solid var(--border-default); border-left: 3px solid var(--accent);
|
|
594
|
-
border-radius: var(--radius-md); box-shadow: var(--shadow-lg); padding: var(--space-4);
|
|
595
|
-
display: flex; flex-direction: column; gap: var(--space-2);
|
|
596
|
-
animation: st-toast-in var(--dur-route) var(--ease-out);
|
|
597
|
-
}
|
|
598
|
-
@keyframes st-toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
|
599
|
-
@media (prefers-reduced-motion: reduce) { .st-toast { animation-duration: 1ms; } }
|
|
600
|
-
.st-toast-hd { display: flex; align-items: center; gap: var(--space-2); color: var(--accent); font-family: var(--font-mono); font-size: var(--type-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
|
|
601
|
-
.st-toast-title { font-size: var(--type-md); font-weight: 600; color: var(--fg-0); }
|
|
602
|
-
.st-toast-txt { font-size: var(--type-sm); color: var(--fg-1); line-height: 1.5; }
|
|
603
587
|
.st-toast-actions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
|
|
604
|
-
.st-toast-close { position: absolute; top: var(--space-2); right: var(--space-2); appearance: none; border: 0; background: transparent; color: var(--fg-3); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 6px; border-radius: var(--radius-xs); }
|
|
605
|
-
.st-toast-close:hover { background: var(--bg-3); color: var(--fg-0); }
|
|
606
588
|
|
|
607
589
|
/* ─── Export & handoff dialog (Plan C P9/P10) ──────────────────────────────
|
|
608
590
|
* Lifted from Studio.css `.st-dialog*` / `.st-fmt*`. Rendered inside `.st-scrim`. */
|
|
@@ -2197,6 +2179,8 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2197
2179
|
.gp-version-msg { color: var(--fg-0); font-size: var(--type-sm); overflow: hidden; text-overflow: ellipsis; }
|
|
2198
2180
|
.gp-version-meta { color: var(--fg-2); font-family: var(--font-mono); font-size: var(--type-xs); margin-top: 2px; }
|
|
2199
2181
|
.gp-version-when { color: var(--fg-2); font-family: var(--font-mono); font-size: var(--type-xs); white-space: nowrap; transition: opacity var(--dur-soft) ease; }
|
|
2182
|
+
/* accepted revisions (T27/T28) — a row carries its own actions, so it is not a button */
|
|
2183
|
+
.gp-version-actions { grid-column: 2 / -1; display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: calc(-1 * var(--space-1)); }
|
|
2200
2184
|
|
|
2201
2185
|
/* phase-27.1 — per-file History rows are click-to-preview when a canvas is open */
|
|
2202
2186
|
.gp-history-scope { color: var(--fg-1); font-size: var(--type-xs); padding: var(--space-1) var(--space-2) var(--space-2); margin: 0; }
|
|
@@ -2648,6 +2632,39 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2648
2632
|
.ob-spin { animation-duration: 1ms; }
|
|
2649
2633
|
}
|
|
2650
2634
|
|
|
2635
|
+
/* ── Team projects (plan T22) — the designer's way in: recent managed copies,
|
|
2636
|
+
Maude Cloud projects, a team server sign-in. Composes the ob-* door parts; the
|
|
2637
|
+
dialog frame reuses gi-modal/gi-dialog from the project switcher. ── */
|
|
2638
|
+
.gi-dl-open { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-2); padding-bottom: var(--space-4); border-bottom: 1px solid var(--border-default); }
|
|
2639
|
+
.sp-ai-held { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
2640
|
+
.sp-ai-actions { display: flex; gap: var(--space-2); }
|
|
2641
|
+
/* ── SourceConflictPanel (plan T28) — two versions, line by line. ── */
|
|
2642
|
+
.gi-dialog.scp-dialog { width: min(760px, 94vw); max-height: min(820px, 90vh); overflow: hidden; display: flex; flex-direction: column; padding: var(--space-6); gap: var(--space-4); }
|
|
2643
|
+
.scp-legend { display: flex; gap: var(--space-4); font-size: var(--type-xs); color: var(--fg-2); }
|
|
2644
|
+
.scp-key::before { content: ''; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: var(--space-2); vertical-align: -1px; }
|
|
2645
|
+
.scp-key--mine::before { background: color-mix(in oklch, var(--status-warn) 45%, transparent); }
|
|
2646
|
+
.scp-key--theirs::before { background: color-mix(in oklch, var(--status-info) 45%, transparent); }
|
|
2647
|
+
.scp-diff { flex: 1; min-height: 120px; overflow: auto; border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-1); font-family: var(--font-mono); font-size: var(--type-xs); line-height: 1.55; }
|
|
2648
|
+
.scp-diff:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2649
|
+
.scp-line { display: flex; gap: var(--space-2); padding: 0 var(--space-3); white-space: pre; }
|
|
2650
|
+
.scp-line code { font: inherit; color: var(--fg-1); }
|
|
2651
|
+
.scp-mark { width: 1ch; color: var(--fg-3); flex: none; }
|
|
2652
|
+
.scp-line--mine { background: color-mix(in oklch, var(--status-warn) 14%, transparent); }
|
|
2653
|
+
.scp-line--theirs { background: color-mix(in oklch, var(--status-info) 14%, transparent); }
|
|
2654
|
+
.scp-line--skip { color: var(--fg-3); font-style: italic; }
|
|
2655
|
+
.scp-line--skip code { color: var(--fg-3); }
|
|
2656
|
+
.scp-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
|
|
2657
|
+
.scp-hint { font-size: var(--type-xs); color: var(--fg-2); }
|
|
2658
|
+
.scp-error { margin: 0; color: var(--status-error); font-size: var(--type-sm); }
|
|
2659
|
+
.tp { display: flex; flex-direction: column; gap: var(--space-5); max-width: 720px; }
|
|
2660
|
+
.tp-section { display: flex; flex-direction: column; gap: var(--space-3); }
|
|
2661
|
+
.tp-device { display: flex; flex-direction: column; gap: var(--space-3); max-width: 560px; }
|
|
2662
|
+
.tp-device .ob-form-actions { align-items: center; justify-content: space-between; }
|
|
2663
|
+
.gi-dialog.tp-dialog { width: min(620px, 94vw); max-height: min(760px, 90vh); overflow: auto; padding: var(--space-6); gap: var(--space-4); }
|
|
2664
|
+
.tp-dialog-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
|
|
2665
|
+
.tp-dialog-head h2 { margin: 0; font-size: var(--type-lg); color: var(--fg-0); }
|
|
2666
|
+
.tp-dialog-head p { margin: var(--space-1) 0 0; color: var(--fg-2); font-size: var(--type-sm); }
|
|
2667
|
+
|
|
2651
2668
|
/* ── Phase 29 (E4) — RepoBranchSwitcher. REDESIGNED to the approved mockup
|
|
2652
2669
|
(.design/ui/RepoBranchSwitcher.tsx): a compact ONE-LINE dock at the BOTTOM of the
|
|
2653
2670
|
sidebar (above the IdentityBar avatar) that opens ONE popup UPWARD. Mirrors the
|
|
@@ -2717,6 +2734,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2717
2734
|
.sp-note-dot { width: 7px; height: 7px; margin-top: 3px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
|
|
2718
2735
|
.sp-note-dot.is-synced { background: var(--presence-online); }
|
|
2719
2736
|
.sp-note-dot.is-refused, .sp-note-dot.is-stalled, .sp-note-dot.is-offline { background: var(--status-error); }
|
|
2737
|
+
.sp-note > .btn { flex: none; white-space: nowrap; }
|
|
2720
2738
|
.sp-sect-attention { color: var(--status-error); }
|
|
2721
2739
|
.sp-list { list-style: none; margin: 0; padding: 0; }
|
|
2722
2740
|
.sp-row { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); min-width: 0; }
|
|
@@ -1454,7 +1454,7 @@
|
|
|
1454
1454
|
animation-duration: 1ms;
|
|
1455
1455
|
}
|
|
1456
1456
|
}
|
|
1457
|
-
.
|
|
1457
|
+
.maude-notice .st-export-progress {
|
|
1458
1458
|
margin-top: var(--space-1, 4px);
|
|
1459
1459
|
}
|
|
1460
1460
|
/* Busy state for the Save…/Download button — the bytes fetch + (native) the
|
|
@@ -2183,3 +2183,20 @@
|
|
|
2183
2183
|
opacity: 0.6;
|
|
2184
2184
|
font-size: 0.9em;
|
|
2185
2185
|
}
|
|
2186
|
+
|
|
2187
|
+
/* Issue #103: full export diagnostics are opt-in and stay inside the panel. */
|
|
2188
|
+
.st-export-item__error { min-width: 0; width: 100%; }
|
|
2189
|
+
.st-export-item__error summary { cursor: pointer; }
|
|
2190
|
+
.st-export-item__error pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; font: inherit; }
|
|
2191
|
+
.st-export-item:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2192
|
+
|
|
2193
|
+
/* Share links reuse the shell dialog material. */
|
|
2194
|
+
.st-share-dialog { max-height: 90vh; overflow-y: auto; }
|
|
2195
|
+
.st-share-path, .st-share-hint { margin: 0; color: var(--fg-2); font-size: var(--type-xs); overflow-wrap: anywhere; }
|
|
2196
|
+
.st-share-link { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
2197
|
+
.st-share-link input { flex: 1; min-width: 0; padding: var(--space-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); color: var(--fg-0); font: inherit; }
|
|
2198
|
+
.st-share-link a { color: var(--fg-1); font-size: var(--type-xs); }
|
|
2199
|
+
.st-share-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--fg-2); cursor: pointer; }
|
|
2200
|
+
.st-share-btn:hover { background: var(--bg-2); color: var(--fg-0); }
|
|
2201
|
+
.st-share-btn:disabled { opacity: .4; cursor: default; }
|
|
2202
|
+
.st-share-btn:focus-visible, .st-share-link input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
@@ -9,7 +9,24 @@
|
|
|
9
9
|
// only `position` is overridden inline since this menu anchors to an
|
|
10
10
|
// arbitrary row, not the menubar's fixed offset.
|
|
11
11
|
|
|
12
|
-
import { useEffect, useRef, useState } from 'react';
|
|
12
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
13
|
+
|
|
14
|
+
const EDGE = 8;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a menu of `size` goes so it stays on screen: below the anchor when it
|
|
18
|
+
* fits, else above it, else pinned to the viewport edge (scrolling inside).
|
|
19
|
+
* A row near the bottom of a long tree used to open its menu off-screen.
|
|
20
|
+
*/
|
|
21
|
+
export function placeMenu(anchor, size, viewport) {
|
|
22
|
+
let top = anchor.bottom + 4;
|
|
23
|
+
if (top + size.height > viewport.height - EDGE) {
|
|
24
|
+
const above = anchor.top - 4 - size.height;
|
|
25
|
+
top = above >= EDGE ? above : Math.max(EDGE, viewport.height - EDGE - size.height);
|
|
26
|
+
}
|
|
27
|
+
const left = Math.max(EDGE, Math.min(anchor.left, viewport.width - EDGE - size.width));
|
|
28
|
+
return { top, left, maxHeight: viewport.height - 2 * EDGE };
|
|
29
|
+
}
|
|
13
30
|
|
|
14
31
|
/**
|
|
15
32
|
* One shared menu instance per tree (mirrors useTreeDrag — only one row's
|
|
@@ -26,7 +43,14 @@ export function useRowMenu() {
|
|
|
26
43
|
e.stopPropagation();
|
|
27
44
|
const anchorEl = e.currentTarget;
|
|
28
45
|
const r = anchorEl.getBoundingClientRect();
|
|
29
|
-
setState({
|
|
46
|
+
setState({
|
|
47
|
+
x: r.left,
|
|
48
|
+
y: r.bottom + 4,
|
|
49
|
+
anchor: { top: r.top, bottom: r.bottom, left: r.left },
|
|
50
|
+
anchorEl,
|
|
51
|
+
view: 'root',
|
|
52
|
+
extra,
|
|
53
|
+
});
|
|
30
54
|
};
|
|
31
55
|
const close = () => {
|
|
32
56
|
setState((s) => {
|
|
@@ -46,6 +70,25 @@ export function useRowMenu() {
|
|
|
46
70
|
*/
|
|
47
71
|
export function TreeRowMenu({ state, onClose, rootItems, destinations, onPickDestination }) {
|
|
48
72
|
const ref = useRef(null);
|
|
73
|
+
const [placed, setPlaced] = useState(null);
|
|
74
|
+
const view = state?.view;
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
const el = ref.current;
|
|
77
|
+
if (!state || !el) {
|
|
78
|
+
setPlaced(null);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const anchor = state.anchor ?? { top: state.y - 4, bottom: state.y - 4, left: state.x };
|
|
82
|
+
setPlaced(
|
|
83
|
+
placeMenu(
|
|
84
|
+
anchor,
|
|
85
|
+
{ width: el.offsetWidth, height: el.scrollHeight },
|
|
86
|
+
{ width: window.innerWidth, height: window.innerHeight }
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
// Re-place when the view switches (Move to… is a different height).
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
+
}, [state?.anchorEl, view]);
|
|
49
92
|
useEffect(() => {
|
|
50
93
|
if (!state) return;
|
|
51
94
|
const el = ref.current;
|
|
@@ -79,7 +122,9 @@ export function TreeRowMenu({ state, onClose, rootItems, destinations, onPickDes
|
|
|
79
122
|
}, [state, onClose]);
|
|
80
123
|
|
|
81
124
|
if (!state) return null;
|
|
82
|
-
const style =
|
|
125
|
+
const style = placed
|
|
126
|
+
? { position: 'fixed', left: placed.left, top: placed.top, maxHeight: placed.maxHeight, overflowY: 'auto' }
|
|
127
|
+
: { position: 'fixed', left: state.x, top: state.y };
|
|
83
128
|
|
|
84
129
|
if (state.view === 'move-to') {
|
|
85
130
|
return (
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// (entry.tour[]) land in Phase 3 — the panel/toast leave a hook for them.
|
|
10
10
|
|
|
11
11
|
import { useCallback, useEffect, useState } from 'react';
|
|
12
|
+
import { dismissNotice, notify } from '../notifications.tsx';
|
|
12
13
|
import { bestSeenVersion, computeUnseen } from './whats-new-seen.js';
|
|
13
14
|
|
|
14
15
|
const WN_SEEN = 'mdcc-whatsnew-seen';
|
|
@@ -143,37 +144,28 @@ export function WhatsNewBadge({ count = 0, onOpen }) {
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
export function WhatsNewToast({ wn }) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
<button type="button" className="btn btn--primary btn--sm" onClick={wn.openPanel}>
|
|
169
|
-
{total > 1 ? `See all (${total})` : 'Details'}
|
|
170
|
-
</button>
|
|
171
|
-
<button type="button" className="btn btn--ghost btn--sm" onClick={wn.dismissToast}>
|
|
172
|
-
Dismiss
|
|
173
|
-
</button>
|
|
174
|
-
</div>
|
|
175
|
-
</div>
|
|
176
|
-
);
|
|
147
|
+
const id = `whats-new-${wn.feedVersion}`;
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!wn.showToast || !wn.toastEntry) {
|
|
150
|
+
dismissNotice(id);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
notify({
|
|
154
|
+
id,
|
|
155
|
+
group: 'whats-new',
|
|
156
|
+
kind: 'info',
|
|
157
|
+
duration: 10_000,
|
|
158
|
+
title: `What's new${wn.toastEntry.version ? ` · v${wn.toastEntry.version}` : ''} — ${wn.toastEntry.title}`,
|
|
159
|
+
description: wn.toastEntry.summary,
|
|
160
|
+
action: {
|
|
161
|
+
label: wn.unseen.length > 1 ? `See all (${wn.unseen.length})` : 'Details',
|
|
162
|
+
onClick: wn.openPanel,
|
|
163
|
+
},
|
|
164
|
+
onDismiss: wn.dismissToast,
|
|
165
|
+
});
|
|
166
|
+
}, [id, wn.showToast, wn.toastEntry, wn.unseen.length, wn.openPanel, wn.dismissToast]);
|
|
167
|
+
useEffect(() => () => dismissNotice(id), [id]);
|
|
168
|
+
return null;
|
|
177
169
|
}
|
|
178
170
|
|
|
179
171
|
export function WhatsNewPanel({ wn, onStartTour }) {
|
|
@@ -229,7 +221,9 @@ export function WhatsNewPanel({ wn, onStartTour }) {
|
|
|
229
221
|
<div className="mdcc-wn-item__hd">
|
|
230
222
|
<span className={'mdcc-wn-kind mdcc-wn-kind--' + e.kind}>{e.kind}</span>
|
|
231
223
|
<span className="mdcc-wn-item__title">{e.title}</span>
|
|
232
|
-
<span className="mdcc-wn-item__ver">
|
|
224
|
+
<span className="mdcc-wn-item__ver">
|
|
225
|
+
{e.version ? `v${e.version}` : 'next'}
|
|
226
|
+
</span>
|
|
233
227
|
</div>
|
|
234
228
|
<p className="mdcc-wn-item__summary">{e.summary}</p>
|
|
235
229
|
{isSafeHref(e.learnMore) && (
|
|
@@ -309,6 +309,75 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
309
309
|
});
|
|
310
310
|
},
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Open a project as a MANAGED local copy (T21): the same open → cell
|
|
314
|
+
* exchange → stored credential as `attach`, but nothing is written into
|
|
315
|
+
* the folder this studio happens to serve — the desktop creates the
|
|
316
|
+
* project's own copy and switches to it.
|
|
317
|
+
*/
|
|
318
|
+
async openManaged(projectId: string): Promise<CloudEndpointResult> {
|
|
319
|
+
const file = readCloudFile();
|
|
320
|
+
if (!file)
|
|
321
|
+
return { status: 401, json: { ok: false, error: 'Sign in to Maude Cloud first.' } };
|
|
322
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(projectId ?? ''))) {
|
|
323
|
+
return { status: 400, json: { ok: false, error: 'unknown project' } };
|
|
324
|
+
}
|
|
325
|
+
const opened = await cloudFetch('/projects/open', {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: { authorization: `Bearer ${file.token}`, 'content-type': 'application/json' },
|
|
328
|
+
body: JSON.stringify({ project: projectId }),
|
|
329
|
+
});
|
|
330
|
+
if (opened.status !== 200 || !opened.body?.token || !opened.body.url) {
|
|
331
|
+
return {
|
|
332
|
+
status: 502,
|
|
333
|
+
json: { ok: false, error: 'The project could not be opened with this account.' },
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return exchangeAtCell({
|
|
337
|
+
workspaceUrl: opened.body.url,
|
|
338
|
+
projectToken: opened.body.token,
|
|
339
|
+
role: opened.body.role,
|
|
340
|
+
project: projectId,
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
/** The maude:// handoff lane, as a managed copy (see `attachCode`). */
|
|
345
|
+
async openManagedCode(code: string, claimedProject?: string): Promise<CloudEndpointResult> {
|
|
346
|
+
if (typeof code !== 'string' || !/^mhc_[0-9a-f]{16,128}$/.test(code)) {
|
|
347
|
+
return { status: 400, json: { ok: false, error: 'That link is not valid.' } };
|
|
348
|
+
}
|
|
349
|
+
const exchanged = await cloudFetch('/auth/handoff/exchange', {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
headers: { 'content-type': 'application/json' },
|
|
352
|
+
body: JSON.stringify({ code }),
|
|
353
|
+
});
|
|
354
|
+
if (exchanged.status !== 200 || !exchanged.body?.token || !exchanged.body.url) {
|
|
355
|
+
return {
|
|
356
|
+
status: 410,
|
|
357
|
+
json: {
|
|
358
|
+
ok: false,
|
|
359
|
+
error:
|
|
360
|
+
'That link expired — it works once, for two minutes. Press “Open in Maude” on the project page again.',
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (claimedProject && exchanged.body.project && claimedProject !== exchanged.body.project) {
|
|
365
|
+
return {
|
|
366
|
+
status: 409,
|
|
367
|
+
json: {
|
|
368
|
+
ok: false,
|
|
369
|
+
error: `That link said ${claimedProject} but it opens ${exchanged.body.project}. Nothing was opened — open the project from its own page instead.`,
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return exchangeAtCell({
|
|
374
|
+
workspaceUrl: exchanged.body.url,
|
|
375
|
+
projectToken: exchanged.body.token,
|
|
376
|
+
role: exchanged.body.role,
|
|
377
|
+
project: exchanged.body.project,
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
|
|
312
381
|
/**
|
|
313
382
|
* Attach via a one-time handoff code — the maude:// lane (Phase 17). The
|
|
314
383
|
* code came from an untrusted URL, so it is exchanged ONLY against the
|
|
@@ -664,6 +733,69 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
664
733
|
}
|
|
665
734
|
}
|
|
666
735
|
|
|
736
|
+
/**
|
|
737
|
+
* The cell exchange alone: project token → hub session → stored credential.
|
|
738
|
+
* Writes nothing into any project folder (a managed open creates its own).
|
|
739
|
+
*/
|
|
740
|
+
async function exchangeAtCell({
|
|
741
|
+
workspaceUrl,
|
|
742
|
+
projectToken,
|
|
743
|
+
role,
|
|
744
|
+
project,
|
|
745
|
+
}: {
|
|
746
|
+
workspaceUrl: string;
|
|
747
|
+
projectToken: string;
|
|
748
|
+
role?: string;
|
|
749
|
+
project?: string;
|
|
750
|
+
}): Promise<CloudEndpointResult> {
|
|
751
|
+
const r = await exchangeCredential(workspaceUrl, projectToken);
|
|
752
|
+
if (!r) {
|
|
753
|
+
return {
|
|
754
|
+
status: 502,
|
|
755
|
+
json: {
|
|
756
|
+
ok: false,
|
|
757
|
+
error: 'The workspace did not accept the sign-in. Try again in a minute.',
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
status: 200,
|
|
763
|
+
json: { ok: true, url: r.url, role: r.role ?? role ?? null, project: project ?? null },
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function exchangeCredential(
|
|
768
|
+
workspaceUrl: string,
|
|
769
|
+
projectToken: string
|
|
770
|
+
): Promise<{ url: string; role?: string } | null> {
|
|
771
|
+
let hubToken: string | null = null;
|
|
772
|
+
let hubTokenExpiresAt: number | undefined;
|
|
773
|
+
let vouchedRole: string | undefined;
|
|
774
|
+
try {
|
|
775
|
+
const res = await fetch(`${workspaceUrl}/auth/login`, {
|
|
776
|
+
method: 'POST',
|
|
777
|
+
headers: { 'content-type': 'application/json' },
|
|
778
|
+
body: JSON.stringify({ token: projectToken }),
|
|
779
|
+
});
|
|
780
|
+
const body = (await res.json().catch(() => ({}))) as CloudBody & {
|
|
781
|
+
expiresAt?: unknown;
|
|
782
|
+
user?: { role?: unknown };
|
|
783
|
+
};
|
|
784
|
+
if (res.ok && body?.token) {
|
|
785
|
+
hubToken = body.token;
|
|
786
|
+
if (typeof body.expiresAt === 'number' && Number.isFinite(body.expiresAt))
|
|
787
|
+
hubTokenExpiresAt = body.expiresAt;
|
|
788
|
+
if (typeof body.user?.role === 'string' && body.user.role) vouchedRole = body.user.role;
|
|
789
|
+
}
|
|
790
|
+
} catch {
|
|
791
|
+
/* handled by the caller */
|
|
792
|
+
}
|
|
793
|
+
if (!hubToken) return null;
|
|
794
|
+
const norm = normalizeUrl(workspaceUrl);
|
|
795
|
+
saveHubCredential(norm, hubToken, vouchedRole, hubTokenExpiresAt);
|
|
796
|
+
return { url: norm, role: vouchedRole };
|
|
797
|
+
}
|
|
798
|
+
|
|
667
799
|
/** The shared tail of every attach: cell exchange → credential + linkedHub. */
|
|
668
800
|
async function linkToWorkspace({
|
|
669
801
|
workspaceUrl,
|