@1agh/maude 0.57.0 → 0.58.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 -1
- package/apps/studio/api.ts +69 -4
- package/apps/studio/client/app.jsx +140 -9
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/styles/3-shell-maude.css +45 -0
- package/apps/studio/client/styles/6-acp-chat.css +28 -0
- package/apps/studio/dist/client.bundle.js +1590 -1590
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +8 -0
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +9 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -175,7 +175,11 @@ pnpm version # = bash scripts/changesets-version.sh
|
|
|
175
175
|
# → re-runs scripts/check-version-parity.sh
|
|
176
176
|
|
|
177
177
|
git commit -am "chore: release v$(node -p "require('./package.json').version")"
|
|
178
|
-
|
|
178
|
+
# ANNOTATED (-a -m) is required — `git push --follow-tags` only pushes annotated
|
|
179
|
+
# tags. A lightweight `git tag vX.Y.Z` will silently stay local and no release
|
|
180
|
+
# workflow ever fires.
|
|
181
|
+
VER="v$(node -p "require('./package.json').version")"
|
|
182
|
+
git tag -a "$VER" -m "$VER"
|
|
179
183
|
git push --follow-tags
|
|
180
184
|
```
|
|
181
185
|
|
package/apps/studio/api.ts
CHANGED
|
@@ -117,6 +117,33 @@ export const SKIP_DIRS = new Set([
|
|
|
117
117
|
]);
|
|
118
118
|
const HIDDEN_OK = new Set(['.ai', '.claude', '.design']);
|
|
119
119
|
|
|
120
|
+
// feature-studio-file-preview — binary/media extensions the tree lists so a
|
|
121
|
+
// DS's assets/{fonts,graphics,logos,photos,...} files show up (previously
|
|
122
|
+
// only their parent folders did, via findFiles's dirsOut accounting). Kept as
|
|
123
|
+
// an explicit enumerated list rather than a broad pattern so it can never
|
|
124
|
+
// accidentally widen to swallow runtime JSON — findFiles already excludes
|
|
125
|
+
// `_`-prefixed entries before this list is even consulted.
|
|
126
|
+
const PREVIEW_ASSET_EXTS = [
|
|
127
|
+
'.svg',
|
|
128
|
+
'.png',
|
|
129
|
+
'.jpg',
|
|
130
|
+
'.jpeg',
|
|
131
|
+
'.gif',
|
|
132
|
+
'.webp',
|
|
133
|
+
'.avif',
|
|
134
|
+
'.woff',
|
|
135
|
+
'.woff2',
|
|
136
|
+
'.ttf',
|
|
137
|
+
'.otf',
|
|
138
|
+
'.mp4',
|
|
139
|
+
'.webm',
|
|
140
|
+
'.mov',
|
|
141
|
+
'.mp3',
|
|
142
|
+
'.wav',
|
|
143
|
+
'.ogg',
|
|
144
|
+
'.m4a',
|
|
145
|
+
];
|
|
146
|
+
|
|
120
147
|
// ---------- File tree ----------
|
|
121
148
|
|
|
122
149
|
/**
|
|
@@ -181,7 +208,31 @@ async function findFiles(
|
|
|
181
208
|
if (e.isDirectory()) {
|
|
182
209
|
dirsOut?.push(rel);
|
|
183
210
|
out.push(...(await findFiles(full, rel, exts, dirsOut)));
|
|
184
|
-
} else if (exts.some((x) => e.name.toLowerCase().endsWith(x)))
|
|
211
|
+
} else if (e.isFile() && exts.some((x) => e.name.toLowerCase().endsWith(x))) {
|
|
212
|
+
// feature-studio-file-preview security review — `e.isFile()` (not just
|
|
213
|
+
// "not a directory") excludes symlinks: a symlink Dirent is neither
|
|
214
|
+
// isDirectory() nor isFile(), so without this check a symlink dropped
|
|
215
|
+
// into an assets/ folder (e.g. pointing at ~/.ssh/id_rsa, named to fit
|
|
216
|
+
// an allowlisted extension) would be listed and, since this feature
|
|
217
|
+
// makes every listed row one-click-fetchable, served straight into the
|
|
218
|
+
// preview panel.
|
|
219
|
+
//
|
|
220
|
+
// A HARDLINK survives `isFile()` (hardlinks are, by design, ordinary
|
|
221
|
+
// files — same inode, indistinguishable from the "original" at the
|
|
222
|
+
// Dirent level), so it needs a second check: `nlink > 1` means this
|
|
223
|
+
// directory entry shares its inode with at least one other name
|
|
224
|
+
// somewhere on the filesystem. A design system's own assets are never
|
|
225
|
+
// legitimately multiply-linked, so excluding them closes the same
|
|
226
|
+
// one-click-disclosure path for a hardlink planted at, say, a
|
|
227
|
+
// teammate's readable dotfile.
|
|
228
|
+
try {
|
|
229
|
+
const st = await lstat(full);
|
|
230
|
+
if (st.nlink > 1) continue;
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
out.push(rel);
|
|
235
|
+
}
|
|
185
236
|
}
|
|
186
237
|
return out;
|
|
187
238
|
}
|
|
@@ -5268,15 +5319,29 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5268
5319
|
const isDs = g.label === 'Design system' || /^system(\/|$)/.test(g.path);
|
|
5269
5320
|
// Always include canvas sidecars (`.meta.json`, `.css`, `.registry.json`)
|
|
5270
5321
|
// so the client can nest them under their primary `.tsx` / `.html`. DS
|
|
5271
|
-
// groups additionally surface `.md` for README + SKILL docs
|
|
5322
|
+
// groups additionally surface `.md` for README + SKILL docs, plus
|
|
5323
|
+
// PREVIEW_ASSET_EXTS (images/fonts/video/audio) so assets/ subfolders
|
|
5324
|
+
// list their files instead of rendering as permanently-empty tree nodes
|
|
5325
|
+
// (feature-studio-file-preview) — click-time behavior lives client-side
|
|
5326
|
+
// in FileRow's previewKind() branch.
|
|
5272
5327
|
// feature-file-tree-drag-drop-folders (Task 6) — `dirs` accumulates
|
|
5273
5328
|
// every directory in this group (incl. empty ones) via the SAME
|
|
5274
5329
|
// traversal, so a freshly `mkdir`'d folder with no files yet is still
|
|
5275
5330
|
// representable in the tree.
|
|
5276
5331
|
const dirs: string[] = [];
|
|
5277
5332
|
const filePaths = isDs
|
|
5278
|
-
? await findFiles(
|
|
5279
|
-
|
|
5333
|
+
? await findFiles(
|
|
5334
|
+
groupAbs,
|
|
5335
|
+
groupRel,
|
|
5336
|
+
['.tsx', '.html', '.md', '.css', '.json', ...PREVIEW_ASSET_EXTS],
|
|
5337
|
+
dirs
|
|
5338
|
+
)
|
|
5339
|
+
: await findFiles(
|
|
5340
|
+
groupAbs,
|
|
5341
|
+
groupRel,
|
|
5342
|
+
['.tsx', '.html', '.css', '.json', ...PREVIEW_ASSET_EXTS],
|
|
5343
|
+
dirs
|
|
5344
|
+
);
|
|
5280
5345
|
// DDR-093 — record each `.tsx` canvas's design system. canvasUrl() only
|
|
5281
5346
|
// injects tokens for `.tsx`, so skip everything else. Path-owned DS wins
|
|
5282
5347
|
// (system/<ds>/…); otherwise the sidecar's `meta.designSystem`, defaulting
|
|
@@ -38,6 +38,7 @@ import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
|
38
38
|
import { ReadinessDialog } from './panels/ReadinessList.jsx';
|
|
39
39
|
import IntroVideoDialog from './panels/IntroVideoDialog.jsx';
|
|
40
40
|
import BrandUploadPanel from './panels/BrandUploadPanel.jsx';
|
|
41
|
+
import { FilePreview, sanitizeDisplayText } from './panels/file-preview.jsx';
|
|
41
42
|
import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
|
|
42
43
|
import TimelinePanel from './panels/TimelinePanel.jsx';
|
|
43
44
|
import { parseCompTimeline } from './panels/timeline-parse.js';
|
|
@@ -189,6 +190,23 @@ function DockSlot({ side, width, open, ids, activeId, onPick, children, labels =
|
|
|
189
190
|
);
|
|
190
191
|
}
|
|
191
192
|
const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
|
|
193
|
+
// feature-studio-file-preview — classifies a non-canvas tree row so FileRow
|
|
194
|
+
// can open an inline preview instead of the old inert no-op. Kept in sync
|
|
195
|
+
// with apps/studio/api.ts's PREVIEW_ASSET_EXTS (server won't list anything
|
|
196
|
+
// outside this set anyway, but the client stays explicit rather than
|
|
197
|
+
// assuming server-side filtering).
|
|
198
|
+
const PREVIEW_KIND_RULES = [
|
|
199
|
+
[/\.md$/i, 'markdown'],
|
|
200
|
+
[/\.(css|json|txt|ya?ml)$/i, 'text'],
|
|
201
|
+
[/\.(svg|png|jpe?g|gif|webp|avif)$/i, 'image'],
|
|
202
|
+
[/\.(mp4|webm|mov)$/i, 'video'],
|
|
203
|
+
[/\.(mp3|wav|ogg|m4a)$/i, 'audio'],
|
|
204
|
+
[/\.(woff2?|ttf|otf)$/i, 'font'],
|
|
205
|
+
];
|
|
206
|
+
function previewKind(name) {
|
|
207
|
+
for (const [re, kind] of PREVIEW_KIND_RULES) if (re.test(name)) return kind;
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
192
210
|
// Shared testid-slug derivation (desktop-e2e skill convention: kebab-case,
|
|
193
211
|
// designRoot-stripped, extension-stripped) — mirrors FileRow's inline
|
|
194
212
|
// `canvas-row-<slug>` computation so DirRow / the row-menu trigger use the
|
|
@@ -2024,7 +2042,9 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
|
|
|
2024
2042
|
function FileRow({
|
|
2025
2043
|
file,
|
|
2026
2044
|
activePath,
|
|
2045
|
+
previewPath,
|
|
2027
2046
|
onOpen,
|
|
2047
|
+
onPreview,
|
|
2028
2048
|
onDelete,
|
|
2029
2049
|
openCount: oc,
|
|
2030
2050
|
depth,
|
|
@@ -2035,12 +2055,20 @@ function FileRow({
|
|
|
2035
2055
|
drag,
|
|
2036
2056
|
menu,
|
|
2037
2057
|
}) {
|
|
2038
|
-
|
|
2058
|
+
// feature-studio-file-preview (a11y fix) — a previewed row must expose
|
|
2059
|
+
// aria-selected too, or a screen-reader user gets no confirmation their
|
|
2060
|
+
// click did anything (a11y-auditor finding: state never changed).
|
|
2061
|
+
const isSel = file.path === activePath || file.path === previewPath;
|
|
2039
2062
|
const isCanvas = CANVAS_EXT_RE.test(file.name);
|
|
2040
|
-
//
|
|
2041
|
-
//
|
|
2042
|
-
//
|
|
2043
|
-
|
|
2063
|
+
// feature-studio-file-preview — RUNTIME rows (_active.json, _server.json, …)
|
|
2064
|
+
// stay inert no matter their extension: they're gitignored process state,
|
|
2065
|
+
// never user-facing content, and must not become previewable just because
|
|
2066
|
+
// `.json` also matches the `text` preview kind (DDR-115 exclusion).
|
|
2067
|
+
const pKind = !isCanvas && kind !== 'runtime' ? previewKind(file.name) : null;
|
|
2068
|
+
// Non-canvas, non-previewable rows (RUNTIME files, unrecognized extensions)
|
|
2069
|
+
// are display-only — clicking them doesn't open anything; we leave the
|
|
2070
|
+
// click as no-op + cursor hint via `aria-disabled`.
|
|
2071
|
+
const inert = !isCanvas && !pKind;
|
|
2044
2072
|
const label = isCanvas ? displayName(file.name) : file.name;
|
|
2045
2073
|
// Delete only real canvases in a deletable group (onDelete is undefined for the
|
|
2046
2074
|
// DS group + runtime files); the server enforces the rest.
|
|
@@ -2087,7 +2115,8 @@ function FileRow({
|
|
|
2087
2115
|
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
2088
2116
|
title={file.path + (oc ? ` — ${oc} open` : inert ? ' (file index only)' : '')}
|
|
2089
2117
|
onClick={() => {
|
|
2090
|
-
if (
|
|
2118
|
+
if (isCanvas) onOpen(file.path);
|
|
2119
|
+
else if (pKind) onPreview?.(file.path);
|
|
2091
2120
|
}}
|
|
2092
2121
|
onContextMenu={
|
|
2093
2122
|
canMove ? (e) => menu.openAt(e, { kind: 'file', path: file.path, dir: fileDir }) : undefined
|
|
@@ -2158,7 +2187,9 @@ function CanvasRow({
|
|
|
2158
2187
|
depth,
|
|
2159
2188
|
kind,
|
|
2160
2189
|
activePath,
|
|
2190
|
+
previewPath,
|
|
2161
2191
|
onOpen,
|
|
2192
|
+
onPreview,
|
|
2162
2193
|
onDelete,
|
|
2163
2194
|
openCount: oc,
|
|
2164
2195
|
showHidden,
|
|
@@ -2182,7 +2213,9 @@ function CanvasRow({
|
|
|
2182
2213
|
<FileRow
|
|
2183
2214
|
file={primary}
|
|
2184
2215
|
activePath={activePath}
|
|
2216
|
+
previewPath={previewPath}
|
|
2185
2217
|
onOpen={onOpen}
|
|
2218
|
+
onPreview={onPreview}
|
|
2186
2219
|
onDelete={onDelete}
|
|
2187
2220
|
openCount={oc}
|
|
2188
2221
|
depth={depth}
|
|
@@ -2282,7 +2315,9 @@ function CanvasRow({
|
|
|
2282
2315
|
key={sc.path}
|
|
2283
2316
|
file={sc}
|
|
2284
2317
|
activePath={activePath}
|
|
2318
|
+
previewPath={previewPath}
|
|
2285
2319
|
onOpen={onOpen}
|
|
2320
|
+
onPreview={onPreview}
|
|
2286
2321
|
openCount={0}
|
|
2287
2322
|
depth={depth + 1}
|
|
2288
2323
|
kind={kind}
|
|
@@ -2296,7 +2331,9 @@ function CanvasRow({
|
|
|
2296
2331
|
function Tree({
|
|
2297
2332
|
node,
|
|
2298
2333
|
activePath,
|
|
2334
|
+
previewPath,
|
|
2299
2335
|
onOpen,
|
|
2336
|
+
onPreview,
|
|
2300
2337
|
commentsByFile,
|
|
2301
2338
|
depth = 1,
|
|
2302
2339
|
kind,
|
|
@@ -2324,8 +2361,20 @@ function Tree({
|
|
|
2324
2361
|
const files = node._files || [];
|
|
2325
2362
|
// VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
|
|
2326
2363
|
// row; same-basename non-canvas files (`.meta.json`, `.css`, …) collapse
|
|
2327
|
-
// under it.
|
|
2364
|
+
// under it. Meta/doc orphans (README.md, tokens.css, …) surface only when
|
|
2365
|
+
// `showHidden` is on; media/asset orphans (images, fonts, video, audio —
|
|
2366
|
+
// feature-studio-file-preview) are content the user asked to see by
|
|
2367
|
+
// default, not sidecar noise, so they always render.
|
|
2328
2368
|
const { canvases, orphans } = useMemo(() => groupBySidecar(files), [files]);
|
|
2369
|
+
const { mediaOrphans, metaOrphans } = useMemo(() => {
|
|
2370
|
+
const media = [];
|
|
2371
|
+
const meta = [];
|
|
2372
|
+
for (const entry of orphans) {
|
|
2373
|
+
const k = previewKind(entry.primary.name);
|
|
2374
|
+
(k === 'image' || k === 'video' || k === 'audio' || k === 'font' ? media : meta).push(entry);
|
|
2375
|
+
}
|
|
2376
|
+
return { mediaOrphans: media, metaOrphans: meta };
|
|
2377
|
+
}, [orphans]);
|
|
2329
2378
|
const hasSearch = !!(search && search.trim());
|
|
2330
2379
|
// DS-folder lookup: only meaningful at the top level of a DS group. The
|
|
2331
2380
|
// server emits `dsFolders: [{name, folder}, ...]` so the client knows which
|
|
@@ -2351,7 +2400,9 @@ function Tree({
|
|
|
2351
2400
|
primary={entry.primary}
|
|
2352
2401
|
sidecars={entry.sidecars}
|
|
2353
2402
|
activePath={activePath}
|
|
2403
|
+
previewPath={previewPath}
|
|
2354
2404
|
onOpen={onOpen}
|
|
2405
|
+
onPreview={onPreview}
|
|
2355
2406
|
onDelete={onDelete}
|
|
2356
2407
|
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
2357
2408
|
depth={depth}
|
|
@@ -2365,13 +2416,28 @@ function Tree({
|
|
|
2365
2416
|
/>
|
|
2366
2417
|
);
|
|
2367
2418
|
})}
|
|
2419
|
+
{mediaOrphans.map((entry) => (
|
|
2420
|
+
<FileRow
|
|
2421
|
+
key={entry.primary.path}
|
|
2422
|
+
file={entry.primary}
|
|
2423
|
+
activePath={activePath}
|
|
2424
|
+
previewPath={previewPath}
|
|
2425
|
+
onOpen={onOpen}
|
|
2426
|
+
onPreview={onPreview}
|
|
2427
|
+
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
2428
|
+
depth={depth}
|
|
2429
|
+
kind={kind}
|
|
2430
|
+
/>
|
|
2431
|
+
))}
|
|
2368
2432
|
{showHidden &&
|
|
2369
|
-
|
|
2433
|
+
metaOrphans.map((entry) => (
|
|
2370
2434
|
<FileRow
|
|
2371
2435
|
key={entry.primary.path}
|
|
2372
2436
|
file={entry.primary}
|
|
2373
2437
|
activePath={activePath}
|
|
2438
|
+
previewPath={previewPath}
|
|
2374
2439
|
onOpen={onOpen}
|
|
2440
|
+
onPreview={onPreview}
|
|
2375
2441
|
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
2376
2442
|
depth={depth}
|
|
2377
2443
|
kind={kind}
|
|
@@ -2385,7 +2451,9 @@ function Tree({
|
|
|
2385
2451
|
<Tree
|
|
2386
2452
|
node={node[d]}
|
|
2387
2453
|
activePath={activePath}
|
|
2454
|
+
previewPath={previewPath}
|
|
2388
2455
|
onOpen={onOpen}
|
|
2456
|
+
onPreview={onPreview}
|
|
2389
2457
|
commentsByFile={commentsByFile}
|
|
2390
2458
|
depth={depth + 1}
|
|
2391
2459
|
kind={kind}
|
|
@@ -2468,8 +2536,10 @@ function Sidebar({
|
|
|
2468
2536
|
cloud,
|
|
2469
2537
|
groups,
|
|
2470
2538
|
activePath,
|
|
2539
|
+
previewPath,
|
|
2471
2540
|
activeDsName,
|
|
2472
2541
|
onOpen,
|
|
2542
|
+
onPreview,
|
|
2473
2543
|
onOpenSystem,
|
|
2474
2544
|
wsConnected,
|
|
2475
2545
|
search,
|
|
@@ -2829,7 +2899,9 @@ function Sidebar({
|
|
|
2829
2899
|
<Tree
|
|
2830
2900
|
node={g.tree}
|
|
2831
2901
|
activePath={activePath}
|
|
2902
|
+
previewPath={previewPath}
|
|
2832
2903
|
onOpen={onOpen}
|
|
2904
|
+
onPreview={onPreview}
|
|
2833
2905
|
commentsByFile={commentsByFile}
|
|
2834
2906
|
depth={1}
|
|
2835
2907
|
kind={g.kind}
|
|
@@ -4205,10 +4277,38 @@ function Viewport({
|
|
|
4205
4277
|
onIframeLoad,
|
|
4206
4278
|
showQuickSetup,
|
|
4207
4279
|
onStartQuickSetup,
|
|
4280
|
+
previewPath,
|
|
4208
4281
|
}) {
|
|
4282
|
+
// feature-studio-file-preview (a11y fix) — a11y-auditor found the preview
|
|
4283
|
+
// overlay gave keyboard/AT users no signal anything happened: the click
|
|
4284
|
+
// stayed on the (unrelated) tree row, nothing was announced, and the
|
|
4285
|
+
// scrollable region itself wasn't in the tab order. Moving focus onto the
|
|
4286
|
+
// region on open covers all three: it's a real focus change (AT announces
|
|
4287
|
+
// the new `role="region"` + its label), and a focused element with
|
|
4288
|
+
// `overflow: auto` is keyboard-scrollable (arrow/Page keys) without a
|
|
4289
|
+
// separate tabIndex fix.
|
|
4290
|
+
const previewRef = useRef(null);
|
|
4291
|
+
useEffect(() => {
|
|
4292
|
+
if (previewPath) previewRef.current?.focus();
|
|
4293
|
+
}, [previewPath]);
|
|
4209
4294
|
return (
|
|
4210
4295
|
<div className="viewport st-stage" data-tour="viewport">
|
|
4211
|
-
{
|
|
4296
|
+
{previewPath && (
|
|
4297
|
+
// feature-studio-file-preview — an overlay, not a tab: the canvas
|
|
4298
|
+
// iframe (if any) stays mounted underneath so switching back to it
|
|
4299
|
+
// is instant and never remounts. previewPath is intentionally never
|
|
4300
|
+
// written into `tabs`/`activePath` (see App()'s onPreview).
|
|
4301
|
+
<div
|
|
4302
|
+
ref={previewRef}
|
|
4303
|
+
className="st-file-preview-overlay"
|
|
4304
|
+
role="region"
|
|
4305
|
+
aria-label={`Preview: ${sanitizeDisplayText(basename(previewPath))}`}
|
|
4306
|
+
tabIndex={0}
|
|
4307
|
+
>
|
|
4308
|
+
<FilePreview path={previewPath} kind={previewKind(basename(previewPath))} />
|
|
4309
|
+
</div>
|
|
4310
|
+
)}
|
|
4311
|
+
{tabs.length === 0 && !previewPath && (
|
|
4212
4312
|
<div className="st-empty">
|
|
4213
4313
|
<div className="st-empty-brand">
|
|
4214
4314
|
<span className="st-brand-mark">
|
|
@@ -4567,6 +4667,7 @@ function StatusBar({
|
|
|
4567
4667
|
unpushed = 0,
|
|
4568
4668
|
changesOpen = false,
|
|
4569
4669
|
onOpenChanges,
|
|
4670
|
+
version,
|
|
4570
4671
|
}) {
|
|
4571
4672
|
const isSystem = activePath === SYSTEM_TAB;
|
|
4572
4673
|
const text =
|
|
@@ -4690,6 +4791,19 @@ function StatusBar({
|
|
|
4690
4791
|
</span>
|
|
4691
4792
|
)}
|
|
4692
4793
|
|
|
4794
|
+
{/* Which release this is. Reading it used to mean probing production by
|
|
4795
|
+
hand — and the desktop, the browser and a cloud tab all serve the same
|
|
4796
|
+
client, so one slot covers all three. Absent on an older server that
|
|
4797
|
+
omits `version` from /_config, exactly as `cloud` and `canvasToken`
|
|
4798
|
+
already are. */}
|
|
4799
|
+
{version && (
|
|
4800
|
+
<span className="st-sb-slot st-sb-version" role="group" aria-label="Maude version">
|
|
4801
|
+
<span className="val" data-testid="statusbar-version">
|
|
4802
|
+
v{version}
|
|
4803
|
+
</span>
|
|
4804
|
+
</span>
|
|
4805
|
+
)}
|
|
4806
|
+
|
|
4693
4807
|
<button
|
|
4694
4808
|
type="button"
|
|
4695
4809
|
className="st-sb-theme"
|
|
@@ -9176,6 +9290,14 @@ function App() {
|
|
|
9176
9290
|
const [project, setProject] = useState('Design');
|
|
9177
9291
|
const [tabs, setTabs] = useState([]);
|
|
9178
9292
|
const [activePath, setActivePath] = useState(null);
|
|
9293
|
+
// feature-studio-file-preview — deliberately separate from `tabs`/
|
|
9294
|
+
// `activePath`: a previewed file was never a canvas, so it must never
|
|
9295
|
+
// trigger the WS `active`/`tabs` broadcasts, the compile-skeleton loading
|
|
9296
|
+
// state, or iframe registration that openTab()/openSystem() drive.
|
|
9297
|
+
const [previewPath, setPreviewPath] = useState(null);
|
|
9298
|
+
const onPreview = useCallback((path) => {
|
|
9299
|
+
setPreviewPath(path);
|
|
9300
|
+
}, []);
|
|
9179
9301
|
const [selected, setSelected] = useState(null);
|
|
9180
9302
|
// Phase 12.3 — latest selection, readable from the (stale-closure) onMessage
|
|
9181
9303
|
// handler so an HMR reload (triggered by a CSS/attr edit) can re-select the
|
|
@@ -9446,6 +9568,10 @@ function App() {
|
|
|
9446
9568
|
// both of these went missing and the cloud studio rendered without
|
|
9447
9569
|
// its chrome and with every canvas iframe unauthenticated.
|
|
9448
9570
|
canvasToken: data.canvasToken,
|
|
9571
|
+
// Which release this server is — rendered as the status-bar chip.
|
|
9572
|
+
// Undefined on a server too old to report it, which is why the chip
|
|
9573
|
+
// renders conditionally rather than printing `vundefined`.
|
|
9574
|
+
version: data.version,
|
|
9449
9575
|
// C2/C4 — `{ dashboardUrl, projectName, user, role }` when this is a
|
|
9450
9576
|
// cloud tab. Its presence is what tells the shared client it is in a
|
|
9451
9577
|
// browser tab on somebody else's machine; `user`/`role` are what let
|
|
@@ -11170,6 +11296,7 @@ function App() {
|
|
|
11170
11296
|
});
|
|
11171
11297
|
setActivePath(path);
|
|
11172
11298
|
setFocusedCommentId(null);
|
|
11299
|
+
setPreviewPath(null);
|
|
11173
11300
|
// Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
|
|
11174
11301
|
// the onLoad fallback timer (legacy .html), or a hard 15s cap.
|
|
11175
11302
|
if (path !== SYSTEM_TAB) setLoadingPath(path);
|
|
@@ -14076,8 +14203,10 @@ function App() {
|
|
|
14076
14203
|
readOnly={viewerMode}
|
|
14077
14204
|
groups={groups}
|
|
14078
14205
|
activePath={activePath}
|
|
14206
|
+
previewPath={previewPath}
|
|
14079
14207
|
activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
|
|
14080
14208
|
onOpen={openTab}
|
|
14209
|
+
onPreview={onPreview}
|
|
14081
14210
|
onOpenSystem={openSystem}
|
|
14082
14211
|
wsConnected={wsConnected}
|
|
14083
14212
|
search={search}
|
|
@@ -14419,6 +14548,7 @@ function App() {
|
|
|
14419
14548
|
isNativeApp() && !viewerMode && !!setupReadiness && !setupReadiness.ready
|
|
14420
14549
|
}
|
|
14421
14550
|
onStartQuickSetup={() => setQuickSetupOpen(true)}
|
|
14551
|
+
previewPath={previewPath}
|
|
14422
14552
|
/>
|
|
14423
14553
|
</div>
|
|
14424
14554
|
{rightActive && (
|
|
@@ -15168,6 +15298,7 @@ function App() {
|
|
|
15168
15298
|
unpushed={gitStatus?.unpushed || 0}
|
|
15169
15299
|
changesOpen={changesOpen}
|
|
15170
15300
|
onOpenChanges={gitStatus?.repo ? () => setChangesOpen(true) : undefined}
|
|
15301
|
+
version={cfg?.version}
|
|
15171
15302
|
/>
|
|
15172
15303
|
</div>
|
|
15173
15304
|
{presentMode && (
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
// Compact, XSS-safe markdown renderer for the ACP chat feed
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Compact, XSS-safe markdown renderer for the ACP chat feed (and, since
|
|
2
|
+
// feature-studio-file-preview, arbitrary repo .md files previewed in the
|
|
3
|
+
// Files tree). Builds React nodes directly (never innerHTML) so neither
|
|
4
|
+
// agent output nor untrusted file content can inject markup. Covers the
|
|
5
|
+
// subset a coding agent actually emits plus GFM pipe tables (common in repo
|
|
6
|
+
// docs): fenced code blocks, headings, bullet + numbered lists, pipe tables,
|
|
7
|
+
// bold / italic / inline code, and http(s) links. Anything else falls
|
|
8
|
+
// through as plain (pre-wrapped) text. No new dependency.
|
|
6
9
|
|
|
7
10
|
function safeHref(url) {
|
|
8
11
|
// Only allow http(s) — never javascript:/data:/etc.
|
|
@@ -47,6 +50,33 @@ function inline(text) {
|
|
|
47
50
|
return out;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// GFM pipe tables: a header row, a separator row (---/:---/---:/:---: per
|
|
54
|
+
// column), then zero or more data rows — all lines containing at least one
|
|
55
|
+
// unescaped `|`.
|
|
56
|
+
const TABLE_ROW_RE = /\|/;
|
|
57
|
+
const TABLE_SEP_CELL_RE = /^:?-+:?$/;
|
|
58
|
+
|
|
59
|
+
function splitTableRow(line) {
|
|
60
|
+
let l = line.trim();
|
|
61
|
+
if (l.startsWith('|')) l = l.slice(1);
|
|
62
|
+
if (l.endsWith('|')) l = l.slice(0, -1);
|
|
63
|
+
// Split on `|` not preceded by `\` (an escaped pipe stays literal in a cell).
|
|
64
|
+
return l.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tableSeparatorAligns(line) {
|
|
68
|
+
const cells = splitTableRow(line);
|
|
69
|
+
if (!cells.length || !cells.every((c) => TABLE_SEP_CELL_RE.test(c))) return null;
|
|
70
|
+
return cells.map((c) => {
|
|
71
|
+
const left = c.startsWith(':');
|
|
72
|
+
const right = c.endsWith(':');
|
|
73
|
+
if (left && right) return 'center';
|
|
74
|
+
if (right) return 'right';
|
|
75
|
+
if (left) return 'left';
|
|
76
|
+
return null;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
export function Markdown({ text }) {
|
|
51
81
|
const lines = String(text).split('\n');
|
|
52
82
|
const blocks = [];
|
|
@@ -108,6 +138,47 @@ export function Markdown({ text }) {
|
|
|
108
138
|
continue;
|
|
109
139
|
}
|
|
110
140
|
|
|
141
|
+
// GFM pipe table: header row + a valid separator row immediately after.
|
|
142
|
+
if (TABLE_ROW_RE.test(line) && i + 1 < lines.length) {
|
|
143
|
+
const aligns = tableSeparatorAligns(lines[i + 1]);
|
|
144
|
+
if (aligns) {
|
|
145
|
+
const header = splitTableRow(line);
|
|
146
|
+
i += 2;
|
|
147
|
+
const rows = [];
|
|
148
|
+
while (i < lines.length && lines[i].trim() !== '' && TABLE_ROW_RE.test(lines[i])) {
|
|
149
|
+
rows.push(splitTableRow(lines[i]));
|
|
150
|
+
i++;
|
|
151
|
+
}
|
|
152
|
+
blocks.push(
|
|
153
|
+
<div key={key++} className="chat-md-table-wrap">
|
|
154
|
+
<table className="chat-md-table">
|
|
155
|
+
<thead>
|
|
156
|
+
<tr>
|
|
157
|
+
{header.map((c, ci) => (
|
|
158
|
+
<th key={ci} style={aligns[ci] ? { textAlign: aligns[ci] } : undefined}>
|
|
159
|
+
{inline(c)}
|
|
160
|
+
</th>
|
|
161
|
+
))}
|
|
162
|
+
</tr>
|
|
163
|
+
</thead>
|
|
164
|
+
<tbody>
|
|
165
|
+
{rows.map((r, ri) => (
|
|
166
|
+
<tr key={ri}>
|
|
167
|
+
{header.map((_, ci) => (
|
|
168
|
+
<td key={ci} style={aligns[ci] ? { textAlign: aligns[ci] } : undefined}>
|
|
169
|
+
{r[ci] !== undefined ? inline(r[ci]) : ''}
|
|
170
|
+
</td>
|
|
171
|
+
))}
|
|
172
|
+
</tr>
|
|
173
|
+
))}
|
|
174
|
+
</tbody>
|
|
175
|
+
</table>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
111
182
|
// Blank line → paragraph separator.
|
|
112
183
|
if (line.trim() === '') {
|
|
113
184
|
i++;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// feature-studio-file-preview — inline preview for a non-canvas tree row
|
|
2
|
+
// (markdown / text / image / video / audio / font). Reuses the existing
|
|
3
|
+
// static byte-serving route (no new server endpoint) and the chat panel's
|
|
4
|
+
// hand-rolled Markdown renderer — no new dependency.
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import { Markdown } from './chat-markdown.jsx';
|
|
8
|
+
|
|
9
|
+
const TEXT_PREVIEW_MAX_BYTES = 2 * 1024 * 1024; // 2 MB — beyond this, just show a size note
|
|
10
|
+
|
|
11
|
+
function basename(p) {
|
|
12
|
+
return p.split('/').pop() || p;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Bidi-override control chars (U+202A-E, U+2066-9) let a crafted filename
|
|
16
|
+
// visually disguise its own name/extension (the classic "invoicegnp.exe"
|
|
17
|
+
// trick) wherever we render a raw path/name as text — strip them before
|
|
18
|
+
// display. Doesn't affect matching (previewKind() regexes run on the
|
|
19
|
+
// unstripped name), only what a human/AT reads.
|
|
20
|
+
export function sanitizeDisplayText(s) {
|
|
21
|
+
return String(s).replace(/[\u202a-\u202e\u2066-\u2069]/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function useFetchedText(url, enabled) {
|
|
25
|
+
const [state, setState] = useState({ loading: true, error: null, text: '', tooLarge: false });
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!enabled) return;
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
setState({ loading: true, error: null, text: '', tooLarge: false });
|
|
30
|
+
fetch(url)
|
|
31
|
+
.then(async (r) => {
|
|
32
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
33
|
+
const len = Number(r.headers.get('content-length') || 0);
|
|
34
|
+
if (len > TEXT_PREVIEW_MAX_BYTES) {
|
|
35
|
+
if (!cancelled) setState({ loading: false, error: null, text: '', tooLarge: len });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const text = await r.text();
|
|
39
|
+
if (!cancelled) setState({ loading: false, error: null, text, tooLarge: false });
|
|
40
|
+
})
|
|
41
|
+
.catch((err) => {
|
|
42
|
+
if (!cancelled) setState({ loading: false, error: err.message || 'Failed to load', text: '', tooLarge: false });
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
cancelled = true;
|
|
46
|
+
};
|
|
47
|
+
}, [url, enabled]);
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function TextPreview({ url, name, as }) {
|
|
52
|
+
const { loading, error, text, tooLarge } = useFetchedText(url, true);
|
|
53
|
+
if (loading) return <div className="st-file-preview-status">Loading {name}…</div>;
|
|
54
|
+
if (error) return <div className="st-file-preview-status st-file-preview-error">Couldn't load {name}: {error}</div>;
|
|
55
|
+
if (tooLarge) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="st-file-preview-status">
|
|
58
|
+
{name} is {Math.round(tooLarge / 1024)} KB — too large to preview inline.
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (as === 'markdown') return <div className="st-file-preview-markdown"><Markdown text={text} /></div>;
|
|
63
|
+
return <pre className="st-file-preview-text">{text}</pre>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// CSS-string-literal escape for a value interpolated inside url("...") —
|
|
67
|
+
// filenames can contain arbitrary characters (only "/" and NUL are forbidden
|
|
68
|
+
// on disk), so an unescaped `"` or `\` could break out of the string and
|
|
69
|
+
// inject arbitrary rules into the live app-shell stylesheet (not just the
|
|
70
|
+
// sandboxed canvas iframe). family is already alnum/hyphen-only; this covers
|
|
71
|
+
// the url() side of the same interpolation.
|
|
72
|
+
function cssStringEscape(s) {
|
|
73
|
+
// CSS input-preprocessing (CSS Syntax Module Level 3 §3.3) normalizes CR,
|
|
74
|
+
// CRLF, AND FORM FEED (\f, U+000C) to a single LF before tokenizing, so an
|
|
75
|
+
// unescaped \f terminates a double-quoted string exactly like \r/\n does —
|
|
76
|
+
// strip all three, not just the two obvious ones.
|
|
77
|
+
return String(s).replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\r\n\f]/g, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function FontPreview({ url, name }) {
|
|
81
|
+
const family = `st-preview-font-${name.replace(/[^a-z0-9]+/gi, '-')}`;
|
|
82
|
+
return (
|
|
83
|
+
<div className="st-file-preview-font">
|
|
84
|
+
<style>{`@font-face { font-family: "${family}"; src: url("${cssStringEscape(url)}"); }`}</style>
|
|
85
|
+
<div className="st-file-preview-font-specimen" style={{ fontFamily: family }}>
|
|
86
|
+
<div className="st-file-preview-font-lg">The quick brown fox</div>
|
|
87
|
+
<div className="st-file-preview-font-md">jumps over the lazy dog</div>
|
|
88
|
+
<div className="st-file-preview-font-sm">ABCDEFGHIJKLM abcdefghijklm 0123456789</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function FilePreview({ path, kind }) {
|
|
95
|
+
if (!path || !kind) return null;
|
|
96
|
+
const url = `/${path}`;
|
|
97
|
+
const name = sanitizeDisplayText(basename(path));
|
|
98
|
+
return (
|
|
99
|
+
<div className="st-file-preview" data-kind={kind}>
|
|
100
|
+
<h2 className="st-file-preview-header">{sanitizeDisplayText(path)}</h2>
|
|
101
|
+
<div className="st-file-preview-body">
|
|
102
|
+
{kind === 'markdown' && <TextPreview url={url} name={name} as="markdown" />}
|
|
103
|
+
{kind === 'text' && <TextPreview url={url} name={name} as="text" />}
|
|
104
|
+
{kind === 'image' && (
|
|
105
|
+
<div className="st-file-preview-image-wrap">
|
|
106
|
+
<img src={url} alt={`Preview: ${name}`} className="st-file-preview-image" />
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{kind === 'video' && <video key={url} src={url} controls className="st-file-preview-media" />}
|
|
110
|
+
{kind === 'audio' && <audio key={url} src={url} controls className="st-file-preview-audio" />}
|
|
111
|
+
{kind === 'font' && <FontPreview url={url} name={name} />}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|