@backmatter/studio 0.0.6 → 0.0.8

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