@backmatter/studio 0.0.15 → 0.0.16

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 (61) hide show
  1. package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-_EDqWID3.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-B2PrFgpz.js} +28979 -27418
  3. package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-BnlnMnnr.js} +55 -54
  4. package/dist/chunks/{index-DHJvh5Uy.js → index-D_BHF_8q.js} +1 -1
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/site-widget.css +4 -0
  8. package/dist/site-widget.js +8304 -8029
  9. package/dist/studio.css +186 -12
  10. package/package.json +2 -2
  11. package/src/WorkspaceRoutes.tsx +5 -2
  12. package/src/api.ts +63 -7
  13. package/src/chat/ChangeCard.tsx +8 -2
  14. package/src/chat/ChatMessage.tsx +171 -102
  15. package/src/chat/ChatPanel.tsx +54 -34
  16. package/src/chat/ChatProvider.tsx +116 -20
  17. package/src/chat/Composer.tsx +48 -7
  18. package/src/chat/PointerChip.tsx +9 -5
  19. package/src/chat/ThreadHistory.tsx +3 -3
  20. package/src/chat/chat-context.ts +67 -3
  21. package/src/chat/chat-helpers.ts +12 -0
  22. package/src/chat/chat-state.ts +11 -0
  23. package/src/chrome/AppShell.tsx +16 -5
  24. package/src/chrome/CommandPalette.tsx +12 -0
  25. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  26. package/src/chrome/UserMenu.tsx +34 -3
  27. package/src/chrome/WorkspaceStatusBar.tsx +209 -27
  28. package/src/chrome/content-api-url.ts +0 -78
  29. package/src/chrome/content-tree-visibility.ts +13 -3
  30. package/src/chrome/explorer-icons.tsx +5 -0
  31. package/src/code-session-entry.ts +9 -0
  32. package/src/components/WorkspaceSwitcher.tsx +2 -2
  33. package/src/element-origin.ts +149 -0
  34. package/src/host-pointer-bootstrap.ts +11 -0
  35. package/src/host-pointer.ts +147 -0
  36. package/src/install-preview-pointer.ts +19 -3
  37. package/src/pages/CodeSessionFiles.tsx +221 -0
  38. package/src/pages/CodeSessionPage.tsx +1188 -0
  39. package/src/pages/DocEditorPage.tsx +33 -15
  40. package/src/pages/PreviewSize.tsx +181 -0
  41. package/src/pages/SettingsGeneralPage.tsx +67 -2
  42. package/src/pages/WorkspaceHomePage.tsx +313 -0
  43. package/src/pages/WorkspaceLayout.tsx +10 -1
  44. package/src/pages/code-session-boot.ts +43 -0
  45. package/src/pages/code-session-context.tsx +51 -0
  46. package/src/pages/code-session-files.ts +42 -0
  47. package/src/pages/code-session-frame.ts +52 -0
  48. package/src/pages/code-session-routes.ts +42 -0
  49. package/src/pages/home-rows.ts +140 -0
  50. package/src/pages/preview-size.ts +33 -0
  51. package/src/pages/session-preview.ts +109 -0
  52. package/src/pages/use-session-preview.ts +70 -0
  53. package/src/replica/store.ts +11 -0
  54. package/src/replica/types.ts +6 -0
  55. package/src/site-pointer.ts +24 -5
  56. package/src/studio-config.ts +1 -1
  57. package/src/ui/ListRow.tsx +12 -3
  58. package/src/ui/Page.tsx +12 -1
  59. package/src/workspace-path.ts +35 -3
  60. package/src/chrome/ContentApiPopover.tsx +0 -215
  61. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -1,21 +1,31 @@
1
1
  // packages/studio/src/chrome/content-tree-visibility.ts
2
2
  import { useEffect, useState } from "react";
3
3
  import type { KeyValueStore } from "../last-workspace";
4
- import { isWorkspaceContentPath, isWorkspaceEditorPath } from "../workspace-path";
4
+ import {
5
+ isWorkspaceContentPath,
6
+ isWorkspaceEditorPath,
7
+ isWorkspaceHomePath,
8
+ } from "../workspace-path";
5
9
 
6
10
  export const CONTENT_TREE_OPEN_KEY = "backmatter-content-tree-open";
7
11
  const CONTENT_TREE_EVENT = "backmatter-content-tree";
8
12
 
