@backmatter/studio 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/chunks/{EmbedPublishPage-DazYJLaD.js → EmbedPublishPage-88VsmOhi.js} +1 -1
  2. package/dist/chunks/{UiKitPage-DyVyBp0I.js → UiKitPage-X4hkFL2d.js} +3 -3
  3. package/dist/chunks/{WorkspaceRoutes-CPvkgvxe.js → WorkspaceRoutes-DXzdWqN3.js} +20287 -18407
  4. package/dist/chunks/{dashboard-6aSS8oyj.js → dashboard-DCHRI0WD.js} +465 -443
  5. package/dist/chunks/{index-DgTNL4t3.js → index-BS0-Bvbk.js} +2 -2
  6. package/dist/chunks/{status-bar-action-RNBujSgk.js → status-bar-action-wj1KZ51m.js} +5512 -5382
  7. package/dist/dashboard.js +2 -2
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.js +2 -2
  10. package/dist/studio.css +450 -62
  11. package/package.json +3 -2
  12. package/src/App.tsx +2 -0
  13. package/src/WorkspaceRoutes.tsx +2 -0
  14. package/src/api.ts +80 -0
  15. package/src/chat/ChatMessage.tsx +10 -3
  16. package/src/chat/Composer.tsx +18 -18
  17. package/src/chat/chat-helpers.ts +58 -1
  18. package/src/chrome/AppSidebar.tsx +31 -156
  19. package/src/chrome/CommandPalette.tsx +211 -74
  20. package/src/chrome/ContentExplorerSidebar.tsx +1174 -0
  21. package/src/chrome/UserMenu.tsx +18 -3
  22. package/src/chrome/WorkspaceNavContext.tsx +1 -0
  23. package/src/chrome/command-palette-icons.tsx +152 -0
  24. package/src/chrome/content-path-tree.ts +95 -0
  25. package/src/chrome/content-repo-path.ts +52 -0
  26. package/src/chrome/explorer-icons.tsx +89 -0
  27. package/src/chrome/folder-create-context.ts +147 -0
  28. package/src/pages/BlockSettingsDialog.tsx +4 -0
  29. package/src/pages/CliAuthorizePage.tsx +43 -0
  30. package/src/pages/DocEditorPage.tsx +33 -4
  31. package/src/pages/MediaFieldInput.tsx +139 -0
  32. package/src/pages/MediaGalleryPage.tsx +752 -0
  33. package/src/pages/MediaLightbox.tsx +460 -0
  34. package/src/pages/SettingsUsagePage.tsx +19 -0
  35. package/src/pages/TypeDocsPage.tsx +2 -210
  36. package/src/pages/WorkspaceLayout.tsx +6 -1
  37. package/src/pages/media-grid.ts +190 -0
  38. package/src/pages/preview-url.ts +13 -2
  39. package/src/replica/useReplica.ts +9 -0
  40. package/src/ui/ConfirmDialog.tsx +1 -1
  41. package/src/workspace/permissions.ts +9 -0
  42. package/src/workspace-path.ts +13 -0
