@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 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
- git tag "v$(node -p "require('./package.json').version")"
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
 
@@ -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))) out.push(rel);
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(groupAbs, groupRel, ['.tsx', '.html', '.md', '.css', '.json'], dirs)
5279
- : await findFiles(groupAbs, groupRel, ['.tsx', '.html', '.css', '.json'], dirs);
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
- const isSel = file.path === activePath;
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
- // Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
2041
- // clicking them doesn't open an iframe; we leave the click as no-op + cursor
2042
- // hint via `aria-disabled`.
2043
- const inert = !isCanvas;
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 (!inert) onOpen(file.path);
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. Orphans surface only when `showHidden` is on.
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
- orphans.map((entry) => (
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
- {tabs.length === 0 && (
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. Builds React nodes
2
- // directly (never innerHTML) so agent output can't inject markup. Covers the
3
- // subset a coding agent actually emits: fenced code blocks, headings, bullet +
4
- // numbered lists, bold / italic / inline code, and http(s) links. Anything else
5
- // falls through as plain (pre-wrapped) text. No new dependency.
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 "invoice‮gnp.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
+ }