@backmatter/studio 0.0.14 → 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.
- package/dist/chunks/{UiKitApp-fYeVEf4-.js → UiKitApp-_EDqWID3.js} +2 -2
- package/dist/chunks/{WorkspaceRoutes-Dt7JvEWT.js → WorkspaceRoutes-B2PrFgpz.js} +29019 -27476
- package/dist/chunks/{dashboard-BuY2aGkO.js → dashboard-BnlnMnnr.js} +55 -54
- package/dist/chunks/{index-NWDqEZYW.js → index-D_BHF_8q.js} +1 -1
- package/dist/dashboard.js +2 -2
- package/dist/index.js +51 -52
- package/dist/site-widget.css +4 -0
- package/dist/site-widget.js +9419 -9156
- package/dist/studio.css +186 -12
- package/package.json +2 -2
- package/src/WorkspaceRoutes.tsx +5 -2
- package/src/api.ts +64 -11
- package/src/chat/ChangeCard.tsx +9 -15
- package/src/chat/ChatMessage.tsx +172 -103
- package/src/chat/ChatPanel.tsx +54 -34
- package/src/chat/ChatProvider.tsx +120 -24
- package/src/chat/Composer.tsx +48 -7
- package/src/chat/DocListCard.tsx +2 -16
- package/src/chat/MentionPopover.tsx +8 -9
- package/src/chat/PointerChip.tsx +9 -5
- package/src/chat/ThreadHistory.tsx +3 -3
- package/src/chat/chat-context.ts +74 -10
- package/src/chat/chat-helpers.ts +15 -8
- package/src/chat/chat-state.ts +11 -0
- package/src/chrome/AppShell.tsx +16 -5
- package/src/chrome/CommandPalette.tsx +14 -10
- package/src/chrome/ContentExplorerSidebar.tsx +23 -3
- package/src/chrome/UserMenu.tsx +34 -3
- package/src/chrome/WorkspaceStatusBar.tsx +215 -50
- package/src/chrome/content-api-url.ts +0 -78
- package/src/chrome/content-tree-visibility.ts +13 -3
- package/src/chrome/explorer-icons.tsx +5 -0
- package/src/code-session-entry.ts +9 -0
- package/src/components/WorkspaceSwitcher.tsx +2 -2
- package/src/element-origin.ts +149 -0
- package/src/host-pointer-bootstrap.ts +11 -0
- package/src/host-pointer.ts +147 -0
- package/src/install-preview-pointer.ts +19 -3
- package/src/pages/CodeSessionFiles.tsx +221 -0
- package/src/pages/CodeSessionPage.tsx +1188 -0
- package/src/pages/DocEditorPage.tsx +33 -15
- package/src/pages/EmbedChatPage.tsx +3 -3
- package/src/pages/EmbedPublishPage.tsx +2 -3
- package/src/pages/PreviewSize.tsx +181 -0
- package/src/pages/SettingsGeneralPage.tsx +67 -2
- package/src/pages/WorkspaceHomePage.tsx +313 -0
- package/src/pages/WorkspaceLayout.tsx +10 -1
- package/src/pages/code-session-boot.ts +43 -0
- package/src/pages/code-session-context.tsx +51 -0
- package/src/pages/code-session-files.ts +42 -0
- package/src/pages/code-session-frame.ts +52 -0
- package/src/pages/code-session-routes.ts +42 -0
- package/src/pages/home-rows.ts +140 -0
- package/src/pages/preview-size.ts +33 -0
- package/src/pages/session-preview.ts +109 -0
- package/src/pages/type-docs.ts +3 -6
- package/src/pages/use-session-preview.ts +70 -0
- package/src/replica/store.ts +13 -10
- package/src/replica/types.ts +7 -2
- package/src/site-pointer.ts +24 -5
- package/src/site-widget.tsx +2 -3
- package/src/studio-config.ts +1 -1
- package/src/ui/ListRow.tsx +12 -3
- package/src/ui/Page.tsx +12 -1
- package/src/workspace-path.ts +35 -3
- package/src/chrome/ContentApiPopover.tsx +0 -215
- package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
|
@@ -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
|
-
|
|
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(
|
|
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
|
+
});
|