9
13
  /**
10
- * The type index is the file tree, so it stays open.
11
- * A document follows the editor toggle. Other routes have no tree.
14
+ * The content index and a type index keep the file tree open.
15
+ * A document follows the editor toggle. Home, settings, and media have no tree.
12
16
  */
13
17
  export function contentTreeVisible(pathname: string, open: boolean) {
18
+ if (isWorkspaceHomePath(pathname)) return false;
14
19
  if (!isWorkspaceContentPath(pathname)) return false;
15
20
  if (isWorkspaceEditorPath(pathname)) return open;
16
21
  return true;
17
22
  }
18
23
 
24
+ /** Content rail is current wherever the file tree is open. */
25
+ export function contentRailCurrent(pathname: string, treeOpen: boolean) {
26
+ return contentTreeVisible(pathname, treeOpen);
27
+ }
28
+
19
29
  export function readContentTreeOpen(storage: KeyValueStore = localStorage) {
20
30
  try {
21
31
  return storage.getItem(CONTENT_TREE_OPEN_KEY) !== "0";
@@ -7,6 +7,7 @@ import {
7
7
  IconChevronRightSmall,
8
8
  IconBrackets2,
9
9
  IconFileText,
10
+ IconCode,
10
11
  IconFiles,
11
12
  IconFolder1,
12
13
  IconGit,
@@ -70,6 +71,10 @@ export function ExplorerRailMediaIcon() {
70
71
  return <Central icon={IconImages1} size={RAIL} />;
71
72
  }
72
73
 
74
+ export function ExplorerRailCodeIcon() {
75
+ return <Central icon={IconCode} size={RAIL} />;
76
+ }
77
+
73
78
  export function ExplorerRailSettingsIcon() {
74
79
  return <Central icon={IconSettingsGear2} size={RAIL} />;
75
80
  }
@@ -0,0 +1,9 @@
1
+ // packages/studio/src/code-session-entry.ts
2
+ import { canDevelop } from "./workspace/permissions";
3
+ import { codeSessionPath } from "./workspace-path";
4
+
5
+ /** Where a developer opens a code session. Writers and editors get nothing. */
6
+ export function codeSessionEntry(role: string, slug: string): string | null {
7
+ if (!canDevelop(role)) return null;
8
+ return codeSessionPath(slug);
9
+ }
@@ -47,9 +47,9 @@ export function WorkspaceSwitcher({
47
47
  (id: string) => {
48
48
  setOpen(false);
49
49
  const next = workspaces.find((workspace) => workspace.id === id);
50
- if (next && id !== workspaceId) navigate(workspaceHomePath(next.slug));
50
+ if (next) navigate(workspaceHomePath(next.slug));
51
51
  },
52
- [navigate, workspaceId, workspaces],
52
+ [navigate, workspaces],
53
53
  );
54
54
 
55
55
  // Number keys 1–9 jump straight to a workspace while the menu is open.
@@ -0,0 +1,149 @@
1
+ // packages/studio/src/element-origin.ts
2
+ // Where a rendered element comes from, read from the React fiber the dev server
3
+ // already attached. A production build or a non-React host yields nothing.
4
+
5
+ export type ElementOrigin = {
6
+ component: string | null;
7
+ file: string | null;
8
+ line: number | null;
9
+ column: number | null;
10
+ stack: string[];
11
+ };
12
+
13
+ const STACK_CAP = 4;
14
+ const SOURCE_ROOT = /(?:^|\/)((?:src|app|pages|components|blocks|lib|content)\/[\w./@+-]+\.(?:tsx|ts|jsx|js|mdx))$/;
15
+ const SOURCE_FILE = /^[\w./@+-]+\.(?:tsx|ts|jsx|js|mdx)$/;
16
+
17
+ type Fiber = {
18
+ type?: unknown;
19
+ elementType?: unknown;
20
+ return?: Fiber | null;
21
+ _debugStack?: unknown;
22
+ _debugInfo?: unknown;
23
+ };
24
+
25
+ function fiberOf(element: Element): Fiber | null {
26
+ for (const key of Object.keys(element)) {
27
+ if (!key.startsWith("__reactFiber$") && !key.startsWith("__reactInternalInstance$")) continue;
28
+ const value = (element as unknown as Record<string, unknown>)[key];
29
+ if (value && typeof value === "object") return value as Fiber;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ function functionName(value: unknown): string | null {
35
+ if (typeof value === "function") {
36
+ const named = value as { displayName?: string; name?: string };
37
+ const name = named.displayName || named.name;
38
+ return name && name !== "Unknown" ? name : null;
39
+ }
40
+ if (!value || typeof value !== "object") return null;
41
+ const record = value as { displayName?: string; render?: unknown; type?: unknown };
42
+ if (typeof record.displayName === "string" && record.displayName) return record.displayName;
43
+ const rendered = functionName(record.render);
44
+ if (rendered) return rendered;
45
+ return functionName(record.type);
46
+ }
47
+
48
+ function componentName(fiber: Fiber): string | null {
49
+ const named = functionName(fiber.type);
50
+ if (named) return named;
51
+ return functionName(fiber.elementType);
52
+ }
53
+
54
+ function pushName(names: string[], name: string | null) {
55
+ if (!name || names.includes(name) || names.length >= STACK_CAP) return;
56
+ names.push(name);
57
+ }
58
+
59
+ function normalizeFile(raw: string): string | null {
60
+ let file = raw.trim();
61
+ file = file.replace(/^webpack-internal:\/\/\/(?:\([^)]*\)\/)?/, "");
62
+ file = file.replace(/^webpack:\/\/\/?/, "");
63
+ if (file.startsWith("./")) file = file.slice(2);
64
+ if (file.startsWith("file://")) {
65
+ try {
66
+ file = decodeURIComponent(new URL(file).pathname);
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+ file = file.split(/[?#]/)[0] ?? "";
72
+ if (/node_modules|react-dom|react-jsx|next\/dist|webpack\/bootstrap|_next\/|https?:/i.test(file)) return null;
73
+ const rooted = file.match(SOURCE_ROOT);
74
+ if (rooted?.[1]) return rooted[1];
75
+ if (SOURCE_FILE.test(file) && !file.startsWith("/") && !file.includes("..")) return file;
76
+ return null;
77
+ }
78
+
79
+ function frameAt(line: string): { file: string; line: number; column: number } | null {
80
+ const trimmed = line.trim();
81
+ if (!trimmed || trimmed.includes("react-stack-top-frame") || trimmed.includes("react_stack_bottom_frame")) {
82
+ return null;
83
+ }
84
+ const chrome = trimmed.match(/^at\s+(?:async\s+)?(?:.*?\s+\()?(.+?):(\d+):(\d+)\)?$/);
85
+ const firefox = chrome ? null : trimmed.match(/^(?:.*@)?(.+?):(\d+):(\d+)$/);
86
+ const match = chrome ?? firefox;
87
+ if (!match?.[1]) return null;
88
+ const file = normalizeFile(match[1]);
89
+ if (!file) return null;
90
+ const lineNo = Number(match[2]);
91
+ const column = Number(match[3]);
92
+ if (!Number.isInteger(lineNo) || lineNo < 1 || !Number.isInteger(column) || column < 1) return null;
93
+ return { file, line: lineNo, column };
94
+ }
95
+
96
+ function stackText(value: unknown): string {
97
+ if (typeof value === "string") return value;
98
+ if (value && typeof value === "object" && "stack" in value) {
99
+ const stack = (value as { stack?: unknown }).stack;
100
+ return typeof stack === "string" ? stack : "";
101
+ }
102
+ return "";
103
+ }
104
+
105
+ function locationFromStack(value: unknown): { file: string; line: number; column: number } | null {
106
+ const text = stackText(value);
107
+ if (!text) return null;
108
+ for (const line of text.split("\n")) {
109
+ if (line.includes("react_stack_bottom_frame")) break;
110
+ const frame = frameAt(line);
111
+ if (frame) return frame;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function debugNames(value: unknown, names: string[]) {
117
+ if (!Array.isArray(value)) return;
118
+ for (const entry of value) {
119
+ if (!entry || typeof entry !== "object") continue;
120
+ const name = (entry as { name?: unknown }).name;
121
+ if (typeof name === "string") pushName(names, name);
122
+ }
123
+ }
124
+
125
+ /** The component and source behind an element, when the dev fiber exposes them. */
126
+ export function readElementOrigin(element: Element): ElementOrigin | null {
127
+ const names: string[] = [];
128
+ let file: string | null = null;
129
+ let line: number | null = null;
130
+ let column: number | null = null;
131
+ let fiber = fiberOf(element);
132
+ const seen = new Set<Fiber>();
133
+ while (fiber && !seen.has(fiber) && names.length < STACK_CAP) {
134
+ seen.add(fiber);
135
+ pushName(names, componentName(fiber));
136
+ debugNames(fiber._debugInfo, names);
137
+ if (!file) {
138
+ const located = locationFromStack(fiber._debugStack);
139
+ if (located) {
140
+ file = located.file;
141
+ line = located.line;
142
+ column = located.column;
143
+ }
144
+ }
145
+ fiber = fiber.return ?? null;
146
+ }
147
+ if (names.length === 0 && !file) return null;
148
+ return { component: names[0] ?? null, file, line, column, stack: names };
149
+ }
@@ -0,0 +1,11 @@
1
+ // packages/studio/src/host-pointer-bootstrap.ts
2
+ // Built into the script the code session sandbox injects. The source of truth
3
+ // is bindHostPointer, the same pointer the document editor's preview uses.
4
+ import { bindHostPointer } from "./host-pointer";
5
+
6
+ function start() {
7
+ bindHostPointer(document);
8
+ }
9
+
10
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
11
+ else start();
@@ -0,0 +1,147 @@
1
+ // packages/studio/src/host-pointer.ts
2
+ //
3
+ // Page side of the preview channel for a framed host that is not running the
4
+ // preview client. The document editor's preview page uses the same pointer and
5
+ // the same messages (hello, point, grab, highlight). This does not accept a
6
+ // document push.
7
+ import {
8
+ createWindowPreviewTransport,
9
+ samePreviewRef,
10
+ type PreviewChannelRef,
11
+ type PreviewWindowLike,
12
+ } from "@backmatter/core/preview";
13
+ import { installPreviewPointer } from "./install-preview-pointer";
14
+ import { findPointedElement, highlightPointedElement } from "./site-pointer";
15
+
16
+ const POINTER_STYLE = "html.backmatter-pointing, html.backmatter-pointing * { cursor: crosshair !important; }";
17
+
18
+ function parentOrigin(view: Window): string {
19
+ try {
20
+ if (view.document.referrer) return new URL(view.document.referrer).origin;
21
+ } catch {
22
+ // A malformed referrer means we cannot name the framing Studio.
23
+ }
24
+ const ancestors = (view.location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;
25
+ if (ancestors && ancestors.length > 0) return ancestors.item(0) ?? "";
26
+ return "";
27
+ }
28
+
29
+ /** The document the page announces. A missing marker falls back to the route. */
30
+ export function hostPointerRef(doc: Document): PreviewChannelRef {
31
+ const marker = doc.querySelector("[data-backmatter-doc]");
32
+ const type = marker?.getAttribute("data-type");
33
+ const slug = marker?.getAttribute("data-slug");
34
+ if (type && slug) return { type, slug };
35
+ const path = doc.defaultView?.location.pathname || "/";
36
+ return { type: "route", slug: path };
37
+ }
38
+
39
+ /**
40
+ * Tell the framing Studio this page can be pointed at, and arm when it asks.
41
+ * No-ops on a top-level page and on a page that already runs the preview client.
42
+ */
43
+ export function bindHostPointer(doc: Document): () => void {
44
+ const view = doc.defaultView;
45
+ if (!view || view.parent === view) return () => {};
46
+ if (doc.querySelector("[data-backmatter-preview]")) return () => {};
47
+ const flagged = view as Window & { __backmatterHostPointer?: boolean };
48
+ if (flagged.__backmatterHostPointer) return () => {};
49
+
50
+ const origin = parentOrigin(view);
51
+ if (!origin) return () => {};
52
+ flagged.__backmatterHostPointer = true;
53
+
54
+ const ref = hostPointerRef(doc);
55
+ if (!doc.querySelector("[data-backmatter-pointer-style]")) {
56
+ const style = doc.createElement("style");
57
+ style.setAttribute("data-backmatter-pointer-style", "");
58
+ style.textContent = POINTER_STYLE;
59
+ doc.head.appendChild(style);
60
+ }
61
+
62
+ const transport = createWindowPreviewTransport({
63
+ target: view.parent,
64
+ targetOrigin: origin,
65
+ allowedOrigins: [origin],
66
+ host: view as unknown as PreviewWindowLike,
67
+ });
68
+ function sendGrab(grab: {
69
+ blockId: string | null;
70
+ blockType: string | null;
71
+ blockIndex: number | null;
72
+ quote: string;
73
+ shift: boolean;
74
+ component?: string | null;
75
+ file?: string | null;
76
+ line?: number | null;
77
+ column?: number | null;
78
+ stack?: string[];
79
+ }) {
80
+ transport.send({
81
+ type: "backmatter:grab",
82
+ v: 1,
83
+ ref,
84
+ blockId: grab.blockId,
85
+ blockType: grab.blockType,
86
+ blockIndex: grab.blockIndex,
87
+ quote: grab.quote,
88
+ shift: grab.shift,
89
+ ...(grab.component ? { component: grab.component } : {}),
90
+ ...(grab.file ? { file: grab.file } : {}),
91
+ ...(grab.line ? { line: grab.line } : {}),
92
+ ...(grab.column ? { column: grab.column } : {}),
93
+ ...(grab.stack && grab.stack.length > 0 ? { stack: grab.stack } : {}),
94
+ });
95
+ }
96
+ const pointer = installPreviewPointer(doc, {
97
+ bar: () => doc.querySelector("[data-backmatter-devbar]"),
98
+ includeUnmarked: true,
99
+ ignore: (target) => {
100
+ if (!(target instanceof view.Element)) return true;
101
+ if (target.closest("[data-backmatter-panel]")) return true;
102
+ return target.closest("iframe") !== null;
103
+ },
104
+ onEditorGrab: sendGrab,
105
+ onUnmarkedGrab: sendGrab,
106
+ onDevbarGrab: () => {},
107
+ onEscape: (source) => {
108
+ if (source !== "editor") return;
109
+ transport.send({ type: "backmatter:point", v: 1, ref, on: false });
110
+ },
111
+ });
112
+ const unsubscribe = transport.subscribe((message) => {
113
+ if (message.type === "backmatter:hotkey" || message.type === "backmatter:hello") return;
114
+ if (!("ref" in message)) return;
115
+ // Studio binds after the first hello on a reload. A message for another
116
+ // document is the cue to announce this page again.
117
+ if (!samePreviewRef(message.ref, ref)) {
118
+ if (message.type === "backmatter:point" || message.type === "backmatter:highlight") hello();
119
+ return;
120
+ }
121
+ if (message.type === "backmatter:point") {
122
+ if (message.on) pointer.arm("editor");
123
+ else if (pointer.source() === "editor") pointer.disarm();
124
+ return;
125
+ }
126
+ if (message.type === "backmatter:highlight") {
127
+ const target = message.blockId
128
+ ? findPointedElement(doc, { blockId: message.blockId, quote: "" })
129
+ : null;
130
+ highlightPointedElement(doc, target);
131
+ }
132
+ });
133
+
134
+ function hello() {
135
+ transport.send({ type: "backmatter:hello", v: 1, ref, capabilities: ["point"] });
136
+ }
137
+ hello();
138
+ const retries = [100, 500].map((ms) => view.setTimeout(hello, ms));
139
+
140
+ return () => {
141
+ for (const id of retries) view.clearTimeout(id);
142
+ unsubscribe();
143
+ pointer.dispose();
144
+ transport.close();
145
+ flagged.__backmatterHostPointer = false;
146
+ };
147
+ }
@@ -8,12 +8,25 @@ import {
8
8
  type PointerGrab,
9
9
  } from "./site-pointer";
10
10
 
11
+ function placeOf(grab: PointerGrab) {
12
+ return {
13
+ ...(grab.component ? { component: grab.component } : {}),
14
+ ...(grab.file ? { file: grab.file } : {}),
15
+ ...(grab.line ? { line: grab.line } : {}),
16
+ ...(grab.column ? { column: grab.column } : {}),
17
+ ...(grab.stack.length > 0 ? { stack: grab.stack } : {}),
18
+ };
19
+ }
20
+
11
21
  export function installPreviewPointer(
12
22
  doc: Document,
13
23
  options: {
14
24
  bar: () => Element | null;
15
25
  ignore?: (target: EventTarget | null) => boolean;
16
- onEditorGrab: (grab: RoutedGrab) => void;
26
+ /** Armed clicks that are not page blocks. The document editor leaves this off. */
27
+ includeUnmarked?: boolean;
28
+ onEditorGrab: (grab: RoutedGrab & ReturnType<typeof placeOf>) => void;
29
+ onUnmarkedGrab?: (grab: PointerGrab & { shift: boolean }) => void;
17
30
  onDevbarGrab: (grab: PointerGrab) => void;
18
31
  onEscape?: (source: PointerArmSource) => void;
19
32
  },
@@ -27,8 +40,11 @@ export function installPreviewPointer(
27
40
 
28
41
  function deliver(grab: PointerGrab, shift: boolean) {
29
42
  const route = routePointerHit(source, { ...grab, shift });
30
- if (route.to === "editor") options.onEditorGrab(route.grab);
31
- else if (route.to === "devbar") options.onDevbarGrab(route.grab);
43
+ if (route.to === "editor") options.onEditorGrab({ ...route.grab, ...placeOf(grab) });
44
+ else if (route.to === "devbar") options.onDevbarGrab(grab);
45
+ else if (options.includeUnmarked && source === "editor" && (grab.quote || grab.component || grab.file)) {
46
+ options.onUnmarkedGrab?.({ ...grab, shift });
47
+ }
32
48
  }
33
49
 
34
50
  const pointer = installSitePointer(doc, {
@@ -0,0 +1,221 @@
1
+ // packages/studio/src/pages/CodeSessionFiles.tsx
2
+ import { useMemo } from "react";
3
+ import * as stylex from "@stylexjs/stylex";
4
+ import { colors, space, type } from "../styles/tokens.stylex";
5
+ import { Text } from "../ui/Text";
6
+ import { fileTree, type FileNode, type WorkspaceFile } from "./code-session-files";
7
+
8
+ export type FilesView = {
9
+ /** Checkout paths, or null while they load. */
10
+ list: string[] | null;
11
+ error: string | null;
12
+ selected: string | null;
13
+ opened: WorkspaceFile | "loading" | null;
14
+ onSelect: (path: string) => void;
15
+ };
16
+
17
+ export function CodeSessionFiles({ list, error, selected, opened, onSelect }: FilesView) {
18
+ const tree = useMemo(() => fileTree(list ?? []), [list]);
19
+
20
+ return (
21
+ <div {...stylex.props(styles.shell)} aria-label="Session files">
22
+ <nav {...stylex.props(styles.tree)} aria-label="Checkout">
23
+ {error ? (
24
+ <p {...stylex.props(styles.note, styles.error)}>{error}</p>
25
+ ) : list === null ? (
26
+ <p {...stylex.props(styles.note)}>Reading the checkout…</p>
27
+ ) : list.length === 0 ? (
28
+ <p {...stylex.props(styles.note)}>The checkout is empty.</p>
29
+ ) : (
30
+ <TreeLevel nodes={tree} depth={0} selected={selected} onSelect={onSelect} />
31
+ )}
32
+ </nav>
33
+ <section {...stylex.props(styles.viewer)} aria-label="File">
34
+ {selected ? (
35
+ <>
36
+ <header {...stylex.props(styles.viewerHeader)}>
37
+ <span {...stylex.props(styles.path)}>{selected}</span>
38
+ </header>
39
+ <FileBody opened={opened} />
40
+ </>
41
+ ) : (
42
+ <div {...stylex.props(styles.empty)}>
43
+ <Text size="sm" tone="muted">
44
+ Pick a file to read it as the host has it now.
45
+ </Text>
46
+ </div>
47
+ )}
48
+ </section>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ function TreeLevel({
54
+ nodes,
55
+ depth,
56
+ selected,
57
+ onSelect,
58
+ }: {
59
+ nodes: FileNode[];
60
+ depth: number;
61
+ selected: string | null;
62
+ onSelect: (path: string) => void;
63
+ }) {
64
+ const indent = { paddingLeft: `${8 + depth * 12}px` };
65
+ return (
66
+ <ul {...stylex.props(styles.level)}>
67
+ {nodes.map((node) =>
68
+ node.kind === "dir" ? (
69
+ <li key={node.path}>
70
+ <details open={selected?.startsWith(`${node.path}/`) ?? false}>
71
+ <summary {...stylex.props(styles.row, styles.dir)} style={indent}>
72
+ {node.name}
73
+ </summary>
74
+ <TreeLevel
75
+ nodes={node.children}
76
+ depth={depth + 1}
77
+ selected={selected}
78
+ onSelect={onSelect}
79
+ />
80
+ </details>
81
+ </li>
82
+ ) : (
83
+ <li key={node.path}>
84
+ <button
85
+ type="button"
86
+ aria-current={selected === node.path ? "true" : undefined}
87
+ onClick={() => onSelect(node.path)}
88
+ {...stylex.props(styles.row, styles.file, selected === node.path && styles.fileActive)}
89
+ style={indent}
90
+ >
91
+ {node.name}
92
+ </button>
93
+ </li>
94
+ ),
95
+ )}
96
+ </ul>
97
+ );
98
+ }
99
+
100
+ function FileBody({ opened }: { opened: WorkspaceFile | "loading" | null }) {
101
+ if (opened === "loading" || opened === null) {
102
+ return <p {...stylex.props(styles.note)}>{opened === "loading" ? "Opening…" : ""}</p>;
103
+ }
104
+ if ("binary" in opened) {
105
+ return <p {...stylex.props(styles.note)}>Binary file, {formatSize(opened.size)}.</p>;
106
+ }
107
+ if ("tooLarge" in opened) {
108
+ return <p {...stylex.props(styles.note)}>Too large to show here ({formatSize(opened.size)}).</p>;
109
+ }
110
+ return <pre {...stylex.props(styles.code)}>{opened.content}</pre>;
111
+ }
112
+
113
+ function formatSize(bytes: number): string {
114
+ if (bytes < 1024) return `${bytes} B`;
115
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
116
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
117
+ }
118
+
119
+ const hover = "@media (hover: hover)";
120
+
121
+ const styles = stylex.create({
122
+ shell: {
123
+ display: "flex",
124
+ flex: 1,
125
+ minWidth: 0,
126
+ minHeight: 0,
127
+ },
128
+ tree: {
129
+ width: "260px",
130
+ flexShrink: 0,
131
+ overflow: "auto",
132
+ paddingBlock: space.sm,
133
+ borderRightWidth: 1,
134
+ borderRightStyle: "solid",
135
+ borderRightColor: colors.border,
136
+ backgroundColor: colors.bgSurface,
137
+ },
138
+ level: {
139
+ margin: 0,
140
+ padding: 0,
141
+ listStyle: "none",
142
+ },
143
+ row: {
144
+ display: "block",
145
+ width: "100%",
146
+ boxSizing: "border-box",
147
+ paddingBlock: "3px",
148
+ paddingRight: space.sm,
149
+ font: "inherit",
150
+ fontSize: type.xs,
151
+ lineHeight: 1.5,
152
+ textAlign: "left",
153
+ whiteSpace: "nowrap",
154
+ overflow: "hidden",
155
+ textOverflow: "ellipsis",
156
+ cursor: "pointer",
157
+ borderWidth: 0,
158
+ color: colors.text,
159
+ backgroundColor: {
160
+ default: "transparent",
161
+ [hover]: { default: null, ":hover": colors.bgHover },
162
+ },
163
+ },
164
+ dir: {
165
+ fontWeight: 500,
166
+ },
167
+ file: {
168
+ color: colors.textMuted,
169
+ },
170
+ fileActive: {
171
+ color: colors.text,
172
+ backgroundColor: colors.bgActive,
173
+ },
174
+ viewer: {
175
+ display: "flex",
176
+ flex: 1,
177
+ flexDirection: "column",
178
+ minWidth: 0,
179
+ minHeight: 0,
180
+ backgroundColor: colors.bgApp,
181
+ },
182
+ viewerHeader: {
183
+ paddingBlock: space.sm,
184
+ paddingInline: space.lg,
185
+ borderBottomWidth: 1,
186
+ borderBottomStyle: "solid",
187
+ borderBottomColor: colors.border,
188
+ },
189
+ path: {
190
+ fontFamily: type.mono,
191
+ fontSize: type.xs,
192
+ color: colors.textMuted,
193
+ },
194
+ code: {
195
+ flex: 1,
196
+ margin: 0,
197
+ overflow: "auto",
198
+ padding: space.lg,
199
+ fontFamily: type.mono,
200
+ fontSize: "12px",
201
+ lineHeight: 1.6,
202
+ tabSize: 2,
203
+ color: colors.text,
204
+ },
205
+ empty: {
206
+ display: "flex",
207
+ flex: 1,
208
+ alignItems: "center",
209
+ justifyContent: "center",
210
+ },
211
+ note: {
212
+ margin: 0,
213
+ paddingBlock: space.sm,
214
+ paddingInline: space.lg,
215
+ fontSize: type.xs,
216
+ color: colors.textMuted,
217
+ },
218
+ error: {
219
+ color: colors.danger,
220
+ },
221
+ });