@@ -0,0 +1,1174 @@
1
+ // packages/studio/src/chrome/ContentExplorerSidebar.tsx
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
4
+ import * as stylex from "@stylexjs/stylex";
5
+ import { notifyTreeChanged, saveDoc, type DocListItem, type DocStatus } from "../api";
6
+ import { useReplicaRevision, useReplicaState } from "../replica/useReplica";
7
+ import { colors, radius, space, type } from "../styles/tokens.stylex";
8
+ import { Tooltip } from "../ui/Tooltip";
9
+ import {
10
+ contentDocPath,
11
+ contentTypePath,
12
+ isWorkspaceContentPath,
13
+ isWorkspaceMediaPath,
14
+ isWorkspaceSettingsPath,
15
+ mediaPath,
16
+ settingsPath,
17
+ workspaceHomePath,
18
+ } from "../workspace-path";
19
+ import {
20
+ groupContentTypes,
21
+ type ContentTypeNav,
22
+ type ContentTypeSection,
23
+ } from "./content-type-groups";
24
+ import type { WorkspaceNav } from "./WorkspaceNavContext";
25
+ import { compareTypeDocs } from "../pages/type-docs";
26
+ import { docRepoPath, fileExtFromRepoPath } from "./content-repo-path";
27
+ import {
28
+ ExplorerFileIcon,
29
+ ExplorerFolderIcon,
30
+ ExplorerHeaderListIcon,
31
+ ExplorerHeaderNewFileIcon,
32
+ ExplorerTreeNewFileIcon,
33
+ ExplorerRailFilesIcon,
34
+ ExplorerRailHistoryIcon,
35
+ ExplorerRailMediaIcon,
36
+ ExplorerRailSearchIcon,
37
+ ExplorerRailSettingsIcon,
38
+ ExplorerRailSourceControlIcon,
39
+ ExplorerTwistie,
40
+ } from "./explorer-icons";
41
+ import {
42
+ buildPathTree,
43
+ folderKeysOnPathTo,
44
+ treePathFromRepo,
45
+ type PathTreeFile,
46
+ type PathTreeNode,
47
+ } from "./content-path-tree";
48
+ import {
49
+ isValidContentSlug,
50
+ resolveFolderCreateContext,
51
+ slugFromNewFileName,
52
+ withModelFolderPlaceholders,
53
+ type FolderCreateContext,
54
+ } from "./folder-create-context";
55
+ import { canWriteContent } from "../workspace/permissions";
56
+
57
+ const hover = "@media (hover: hover)";
58
+ const statusDot = "#ea580c";
59
+
60
+ /** VS Code–style explorer: 8px indent per depth, 22px rows. */
61
+ const TREE_PAD_PX = 8;
62
+ const INDENT_PX = 8;
63
+ const TWISTIE_PX = 16;
64
+
65
+ export function ContentExplorerSidebar({ workspace }: { workspace: WorkspaceNav }) {
66
+ const location = useLocation();
67
+ const [searchParams] = useSearchParams();
68
+ const replica = useReplicaState();
69
+ const replicaRevision = useReplicaRevision();
70
+ const grouped = useMemo(
71
+ () => groupContentTypes(workspace.contentModels),
72
+ [workspace.contentModels],
73
+ );
74
+ const docsByType = useMemo(() => {
75
+ const all = replica.docs(workspace.workspaceId);
76
+ const map = new Map<string, DocListItem[]>();
77
+ for (const doc of all) {
78
+ const list = map.get(doc.type);
79
+ if (list) list.push(doc);
80
+ else map.set(doc.type, [doc]);
81
+ }
82
+ for (const list of map.values()) {
83
+ list.sort(compareTypeDocs);
84
+ }
85
+ return map;
86
+ }, [replica, replicaRevision, workspace.workspaceId]);
87
+
88
+ const route = useMemo(
89
+ () => parseContentRoute(location.pathname, searchParams.get("locale") ?? ""),
90
+ [location.pathname, searchParams],
91
+ );
92
+ const git = replica.git(workspace.workspaceId);
93
+ const pendingCount =
94
+ git?.changes.filter((c) => c.status !== "deleted").length ?? 0;
95
+
96
+ const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
97
+ const toggleFolder = useCallback((pathKey: string) => {
98
+ setExpanded((prev) => {
99
+ const next = new Set(prev);
100
+ if (next.has(pathKey)) next.delete(pathKey);
101
+ else next.add(pathKey);
102
+ return next;
103
+ });
104
+ }, []);
105
+
106
+ const expandFolderPath = useCallback((pathKey: string) => {
107
+ setExpanded((prev) => {
108
+ const next = new Set(prev);
109
+ const parts = pathKey.split("/").filter(Boolean);
110
+ for (let i = 1; i <= parts.length; i++) {
111
+ next.add(parts.slice(0, i).join("/"));
112
+ }
113
+ return next;
114
+ });
115
+ }, []);
116
+
117
+ const models = workspace.contentModels;
118
+ useEffect(() => {
119
+ const repoPath = findActiveRepoPath(workspace, route, models, docsByType);
120
+ if (!repoPath) return;
121
+ const treePath = treePathFromRepo(repoPath);
122
+ setExpanded((prev) => {
123
+ const next = new Set(prev);
124
+ for (const key of folderKeysOnPathTo(treePath)) next.add(key);
125
+ return next;
126
+ });
127
+ }, [workspace, route, models, docsByType]);
128
+
129
+ const empty = workspace.contentTypes.length === 0;
130
+ const settingsActive = isWorkspaceSettingsPath(location.pathname);
131
+ const showTree = !isWorkspaceMediaPath(location.pathname);
132
+ const canWrite = canWriteContent(workspace.role);
133
+
134
+ return (
135
+ <div {...stylex.props(styles.root)}>
136
+ <ActivityRail
137
+ pendingCount={pendingCount}
138
+ settingsActive={settingsActive}
139
+ workspaceSlug={workspace.workspaceSlug}
140
+ contentRoute={route}
141
+ />
142
+ {showTree ? <div {...stylex.props(styles.panel)}>
143
+ <header {...stylex.props(styles.panelHeader)}>
144
+ <span {...stylex.props(styles.panelTitle)}>Content</span>
145
+ <div {...stylex.props(styles.panelActions)}>
146
+ <IconButton label="New file (soon)" disabled>
147
+ <ExplorerHeaderNewFileIcon />
148
+ </IconButton>
149
+ <IconButton label="View options (soon)" disabled>
150
+ <ExplorerHeaderListIcon />
151
+ </IconButton>
152
+ </div>
153
+ </header>
154
+ <div {...stylex.props(styles.treeScroll)}>
155
+ {empty ? (
156
+ <p {...stylex.props(styles.emptyHint)}>
157
+ No content types yet. Pull from GitHub to load models.
158
+ </p>
159
+ ) : (
160
+ <>
161
+ {grouped.pages.length > 0 ? (
162
+ <ExplorerSection
163
+ section="pages"
164
+ models={grouped.pages}
165
+ workspace={workspace}
166
+ docsByType={docsByType}
167
+ route={route}
168
+ expanded={expanded}
169
+ onToggleFolder={toggleFolder}
170
+ onExpandFolderPath={expandFolderPath}
171
+ canWrite={canWrite}
172
+ />
173
+ ) : null}
174
+ {grouped.data.length > 0 ? (
175
+ <ExplorerSection
176
+ section="data"
177
+ models={grouped.data}
178
+ workspace={workspace}
179
+ docsByType={docsByType}
180
+ route={route}
181
+ expanded={expanded}
182
+ onToggleFolder={toggleFolder}
183
+ onExpandFolderPath={expandFolderPath}
184
+ canWrite={canWrite}
185
+ />
186
+ ) : null}
187
+ {grouped.globals.length > 0 ? (
188
+ <ExplorerSection
189
+ section="globals"
190
+ models={grouped.globals}
191
+ workspace={workspace}
192
+ docsByType={docsByType}
193
+ route={route}
194
+ expanded={expanded}
195
+ onToggleFolder={toggleFolder}
196
+ onExpandFolderPath={expandFolderPath}
197
+ canWrite={canWrite}
198
+ />
199
+ ) : null}
200
+ </>
201
+ )}
202
+ </div>
203
+ </div> : null}
204
+ </div>
205
+ );
206
+ }
207
+
208
+ function ActivityRail({
209
+ pendingCount,
210
+ settingsActive,
211
+ workspaceSlug,
212
+ contentRoute,
213
+ }: {
214
+ pendingCount: number;
215
+ settingsActive: boolean;
216
+ workspaceSlug: string;
217
+ contentRoute: ContentRoute;
218
+ }) {
219
+ const navigate = useNavigate();
220
+ const location = useLocation();
221
+
222
+ return (
223
+ <nav
224
+ aria-label="Studio"
225
+ {...stylex.props(styles.rail, isWorkspaceMediaPath(location.pathname) && styles.railEdge)}
226
+ >
227
+ <div {...stylex.props(styles.railTop)}>
228
+ <Tooltip label="Content" side="right">
229
+ <button
230
+ type="button"
231
+ aria-label="Content"
232
+ aria-current={isWorkspaceContentPath(location.pathname) ? "page" : undefined}
233
+ onClick={() => {
234
+ if (isWorkspaceMediaPath(location.pathname)) {
235
+ navigate(workspaceHomePath(workspaceSlug));
236
+ return;
237
+ }
238
+ if (contentRoute.docSlug && contentRoute.type) {
239
+ navigate(contentTypePath(workspaceSlug, contentRoute.type));
240
+ }
241
+ }}
242
+ {...stylex.props(
243
+ styles.railBtn,
244
+ isWorkspaceContentPath(location.pathname) && styles.railBtnActive,
245
+ )}
246
+ >
247
+ <ExplorerRailFilesIcon />
248
+ </button>
249
+ </Tooltip>
250
+ <Tooltip label="Search (soon)" side="right">
251
+ <button type="button" disabled aria-label="Search" {...stylex.props(styles.railBtn, styles.railBtnDisabled)}>
252
+ <ExplorerRailSearchIcon />
253
+ </button>
254
+ </Tooltip>
255
+ <Tooltip label="Source control (soon)" side="right">
256
+ <button
257
+ type="button"
258
+ disabled
259
+ aria-label="Source control"
260
+ {...stylex.props(styles.railBtn, styles.railBtnDisabled, styles.railBtnBadgeHost)}
261
+ >
262
+ <ExplorerRailSourceControlIcon />
263
+ {pendingCount > 0 ? (
264
+ <span {...stylex.props(styles.badge)} aria-hidden>
265
+ {pendingCount > 9 ? "9+" : pendingCount}
266
+ </span>
267
+ ) : null}
268
+ </button>
269
+ </Tooltip>
270
+ <Tooltip label="History (soon)" side="right">
271
+ <button type="button" disabled aria-label="History" {...stylex.props(styles.railBtn, styles.railBtnDisabled)}>
272
+ <ExplorerRailHistoryIcon />
273
+ </button>
274
+ </Tooltip>
275
+ <Tooltip label="Media" side="right">
276
+ <Link
277
+ to={mediaPath(workspaceSlug)}
278
+ aria-label="Media"
279
+ aria-current={isWorkspaceMediaPath(location.pathname) ? "page" : undefined}
280
+ {...stylex.props(
281
+ styles.railBtn,
282
+ isWorkspaceMediaPath(location.pathname) && styles.railBtnActive,
283
+ )}
284
+ >
285
+ <ExplorerRailMediaIcon />
286
+ </Link>
287
+ </Tooltip>
288
+ </div>
289
+ <div {...stylex.props(styles.railBottom)}>
290
+ <Tooltip label="Settings" side="right">
291
+ <Link
292
+ to={settingsPath(workspaceSlug, "general")}
293
+ aria-label="Settings"
294
+ aria-current={settingsActive ? "page" : undefined}
295
+ {...stylex.props(styles.railBtn, settingsActive && styles.railBtnActive)}
296
+ >
297
+ <ExplorerRailSettingsIcon />
298
+ </Link>
299
+ </Tooltip>
300
+ </div>
301
+ </nav>
302
+ );
303
+ }
304
+
305
+ function ExplorerSection({
306
+ section,
307
+ models,
308
+ workspace,
309
+ docsByType,
310
+ route,
311
+ expanded,
312
+ onToggleFolder,
313
+ onExpandFolderPath,
314
+ canWrite,
315
+ }: {
316
+ section: ContentTypeSection;
317
+ models: ContentTypeNav[];
318
+ workspace: WorkspaceNav;
319
+ docsByType: Map<string, DocListItem[]>;
320
+ route: ContentRoute;
321
+ expanded: Set<string>;
322
+ onToggleFolder: (pathKey: string) => void;
323
+ onExpandFolderPath: (pathKey: string) => void;
324
+ canWrite: boolean;
325
+ }) {
326
+ const navigate = useNavigate();
327
+ const [newFileFolder, setNewFileFolder] = useState<string | null>(null);
328
+ const [newFileError, setNewFileError] = useState<string | null>(null);
329
+ const [creating, setCreating] = useState(false);
330
+
331
+ const tree = useMemo(() => {
332
+ const leaves: PathTreeFile[] = [];
333
+ for (const model of models) {
334
+ for (const doc of docsByType.get(model.type) ?? []) {
335
+ const repoPath = docRepoPath(model, doc, workspace.defaultLocale);
336
+ const treePath = treePathFromRepo(repoPath);
337
+ leaves.push({
338
+ kind: "file",
339
+ name: treePath.split("/").pop() ?? treePath,
340
+ repoPath: treePath,
341
+ href: contentDocPath(
342
+ workspace.workspaceSlug,
343
+ doc.type,
344
+ doc.slug,
345
+ doc.locale ?? "",
346
+ workspace.defaultLocale,
347
+ ),
348
+ active: docMatchesRoute(route, doc, workspace.defaultLocale),
349
+ dirty: isDirtyStatus(doc.status),
350
+ });
351
+ }
352
+ }
353
+ const built = buildPathTree(leaves);
354
+ return withModelFolderPlaceholders(
355
+ built,
356
+ models,
357
+ workspace.locales,
358
+ workspace.defaultLocale,
359
+ );
360
+ }, [models, docsByType, workspace, route]);
361
+
362
+ const startNewFile = useCallback(
363
+ (pathKey: string) => {
364
+ setNewFileError(null);
365
+ setNewFileFolder(pathKey);
366
+ onExpandFolderPath(pathKey);
367
+ },
368
+ [onExpandFolderPath],
369
+ );
370
+
371
+ const submitNewFile = useCallback(
372
+ async (pathKey: string, ctx: FolderCreateContext, rawName: string) => {
373
+ const slug = slugFromNewFileName(rawName, ctx.ext);
374
+ if (!slug) {
375
+ setNewFileError("Enter a file name.");
376
+ return;
377
+ }
378
+ if (!isValidContentSlug(slug)) {
379
+ setNewFileError("Use lowercase letters, numbers, and hyphens.");
380
+ return;
381
+ }
382
+ const list = docsByType.get(ctx.type) ?? [];
383
+ const localeForDoc = ctx.locale || workspace.defaultLocale;
384
+ const exists = list.some(
385
+ (d) =>
386
+ d.slug === slug &&
387
+ (d.locale || workspace.defaultLocale) === localeForDoc,
388
+ );
389
+ if (exists) {
390
+ setNewFileError("A file with that name already exists.");
391
+ return;
392
+ }
393
+ setCreating(true);
394
+ setNewFileError(null);
395
+ try {
396
+ const frontmatter = ctx.pageFormat ? { blocks: [] } : {};
397
+ const { doc } = await saveDoc(
398
+ workspace.workspaceId,
399
+ ctx.locale,
400
+ ctx.type,
401
+ slug,
402
+ { frontmatter, body: "" },
403
+ );
404
+ notifyTreeChanged({
405
+ workspaceId: workspace.workspaceId,
406
+ locale: doc.locale,
407
+ type: doc.type,
408
+ slug: doc.slug,
409
+ status: doc.status,
410
+ });
411
+ setNewFileFolder(null);
412
+ navigate(
413
+ contentDocPath(
414
+ workspace.workspaceSlug,
415
+ doc.type,
416
+ doc.slug,
417
+ doc.locale ?? "",
418
+ workspace.defaultLocale,
419
+ ),
420
+ );
421
+ } catch (err) {
422
+ setNewFileError(err instanceof Error ? err.message : "Could not create file.");
423
+ } finally {
424
+ setCreating(false);
425
+ }
426
+ },
427
+ [docsByType, navigate, workspace],
428
+ );
429
+
430
+ const hasTree = tree.length > 0;
431
+
432
+ return (
433
+ <section {...stylex.props(styles.section)}>
434
+ <div {...stylex.props(styles.sectionLabel)}>{sectionTitle(section)}</div>
435
+ {!hasTree ? (
436
+ <p {...stylex.props(styles.emptySection)}>No files</p>
437
+ ) : (
438
+ <PathTree
439
+ nodes={tree}
440
+ depth={0}
441
+ expanded={expanded}
442
+ onToggleFolder={onToggleFolder}
443
+ models={models}
444
+ locales={workspace.locales}
445
+ defaultLocale={workspace.defaultLocale}
446
+ canWrite={canWrite}
447
+ newFileFolder={newFileFolder}
448
+ newFileError={newFileError}
449
+ creating={creating}
450
+ onStartNewFile={startNewFile}
451
+ onCancelNewFile={() => {
452
+ setNewFileFolder(null);
453
+ setNewFileError(null);
454
+ }}
455
+ onSubmitNewFile={submitNewFile}
456
+ />
457
+ )}
458
+ </section>
459
+ );
460
+ }
461
+
462
+ function PathTree({
463
+ nodes,
464
+ depth,
465
+ expanded,
466
+ onToggleFolder,
467
+ models,
468
+ locales,
469
+ defaultLocale,
470
+ canWrite,
471
+ newFileFolder,
472
+ newFileError,
473
+ creating,
474
+ onStartNewFile,
475
+ onCancelNewFile,
476
+ onSubmitNewFile,
477
+ }: {
478
+ nodes: PathTreeNode[];
479
+ depth: number;
480
+ expanded: Set<string>;
481
+ onToggleFolder: (pathKey: string) => void;
482
+ models: ContentTypeNav[];
483
+ locales: string[];
484
+ defaultLocale: string;
485
+ canWrite: boolean;
486
+ newFileFolder: string | null;
487
+ newFileError: string | null;
488
+ creating: boolean;
489
+ onStartNewFile: (pathKey: string) => void;
490
+ onCancelNewFile: () => void;
491
+ onSubmitNewFile: (
492
+ pathKey: string,
493
+ ctx: FolderCreateContext,
494
+ rawName: string,
495
+ ) => void;
496
+ }) {
497
+ return (
498
+ <ul {...stylex.props(styles.tree)} role="group">
499
+ {nodes.map((node) =>
500
+ node.kind === "folder" ? (
501
+ <li key={node.pathKey}>
502
+ <FolderTreeRow
503
+ node={node}
504
+ depth={depth}
505
+ open={expanded.has(node.pathKey)}
506
+ onToggle={() => onToggleFolder(node.pathKey)}
507
+ createContext={
508
+ canWrite
509
+ ? resolveFolderCreateContext(
510
+ node.pathKey,
511
+ models,
512
+ locales,
513
+ defaultLocale,
514
+ )
515
+ : null
516
+ }
517
+ onNewFile={
518
+ canWrite
519
+ ? () => onStartNewFile(node.pathKey)
520
+ : undefined
521
+ }
522
+ />
523
+ {newFileFolder === node.pathKey ? (
524
+ <NewFileTreeRow
525
+ depth={depth + 1}
526
+ ext={
527
+ resolveFolderCreateContext(
528
+ node.pathKey,
529
+ models,
530
+ locales,
531
+ defaultLocale,
532
+ )?.ext ?? "mdx"
533
+ }
534
+ error={newFileError}
535
+ disabled={creating}
536
+ onCancel={onCancelNewFile}
537
+ onSubmit={(name) => {
538
+ const ctx = resolveFolderCreateContext(
539
+ node.pathKey,
540
+ models,
541
+ locales,
542
+ defaultLocale,
543
+ );
544
+ if (ctx) onSubmitNewFile(node.pathKey, ctx, name);
545
+ }}
546
+ />
547
+ ) : null}
548
+ {expanded.has(node.pathKey) && node.children.length > 0 ? (
549
+ <PathTree
550
+ nodes={node.children}
551
+ depth={depth + 1}
552
+ expanded={expanded}
553
+ onToggleFolder={onToggleFolder}
554
+ models={models}
555
+ locales={locales}
556
+ defaultLocale={defaultLocale}
557
+ canWrite={canWrite}
558
+ newFileFolder={newFileFolder}
559
+ newFileError={newFileError}
560
+ creating={creating}
561
+ onStartNewFile={onStartNewFile}
562
+ onCancelNewFile={onCancelNewFile}
563
+ onSubmitNewFile={onSubmitNewFile}
564
+ />
565
+ ) : null}
566
+ </li>
567
+ ) : (
568
+ <li key={node.repoPath}>
569
+ <FileTreeRow node={node} depth={depth} />
570
+ </li>
571
+ ),
572
+ )}
573
+ </ul>
574
+ );
575
+ }
576
+
577
+ function treeRowPad(depth: number) {
578
+ return TREE_PAD_PX + depth * INDENT_PX;
579
+ }
580
+
581
+ function indentGuideLeft(level: number) {
582
+ return TREE_PAD_PX + level * INDENT_PX + TWISTIE_PX / 2;
583
+ }
584
+
585
+ function TreeRowFrame({
586
+ depth,
587
+ children,
588
+ }: {
589
+ depth: number;
590
+ children: React.ReactNode;
591
+ }) {
592
+ return (
593
+ <div {...stylex.props(styles.treeRowFrame)}>
594
+ {depth > 0 ? (
595
+ <span {...stylex.props(styles.indentGuides)} aria-hidden>
596
+ {Array.from({ length: depth }, (_, level) => (
597
+ <span
598
+ key={level}
599
+ {...stylex.props(styles.indentGuide)}
600
+ style={{ left: indentGuideLeft(level) }}
601
+ />
602
+ ))}
603
+ </span>
604
+ ) : null}
605
+ {children}
606
+ </div>
607
+ );
608
+ }
609
+
610
+ function FolderTreeRow({
611
+ node,
612
+ depth,
613
+ open,
614
+ onToggle,
615
+ createContext,
616
+ onNewFile,
617
+ }: {
618
+ node: Extract<PathTreeNode, { kind: "folder" }>;
619
+ depth: number;
620
+ open: boolean;
621
+ onToggle: () => void;
622
+ createContext: FolderCreateContext | null;
623
+ onNewFile?: () => void;
624
+ }) {
625
+ return (
626
+ <TreeRowFrame depth={depth}>
627
+ <div {...stylex.props(styles.folderRowHost)}>
628
+ <button
629
+ type="button"
630
+ aria-expanded={open}
631
+ aria-label={node.name}
632
+ onClick={onToggle}
633
+ {...stylex.props(styles.treeRow, styles.folderRowBtn)}
634
+ style={{ paddingLeft: treeRowPad(depth) }}
635
+ >
636
+ <span {...stylex.props(styles.twistie)}>
637
+ <ExplorerTwistie open={open} />
638
+ </span>
639
+ <span {...stylex.props(styles.treeIcon)}>
640
+ <ExplorerFolderIcon open={open} />
641
+ </span>
642
+ <span {...stylex.props(styles.treeLabel)}>{node.name}</span>
643
+ </button>
644
+ {createContext && onNewFile ? (
645
+ <Tooltip label="New file" side="right">
646
+ <button
647
+ type="button"
648
+ data-folder-action
649
+ aria-label={`New file in ${node.name}`}
650
+ onClick={(e) => {
651
+ e.stopPropagation();
652
+ onNewFile();
653
+ }}
654
+ {...stylex.props(styles.folderActionBtn)}
655
+ >
656
+ <ExplorerTreeNewFileIcon />
657
+ </button>
658
+ </Tooltip>
659
+ ) : null}
660
+ </div>
661
+ </TreeRowFrame>
662
+ );
663
+ }
664
+
665
+ function NewFileTreeRow({
666
+ depth,
667
+ ext,
668
+ error,
669
+ disabled,
670
+ onCancel,
671
+ onSubmit,
672
+ }: {
673
+ depth: number;
674
+ ext: string;
675
+ error: string | null;
676
+ disabled: boolean;
677
+ onCancel: () => void;
678
+ onSubmit: (name: string) => void;
679
+ }) {
680
+ const inputRef = useRef<HTMLInputElement>(null);
681
+ const [value, setValue] = useState("");
682
+
683
+ useEffect(() => {
684
+ inputRef.current?.focus();
685
+ }, []);
686
+
687
+ return (
688
+ <TreeRowFrame depth={depth}>
689
+ <form
690
+ {...stylex.props(styles.newFileForm)}
691
+ style={{ paddingLeft: treeRowPad(depth) }}
692
+ onSubmit={(e) => {
693
+ e.preventDefault();
694
+ if (!disabled) onSubmit(value);
695
+ }}
696
+ >
697
+ <span {...stylex.props(styles.twistie)} aria-hidden />
698
+ <span {...stylex.props(styles.treeIcon)}>
699
+ <ExplorerFileIcon ext={ext} />
700
+ </span>
701
+ <input
702
+ ref={inputRef}
703
+ type="text"
704
+ value={value}
705
+ disabled={disabled}
706
+ placeholder={`name.${ext}`}
707
+ aria-label="New file name"
708
+ aria-invalid={error ? true : undefined}
709
+ onChange={(e) => setValue(e.target.value)}
710
+ onKeyDown={(e) => {
711
+ if (e.key === "Escape") {
712
+ e.preventDefault();
713
+ onCancel();
714
+ }
715
+ }}
716
+ {...stylex.props(styles.newFileInput)}
717
+ />
718
+ </form>
719
+ {error ? (
720
+ <p
721
+ {...stylex.props(styles.newFileError)}
722
+ style={{ paddingLeft: treeRowPad(depth) }}
723
+ role="alert"
724
+ >
725
+ {error}
726
+ </p>
727
+ ) : null}
728
+ </TreeRowFrame>
729
+ );
730
+ }
731
+
732
+ function FileTreeRow({
733
+ node,
734
+ depth,
735
+ }: {
736
+ node: PathTreeFile;
737
+ depth: number;
738
+ }) {
739
+ const ext = fileExtFromRepoPath(node.repoPath);
740
+ return (
741
+ <TreeRowFrame depth={depth}>
742
+ <Link
743
+ to={node.href}
744
+ title={`content/${node.repoPath}`}
745
+ {...stylex.props(styles.treeRow, styles.treeRowLink, node.active && styles.treeRowActive)}
746
+ style={{ paddingLeft: treeRowPad(depth) }}
747
+ >
748
+ <span {...stylex.props(styles.twistie)} aria-hidden />
749
+ <span {...stylex.props(styles.treeIcon)}>
750
+ <ExplorerFileIcon ext={ext} />
751
+ </span>
752
+ <span {...stylex.props(styles.treeLabel)}>{node.name}</span>
753
+ {node.dirty ? <span {...stylex.props(styles.fileDot)} aria-hidden /> : null}
754
+ </Link>
755
+ </TreeRowFrame>
756
+ );
757
+ }
758
+
759
+ function findActiveRepoPath(
760
+ workspace: WorkspaceNav,
761
+ route: ContentRoute,
762
+ models: ContentTypeNav[],
763
+ docsByType: Map<string, DocListItem[]>,
764
+ ): string | null {
765
+ if (!route.type || !route.docSlug) return null;
766
+ const model = models.find((m) => m.type === route.type);
767
+ if (!model) return null;
768
+ const doc = (docsByType.get(route.type) ?? []).find((d) =>
769
+ docMatchesRoute(route, d, workspace.defaultLocale),
770
+ );
771
+ if (!doc) return null;
772
+ return docRepoPath(model, doc, workspace.defaultLocale);
773
+ }
774
+
775
+ function IconButton({
776
+ label,
777
+ disabled,
778
+ children,
779
+ }: {
780
+ label: string;
781
+ disabled?: boolean;
782
+ children: React.ReactNode;
783
+ }) {
784
+ return (
785
+ <Tooltip label={label} side="bottom">
786
+ <button
787
+ type="button"
788
+ disabled={disabled}
789
+ aria-label={label}
790
+ {...stylex.props(styles.headerIconBtn, disabled && styles.headerIconBtnDisabled)}
791
+ >
792
+ {children}
793
+ </button>
794
+ </Tooltip>
795
+ );
796
+ }
797
+
798
+ type ContentRoute = {
799
+ type: string | null;
800
+ docSlug: string | null;
801
+ locale: string;
802
+ };
803
+
804
+ function parseContentRoute(pathname: string, localeParam: string): ContentRoute {
805
+ const match = pathname.match(/\/content\/([^/]+)(?:\/([^/?]+))?/);
806
+ if (!match) return { type: null, docSlug: null, locale: "" };
807
+ const type = decodeURIComponent(match[1]!);
808
+ const docSlug = match[2] ? decodeURIComponent(match[2]) : null;
809
+ return { type, docSlug, locale: localeParam };
810
+ }
811
+
812
+ function docMatchesRoute(
813
+ route: ContentRoute,
814
+ doc: DocListItem,
815
+ defaultLocale: string,
816
+ ) {
817
+ if (!route.type || route.docSlug !== doc.slug || route.type !== doc.type) {
818
+ return false;
819
+ }
820
+ const docLocale = doc.locale || defaultLocale;
821
+ const urlLocale = route.locale || defaultLocale;
822
+ return docLocale === urlLocale;
823
+ }
824
+
825
+ function isDirtyStatus(status: DocStatus) {
826
+ return status === "modified" || status === "added" || status === "conflict";
827
+ }
828
+
829
+ function sectionTitle(section: ContentTypeSection) {
830
+ if (section === "pages") return "Routes";
831
+ if (section === "data") return "Data";
832
+ return "Globals";
833
+ }
834
+
835
+ const styles = stylex.create({
836
+ root: {
837
+ display: "flex",
838
+ minHeight: 0,
839
+ flex: 1,
840
+ flexDirection: "row",
841
+ },
842
+ rail: {
843
+ display: "flex",
844
+ width: "48px",
845
+ flexShrink: 0,
846
+ flexDirection: "column",
847
+ justifyContent: "space-between",
848
+ alignItems: "center",
849
+ borderRightWidth: 1,
850
+ borderRightStyle: "solid",
851
+ borderRightColor: colors.border,
852
+ backgroundColor: colors.bgSidebar,
853
+ paddingBlock: space.md,
854
+ },
855
+ railEdge: {
856
+ borderRightWidth: 0,
857
+ },
858
+ railTop: {
859
+ display: "flex",
860
+ flexDirection: "column",
861
+ alignItems: "center",
862
+ gap: 2,
863
+ },
864
+ railBottom: {
865
+ display: "flex",
866
+ flexDirection: "column",
867
+ alignItems: "center",
868
+ },
869
+ railBtn: {
870
+ position: "relative",
871
+ display: "flex",
872
+ alignItems: "center",
873
+ justifyContent: "center",
874
+ width: 36,
875
+ height: 36,
876
+ borderWidth: 0,
877
+ borderStyle: "none",
878
+ borderRadius: radius.md,
879
+ backgroundColor: "transparent",
880
+ color: colors.text,
881
+ textDecoration: "none",
882
+ cursor: "pointer",
883
+ [hover]: {
884
+ ":hover": {
885
+ backgroundColor: colors.bgHover,
886
+ },
887
+ },
888
+ },
889
+ railBtnActive: {
890
+ backgroundColor: colors.bgActive,
891
+ color: colors.text,
892
+ },
893
+ railBtnDisabled: {
894
+ color: colors.textMuted,
895
+ opacity: 0.45,
896
+ cursor: "not-allowed",
897
+ [hover]: {
898
+ ":hover": {
899
+ backgroundColor: "transparent",
900
+ },
901
+ },
902
+ },
903
+ railBtnBadgeHost: {
904
+ overflow: "visible",
905
+ },
906
+ badge: {
907
+ position: "absolute",
908
+ top: 4,
909
+ right: 2,
910
+ minWidth: 14,
911
+ height: 14,
912
+ paddingInline: 3,
913
+ borderRadius: 999,
914
+ backgroundColor: statusDot,
915
+ color: colors.textInverse,
916
+ fontSize: "9px",
917
+ fontWeight: 700,
918
+ lineHeight: "14px",
919
+ textAlign: "center",
920
+ pointerEvents: "none",
921
+ },
922
+ panel: {
923
+ display: "flex",
924
+ minWidth: 0,
925
+ minHeight: 0,
926
+ flex: 1,
927
+ flexDirection: "column",
928
+ backgroundColor: colors.bgSurface,
929
+ },
930
+ panelHeader: {
931
+ display: "flex",
932
+ flexShrink: 0,
933
+ alignItems: "center",
934
+ justifyContent: "space-between",
935
+ gap: space.md,
936
+ paddingInline: space.lg,
937
+ paddingBlock: space.md,
938
+ borderBottomWidth: 1,
939
+ borderBottomStyle: "solid",
940
+ borderBottomColor: colors.border,
941
+ },
942
+ panelTitle: {
943
+ fontSize: type.xs,
944
+ fontWeight: 600,
945
+ letterSpacing: "0.1em",
946
+ textTransform: "uppercase",
947
+ color: colors.textMuted,
948
+ },
949
+ panelActions: {
950
+ display: "flex",
951
+ alignItems: "center",
952
+ gap: 2,
953
+ },
954
+ headerIconBtn: {
955
+ display: "flex",
956
+ alignItems: "center",
957
+ justifyContent: "center",
958
+ width: 26,
959
+ height: 26,
960
+ borderWidth: 0,
961
+ borderStyle: "none",
962
+ borderRadius: radius.sm,
963
+ backgroundColor: "transparent",
964
+ color: colors.textMuted,
965
+ cursor: "pointer",
966
+ [hover]: {
967
+ ":hover": {
968
+ backgroundColor: colors.bgHover,
969
+ color: colors.text,
970
+ },
971
+ },
972
+ },
973
+ headerIconBtnDisabled: {
974
+ opacity: 0.35,
975
+ cursor: "not-allowed",
976
+ [hover]: {
977
+ ":hover": {
978
+ backgroundColor: "transparent",
979
+ color: colors.textMuted,
980
+ },
981
+ },
982
+ },
983
+ treeScroll: {
984
+ minHeight: 0,
985
+ flex: 1,
986
+ overflowY: "auto",
987
+ overflowX: "hidden",
988
+ paddingBlock: space.sm,
989
+ },
990
+ emptyHint: {
991
+ margin: 0,
992
+ paddingInline: `${TREE_PAD_PX}px`,
993
+ paddingBlock: space.lg,
994
+ fontSize: type.sm,
995
+ lineHeight: 1.45,
996
+ color: colors.textMuted,
997
+ },
998
+ section: {
999
+ marginBottom: space.md,
1000
+ },
1001
+ sectionLabel: {
1002
+ paddingInline: `${TREE_PAD_PX}px`,
1003
+ paddingBlock: space.xs,
1004
+ fontSize: type.xs,
1005
+ fontWeight: 600,
1006
+ letterSpacing: "0.06em",
1007
+ textTransform: "uppercase",
1008
+ color: colors.textMuted,
1009
+ },
1010
+ emptySection: {
1011
+ margin: 0,
1012
+ paddingInline: `${TREE_PAD_PX}px`,
1013
+ paddingBlock: space.xs,
1014
+ fontSize: type.xs,
1015
+ color: colors.textMuted,
1016
+ },
1017
+ tree: {
1018
+ margin: 0,
1019
+ padding: 0,
1020
+ listStyle: "none",
1021
+ },
1022
+ treeRow: {
1023
+ display: "flex",
1024
+ width: "100%",
1025
+ minWidth: 0,
1026
+ alignItems: "center",
1027
+ gap: 4,
1028
+ height: "22px",
1029
+ paddingRight: `${TREE_PAD_PX}px`,
1030
+ borderWidth: 0,
1031
+ borderStyle: "none",
1032
+ borderRadius: 0,
1033
+ backgroundColor: "transparent",
1034
+ color: colors.text,
1035
+ fontFamily: type.sans,
1036
+ fontSize: type.md,
1037
+ fontWeight: 400,
1038
+ textAlign: "left",
1039
+ cursor: "pointer",
1040
+ [hover]: {
1041
+ ":hover": {
1042
+ backgroundColor: colors.bgHover,
1043
+ },
1044
+ },
1045
+ },
1046
+ treeRowLink: {
1047
+ textDecoration: "none",
1048
+ cursor: "pointer",
1049
+ },
1050
+ treeRowActive: {
1051
+ backgroundColor: colors.bgActive,
1052
+ },
1053
+ treeRowFrame: {
1054
+ position: "relative",
1055
+ },
1056
+ folderRowHost: {
1057
+ position: "relative",
1058
+ display: "flex",
1059
+ alignItems: "center",
1060
+ minWidth: 0,
1061
+ [hover]: {
1062
+ ":hover [data-folder-action]": {
1063
+ opacity: 1,
1064
+ },
1065
+ },
1066
+ },
1067
+ folderRowBtn: {
1068
+ flex: 1,
1069
+ minWidth: 0,
1070
+ },
1071
+ folderActionBtn: {
1072
+ position: "absolute",
1073
+ right: 2,
1074
+ top: 0,
1075
+ display: "flex",
1076
+ alignItems: "center",
1077
+ justifyContent: "center",
1078
+ width: 22,
1079
+ height: 22,
1080
+ borderWidth: 0,
1081
+ borderStyle: "none",
1082
+ borderRadius: radius.sm,
1083
+ backgroundColor: "transparent",
1084
+ color: colors.textMuted,
1085
+ opacity: 0.55,
1086
+ cursor: "pointer",
1087
+ [hover]: {
1088
+ opacity: 0,
1089
+ ":hover": {
1090
+ backgroundColor: colors.bgHover,
1091
+ color: colors.text,
1092
+ },
1093
+ },
1094
+ },
1095
+ newFileForm: {
1096
+ display: "flex",
1097
+ width: "100%",
1098
+ minWidth: 0,
1099
+ alignItems: "center",
1100
+ gap: 4,
1101
+ height: "22px",
1102
+ paddingRight: `${TREE_PAD_PX}px`,
1103
+ },
1104
+ newFileInput: {
1105
+ minWidth: 0,
1106
+ flex: 1,
1107
+ height: 20,
1108
+ paddingInline: 4,
1109
+ borderWidth: 1,
1110
+ borderStyle: "solid",
1111
+ borderColor: colors.border,
1112
+ borderRadius: radius.sm,
1113
+ backgroundColor: colors.bgSurface,
1114
+ color: colors.text,
1115
+ fontFamily: type.sans,
1116
+ fontSize: type.md,
1117
+ outline: "none",
1118
+ ":focus": {
1119
+ borderColor: colors.textMuted,
1120
+ },
1121
+ },
1122
+ newFileError: {
1123
+ margin: 0,
1124
+ paddingRight: `${TREE_PAD_PX}px`,
1125
+ paddingBottom: space.xs,
1126
+ fontSize: type.xs,
1127
+ color: "#dc2626",
1128
+ },
1129
+ indentGuides: {
1130
+ position: "absolute",
1131
+ inset: 0,
1132
+ pointerEvents: "none",
1133
+ },
1134
+ indentGuide: {
1135
+ position: "absolute",
1136
+ top: 0,
1137
+ bottom: 0,
1138
+ width: 1,
1139
+ backgroundColor: colors.border,
1140
+ opacity: 0.65,
1141
+ transform: "translateX(-0.5px)",
1142
+ },
1143
+ twistie: {
1144
+ display: "flex",
1145
+ flexShrink: 0,
1146
+ alignItems: "center",
1147
+ justifyContent: "center",
1148
+ width: `${TWISTIE_PX}px`,
1149
+ height: `${TWISTIE_PX}px`,
1150
+ color: colors.textMuted,
1151
+ },
1152
+ treeIcon: {
1153
+ display: "flex",
1154
+ flexShrink: 0,
1155
+ alignItems: "center",
1156
+ justifyContent: "center",
1157
+ color: colors.textMuted,
1158
+ },
1159
+ treeLabel: {
1160
+ minWidth: 0,
1161
+ flex: 1,
1162
+ overflow: "hidden",
1163
+ textOverflow: "ellipsis",
1164
+ whiteSpace: "nowrap",
1165
+ },
1166
+ fileDot: {
1167
+ width: 6,
1168
+ height: 6,
1169
+ flexShrink: 0,
1170
+ marginLeft: space.xs,
1171
+ borderRadius: 999,
1172
+ backgroundColor: statusDot,
1173
+ },
1174
+ });