@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.
- package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-_EDqWID3.js} +2 -2
- package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-B2PrFgpz.js} +28979 -27418
- package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-BnlnMnnr.js} +55 -54
- package/dist/chunks/{index-DHJvh5Uy.js → index-D_BHF_8q.js} +1 -1
- package/dist/dashboard.js +2 -2
- package/dist/index.js +2 -2
- package/dist/site-widget.css +4 -0
- package/dist/site-widget.js +8304 -8029
- package/dist/studio.css +186 -12
- package/package.json +2 -2
- package/src/WorkspaceRoutes.tsx +5 -2
- package/src/api.ts +63 -7
- package/src/chat/ChangeCard.tsx +8 -2
- package/src/chat/ChatMessage.tsx +171 -102
- package/src/chat/ChatPanel.tsx +54 -34
- package/src/chat/ChatProvider.tsx +116 -20
- package/src/chat/Composer.tsx +48 -7
- package/src/chat/PointerChip.tsx +9 -5
- package/src/chat/ThreadHistory.tsx +3 -3
- package/src/chat/chat-context.ts +67 -3
- package/src/chat/chat-helpers.ts +12 -0
- package/src/chat/chat-state.ts +11 -0
- package/src/chrome/AppShell.tsx +16 -5
- package/src/chrome/CommandPalette.tsx +12 -0
- package/src/chrome/ContentExplorerSidebar.tsx +23 -3
- package/src/chrome/UserMenu.tsx +34 -3
- package/src/chrome/WorkspaceStatusBar.tsx +209 -27
- 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/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/use-session-preview.ts +70 -0
- package/src/replica/store.ts +11 -0
- package/src/replica/types.ts +6 -0
- package/src/site-pointer.ts +24 -5
- 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
|
@@ -12,7 +12,10 @@ import { PageFrame } from "../ui/Page";
|
|
|
12
12
|
import { Text } from "../ui/Text";
|
|
13
13
|
import type { WorkspaceOutletContext } from "../workspace/types";
|
|
14
14
|
import {
|
|
15
|
+
isWorkspaceContentIndexPath,
|
|
15
16
|
isWorkspaceEditorPath,
|
|
17
|
+
isWorkspaceHomePath,
|
|
18
|
+
isWorkspaceCodePath,
|
|
16
19
|
isWorkspaceMediaPath,
|
|
17
20
|
isWorkspaceSettingsPath,
|
|
18
21
|
isWorkspaceTypeIndexPath,
|
|
@@ -26,9 +29,12 @@ export function WorkspaceLayout() {
|
|
|
26
29
|
const { workspaceSlug = "" } = useParams();
|
|
27
30
|
const location = useLocation();
|
|
28
31
|
const isEditor = isWorkspaceEditorPath(location.pathname);
|
|
32
|
+
const isContentIndex = isWorkspaceContentIndexPath(location.pathname);
|
|
29
33
|
const isTypeIndex = isWorkspaceTypeIndexPath(location.pathname);
|
|
30
34
|
const isSettings = isWorkspaceSettingsPath(location.pathname);
|
|
31
35
|
const isMedia = isWorkspaceMediaPath(location.pathname);
|
|
36
|
+
const isCode = isWorkspaceCodePath(location.pathname);
|
|
37
|
+
const isHome = isWorkspaceHomePath(location.pathname);
|
|
32
38
|
const { userId } = useAppChrome();
|
|
33
39
|
const setNav = useSetWorkspaceNav();
|
|
34
40
|
const replica = useReplicaState();
|
|
@@ -96,7 +102,10 @@ export function WorkspaceLayout() {
|
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
return (
|
|
99
|
-
<PageFrame
|
|
105
|
+
<PageFrame
|
|
106
|
+
wide={isHome}
|
|
107
|
+
flush={isEditor || isContentIndex || isTypeIndex || isSettings || isMedia || isCode}
|
|
108
|
+
>
|
|
100
109
|
<Outlet context={outletContext} />
|
|
101
110
|
</PageFrame>
|
|
102
111
|
);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// packages/studio/src/pages/code-session-boot.ts
|
|
2
|
+
export type HostPhase = "stopped" | "installing" | "starting" | "ready" | "failed";
|
|
3
|
+
|
|
4
|
+
export type BootStepState = "pending" | "active" | "done" | "failed";
|
|
5
|
+
|
|
6
|
+
export type BootStep = { label: string; state: BootStepState };
|
|
7
|
+
|
|
8
|
+
const LABELS = ["Start the container", "Install dependencies", "Start the dev server"] as const;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The three steps a developer watches before the host shows. `furthest` is the
|
|
12
|
+
* latest phase seen, so a failure lands on the step that was running.
|
|
13
|
+
*/
|
|
14
|
+
export function bootSteps(input: {
|
|
15
|
+
opening: boolean;
|
|
16
|
+
phase: HostPhase | null;
|
|
17
|
+
furthest: "installing" | "starting" | null;
|
|
18
|
+
}): BootStep[] {
|
|
19
|
+
const at = (index: number, state: BootStepState): BootStep[] =>
|
|
20
|
+
LABELS.map((label, i) => ({
|
|
21
|
+
label,
|
|
22
|
+
state: i < index ? "done" : i === index ? state : "pending",
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
if (input.opening || input.phase === null) return at(0, "active");
|
|
26
|
+
if (input.phase === "installing") return at(1, "active");
|
|
27
|
+
if (input.phase === "starting") return at(2, "active");
|
|
28
|
+
if (input.phase === "ready") return LABELS.map((label) => ({ label, state: "done" }));
|
|
29
|
+
if (input.phase === "stopped") return at(0, "failed");
|
|
30
|
+
return at(input.furthest === "starting" ? 2 : input.furthest === "installing" ? 1 : 0, "failed");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Normalizes what someone types in the address bar to a path inside the host. */
|
|
34
|
+
export function hostPath(input: string): string {
|
|
35
|
+
const trimmed = input.trim();
|
|
36
|
+
if (!trimmed) return "/";
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(trimmed);
|
|
39
|
+
return `${url.pathname}${url.search}${url.hash}` || "/";
|
|
40
|
+
} catch {
|
|
41
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// packages/studio/src/pages/code-session-context.ts
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
type Dispatch,
|
|
9
|
+
type SetStateAction,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
export type CodeSessionContextValue = {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
editCount: number;
|
|
15
|
+
refresh: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const CodeSessionContext = createContext<CodeSessionContextValue | null>(null);
|
|
19
|
+
const CodeSessionPublishContext = createContext<
|
|
20
|
+
Dispatch<SetStateAction<CodeSessionContextValue | null>> | null
|
|
21
|
+
>(null);
|
|
22
|
+
|
|
23
|
+
export function CodeSessionProvider({
|
|
24
|
+
children,
|
|
25
|
+
initial = null,
|
|
26
|
+
}: {
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
initial?: CodeSessionContextValue | null;
|
|
29
|
+
}) {
|
|
30
|
+
const [published, setPublished] = useState<CodeSessionContextValue | null>(initial);
|
|
31
|
+
const value = useMemo(() => published, [published]);
|
|
32
|
+
return (
|
|
33
|
+
<CodeSessionContext.Provider value={value}>
|
|
34
|
+
<CodeSessionPublishContext.Provider value={setPublished}>
|
|
35
|
+
{children}
|
|
36
|
+
</CodeSessionPublishContext.Provider>
|
|
37
|
+
</CodeSessionContext.Provider>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useCodeSessionContext(): CodeSessionContextValue | null {
|
|
42
|
+
return useContext(CodeSessionContext);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function usePublishCodeSession(): Dispatch<SetStateAction<CodeSessionContextValue | null>> {
|
|
46
|
+
const publish = useContext(CodeSessionPublishContext);
|
|
47
|
+
if (!publish) {
|
|
48
|
+
throw new Error("usePublishCodeSession requires CodeSessionProvider");
|
|
49
|
+
}
|
|
50
|
+
return publish;
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// packages/studio/src/pages/code-session-files.ts
|
|
2
|
+
export type FileNode =
|
|
3
|
+
| { kind: "file"; name: string; path: string }
|
|
4
|
+
| { kind: "dir"; name: string; path: string; children: FileNode[] };
|
|
5
|
+
|
|
6
|
+
/** One checkout file as the session's container has it now. */
|
|
7
|
+
export type WorkspaceFile =
|
|
8
|
+
| { path: string; content: string }
|
|
9
|
+
| { path: string; binary: true; size: number }
|
|
10
|
+
| { path: string; tooLarge: true; size: number };
|
|
11
|
+
|
|
12
|
+
export function fileTree(paths: readonly string[]): FileNode[] {
|
|
13
|
+
const root: FileNode & { kind: "dir" } = { kind: "dir", name: "", path: "", children: [] };
|
|
14
|
+
for (const path of paths) {
|
|
15
|
+
const parts = path.split("/");
|
|
16
|
+
let dir = root;
|
|
17
|
+
parts.forEach((name, index) => {
|
|
18
|
+
const at = parts.slice(0, index + 1).join("/");
|
|
19
|
+
if (index === parts.length - 1) {
|
|
20
|
+
dir.children.push({ kind: "file", name, path: at });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
let next = dir.children.find(
|
|
24
|
+
(child): child is FileNode & { kind: "dir" } => child.kind === "dir" && child.name === name,
|
|
25
|
+
);
|
|
26
|
+
if (!next) {
|
|
27
|
+
next = { kind: "dir", name, path: at, children: [] };
|
|
28
|
+
dir.children.push(next);
|
|
29
|
+
}
|
|
30
|
+
dir = next;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return sortLevel(root.children);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sortLevel(nodes: FileNode[]): FileNode[] {
|
|
37
|
+
return nodes
|
|
38
|
+
.map((node) => (node.kind === "dir" ? { ...node, children: sortLevel(node.children) } : node))
|
|
39
|
+
.sort((a, b) =>
|
|
40
|
+
a.kind !== b.kind ? (a.kind === "dir" ? -1 : 1) : a.name.localeCompare(b.name),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// packages/studio/src/pages/code-session-frame.ts
|
|
2
|
+
|
|
3
|
+
/** The host's own origin, and an entry URL carrying the ticket that admits this browser to it. */
|
|
4
|
+
export type HostPreview = { origin: string; entry: string };
|
|
5
|
+
|
|
6
|
+
/** The frame's address. The first load carries the ticket. A reload is that same path. */
|
|
7
|
+
export function frameAddress(preview: HostPreview, path: string, reloads: number): string {
|
|
8
|
+
if (reloads === 0) return previewUrl(preview.entry, path);
|
|
9
|
+
return previewUrl(preview.origin, path);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function previewUrl(base: string, path: string): string {
|
|
13
|
+
const from = new URL(base);
|
|
14
|
+
const url = new URL(path, from.origin);
|
|
15
|
+
from.searchParams.forEach((value, key) => url.searchParams.set(key, value));
|
|
16
|
+
return url.toString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type PathMemory = {
|
|
20
|
+
getItem(key: string): string | null;
|
|
21
|
+
setItem(key: string, value: string): void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function pathKey(workspaceId: string): string {
|
|
25
|
+
return `backmatter:code-session-path:${workspaceId}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isRoute(path: string | null): path is string {
|
|
29
|
+
return typeof path === "string" && path.startsWith("/") && !path.startsWith("//");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The route this workspace's preview was last on, kept across a reload of Studio. */
|
|
33
|
+
export function rememberedPath(storage: PathMemory, workspaceId: string): string {
|
|
34
|
+
const raw = storage.getItem(pathKey(workspaceId));
|
|
35
|
+
return isRoute(raw) ? raw : "/";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function rememberPath(storage: PathMemory, workspaceId: string, path: string): void {
|
|
39
|
+
if (!isRoute(path)) return;
|
|
40
|
+
storage.setItem(pathKey(workspaceId), path);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The route a framed host page reports through `postMessage`, if `message` is that report. */
|
|
44
|
+
export function framedLocation(
|
|
45
|
+
message: { origin: string; data: unknown },
|
|
46
|
+
hostOrigin: string,
|
|
47
|
+
): string | null {
|
|
48
|
+
if (message.origin !== new URL(hostOrigin).origin) return null;
|
|
49
|
+
const data = message.data as { type?: unknown; path?: unknown } | null;
|
|
50
|
+
if (data?.type !== "backmatter:preview-location" || typeof data.path !== "string") return null;
|
|
51
|
+
return data.path.startsWith("/") && !data.path.startsWith("//") ? data.path : null;
|
|
52
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// packages/studio/src/pages/code-session-routes.ts
|
|
2
|
+
import {
|
|
3
|
+
isRoutable,
|
|
4
|
+
isTemplateSlug,
|
|
5
|
+
matchModelPath,
|
|
6
|
+
routeFor,
|
|
7
|
+
type RouteModel,
|
|
8
|
+
} from "@backmatter/core";
|
|
9
|
+
|
|
10
|
+
export type ContentRouteModel = RouteModel & {
|
|
11
|
+
path: string;
|
|
12
|
+
external?: boolean;
|
|
13
|
+
template?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Public routes for the content files in a code session checkout. */
|
|
17
|
+
export function contentRoutes(
|
|
18
|
+
models: readonly ContentRouteModel[],
|
|
19
|
+
files: readonly string[],
|
|
20
|
+
): string[] {
|
|
21
|
+
const routes = new Set<string>();
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
const repoPath = file.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
24
|
+
for (const model of models) {
|
|
25
|
+
const hit = matchModelPath(model, repoPath);
|
|
26
|
+
if (!hit) continue;
|
|
27
|
+
if (isRoutable(model) && !isTemplateSlug(model, hit.slug)) {
|
|
28
|
+
const route = routeFor(model, hit.slug);
|
|
29
|
+
if (route) routes.add(route);
|
|
30
|
+
}
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return [...routes].sort((a, b) => (a === "/" ? -1 : b === "/" ? 1 : a.localeCompare(b)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Routes whose path contains the address query. A blank query keeps the full list. */
|
|
38
|
+
export function matchingRoutes(routes: readonly string[], query: string): string[] {
|
|
39
|
+
const needle = query.trim().toLowerCase();
|
|
40
|
+
if (!needle) return [...routes];
|
|
41
|
+
return routes.filter((route) => route.toLowerCase().includes(needle));
|
|
42
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// packages/studio/src/pages/home-rows.ts
|
|
2
|
+
import type { ChangeStatus, RecentCommit } from "../api";
|
|
3
|
+
import { routeFor } from "@backmatter/core";
|
|
4
|
+
import { docTreeLabel } from "../chrome/content-repo-path";
|
|
5
|
+
import type { ContentTypeNav } from "../chrome/content-type-groups";
|
|
6
|
+
import { contentDocPath, settingsPath } from "../workspace-path";
|
|
7
|
+
|
|
8
|
+
const STATUS_WORD: Record<ChangeStatus, string> = {
|
|
9
|
+
modified: "modified",
|
|
10
|
+
added: "new",
|
|
11
|
+
conflict: "conflict",
|
|
12
|
+
deleted: "deleted",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type HomeChange = {
|
|
16
|
+
type: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
status: ChangeStatus;
|
|
19
|
+
updatedAt?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type HomeModel = Pick<ContentTypeNav, "type" | "path"> & {
|
|
23
|
+
route?: string | false;
|
|
24
|
+
indexSlug?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type HomeRow = {
|
|
28
|
+
key: string;
|
|
29
|
+
label: string;
|
|
30
|
+
/** Public path when the model is routable. Null for data with no route. */
|
|
31
|
+
route: string | null;
|
|
32
|
+
status: string;
|
|
33
|
+
href: string | null;
|
|
34
|
+
updatedAt: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Public path for a changed document. Data models with `route: false` have none. */
|
|
38
|
+
export function changedRoute(
|
|
39
|
+
change: Pick<HomeChange, "type" | "slug">,
|
|
40
|
+
model: Pick<HomeModel, "type" | "route" | "indexSlug"> | undefined,
|
|
41
|
+
): string | null {
|
|
42
|
+
if (model?.route === false) return null;
|
|
43
|
+
const path = routeFor(
|
|
44
|
+
{ type: change.type, route: model?.route, indexSlug: model?.indexSlug },
|
|
45
|
+
change.slug,
|
|
46
|
+
);
|
|
47
|
+
return path || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Working-tree rows for the workspace home, newest edit first. */
|
|
51
|
+
export function homeRows(
|
|
52
|
+
changes: HomeChange[],
|
|
53
|
+
models: HomeModel[],
|
|
54
|
+
workspaceSlug: string,
|
|
55
|
+
): HomeRow[] {
|
|
56
|
+
const rows = changes.map((change) => {
|
|
57
|
+
const model = models.find((item) => item.type === change.type);
|
|
58
|
+
const label = model ? docTreeLabel(model, change) : change.slug;
|
|
59
|
+
return {
|
|
60
|
+
key: `${change.type}/${change.slug}`,
|
|
61
|
+
label,
|
|
62
|
+
route: changedRoute(change, model),
|
|
63
|
+
status: STATUS_WORD[change.status],
|
|
64
|
+
href:
|
|
65
|
+
change.status === "deleted"
|
|
66
|
+
? null
|
|
67
|
+
: contentDocPath(workspaceSlug, change.type, change.slug),
|
|
68
|
+
updatedAt: change.updatedAt ?? "",
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
rows.sort((a, b) => {
|
|
72
|
+
if (a.updatedAt !== b.updatedAt) return a.updatedAt < b.updatedAt ? 1 : -1;
|
|
73
|
+
return a.label.localeCompare(b.label);
|
|
74
|
+
});
|
|
75
|
+
return rows;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const RECENT_LIMIT = 4;
|
|
79
|
+
|
|
80
|
+
export type HomeSummary = {
|
|
81
|
+
name: string;
|
|
82
|
+
branch: string;
|
|
83
|
+
defaultBranch: string;
|
|
84
|
+
uncommitted: number;
|
|
85
|
+
conflicts: number;
|
|
86
|
+
recent: HomeRow[];
|
|
87
|
+
modelCount: number;
|
|
88
|
+
repository: string | null;
|
|
89
|
+
siteUrl: string | null;
|
|
90
|
+
schemaHref: string;
|
|
91
|
+
githubHref: string;
|
|
92
|
+
generalHref: string;
|
|
93
|
+
/** Null until git status has loaded. Empty means the branch has no commits. */
|
|
94
|
+
commits: RecentCommit[] | null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** How long ago a commit landed, stable enough to test. */
|
|
98
|
+
export function commitAge(iso: string, now: number): string {
|
|
99
|
+
const then = Date.parse(iso);
|
|
100
|
+
if (!Number.isFinite(then)) return "";
|
|
101
|
+
const minutes = Math.max(0, Math.round((now - then) / 60_000));
|
|
102
|
+
if (minutes < 1) return "just now";
|
|
103
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
104
|
+
const hours = Math.round(minutes / 60);
|
|
105
|
+
if (hours < 24) return `${hours}h ago`;
|
|
106
|
+
const days = Math.round(hours / 24);
|
|
107
|
+
if (days < 14) return `${days}d ago`;
|
|
108
|
+
return iso.slice(0, 10);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The few facts the workspace home can show without inventing activity. */
|
|
112
|
+
export function homeSummary(input: {
|
|
113
|
+
name: string;
|
|
114
|
+
slug: string;
|
|
115
|
+
branch: string;
|
|
116
|
+
defaultBranch: string;
|
|
117
|
+
repository: string | null;
|
|
118
|
+
siteUrl: string | null;
|
|
119
|
+
modelCount: number;
|
|
120
|
+
changes: HomeChange[];
|
|
121
|
+
models: HomeModel[];
|
|
122
|
+
commits?: RecentCommit[] | null;
|
|
123
|
+
}): HomeSummary {
|
|
124
|
+
const rows = homeRows(input.changes, input.models, input.slug);
|
|
125
|
+
return {
|
|
126
|
+
name: input.name,
|
|
127
|
+
branch: input.branch,
|
|
128
|
+
defaultBranch: input.defaultBranch,
|
|
129
|
+
uncommitted: rows.length,
|
|
130
|
+
conflicts: input.changes.filter((change) => change.status === "conflict").length,
|
|
131
|
+
recent: rows.slice(0, RECENT_LIMIT),
|
|
132
|
+
modelCount: input.modelCount,
|
|
133
|
+
repository: input.repository,
|
|
134
|
+
siteUrl: input.siteUrl,
|
|
135
|
+
schemaHref: settingsPath(input.slug, "schema"),
|
|
136
|
+
githubHref: settingsPath(input.slug, "github"),
|
|
137
|
+
generalHref: settingsPath(input.slug, "general"),
|
|
138
|
+
commits: input.commits ?? null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// packages/studio/src/pages/preview-size.ts
|
|
2
|
+
export type PreviewSize = "desktop" | "tablet" | "phone";
|
|
3
|
+
|
|
4
|
+
export const PREVIEW_SIZE_KEY = "backmatter-preview-size";
|
|
5
|
+
|
|
6
|
+
export type PreviewSizeStore = {
|
|
7
|
+
getItem(key: string): string | null;
|
|
8
|
+
setItem(key: string, value: string): void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function previewFrameWidth(size: PreviewSize): "390px" | "768px" | null {
|
|
12
|
+
if (size === "phone") return "390px";
|
|
13
|
+
if (size === "tablet") return "768px";
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readPreviewSize(store?: PreviewSizeStore): PreviewSize {
|
|
18
|
+
try {
|
|
19
|
+
const raw = (store ?? localStorage).getItem(PREVIEW_SIZE_KEY);
|
|
20
|
+
if (raw === "desktop" || raw === "tablet" || raw === "phone") return raw;
|
|
21
|
+
} catch {
|
|
22
|
+
// Private mode, or no browser storage.
|
|
23
|
+
}
|
|
24
|
+
return "desktop";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function persistPreviewSize(size: PreviewSize, store?: PreviewSizeStore) {
|
|
28
|
+
try {
|
|
29
|
+
(store ?? localStorage).setItem(PREVIEW_SIZE_KEY, size);
|
|
30
|
+
} catch {
|
|
31
|
+
// Ignore quota and private mode.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// packages/studio/src/pages/session-preview.ts
|
|
2
|
+
// Studio side of pointing at the running host. Same preview channel as the
|
|
3
|
+
// document editor. The session never pushes a document into the page.
|
|
4
|
+
import { matchRoute, type RouteModel } from "@backmatter/core";
|
|
5
|
+
import {
|
|
6
|
+
createPreviewEditor,
|
|
7
|
+
type PreviewChannelRef,
|
|
8
|
+
type PreviewEditorSession,
|
|
9
|
+
type PreviewTransport,
|
|
10
|
+
} from "@backmatter/core/preview";
|
|
11
|
+
import type { ChatPointer } from "../chat/chat-context";
|
|
12
|
+
|
|
13
|
+
/** The document a hello or grab refers to. A route ref is resolved against the models. */
|
|
14
|
+
export function sessionDocument(
|
|
15
|
+
ref: PreviewChannelRef,
|
|
16
|
+
models: readonly RouteModel[],
|
|
17
|
+
): { type: string; slug: string } | null {
|
|
18
|
+
if (ref.type === "route") {
|
|
19
|
+
const path = ref.slug.split(/[?#]/, 1)[0] || "/";
|
|
20
|
+
const hit = matchRoute(models, path);
|
|
21
|
+
return hit ? { type: hit.type, slug: hit.slug } : null;
|
|
22
|
+
}
|
|
23
|
+
if (!ref.type || !ref.slug) return null;
|
|
24
|
+
return { type: ref.type, slug: ref.slug };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SessionPreviewBinding = {
|
|
28
|
+
arm(on: boolean): void;
|
|
29
|
+
highlight(blockId: string | null): void;
|
|
30
|
+
/** Ask a page that already loaded to send hello again. */
|
|
31
|
+
nudge(): void;
|
|
32
|
+
close(): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function bindSessionPreview(options: {
|
|
36
|
+
transport: PreviewTransport;
|
|
37
|
+
onCanPoint(on: boolean): void;
|
|
38
|
+
onPointer(pointer: ChatPointer): void;
|
|
39
|
+
onDisarm(): void;
|
|
40
|
+
/** The document a hello or grab refers to. A route ref is resolved by the caller. */
|
|
41
|
+
documentAt(ref: PreviewChannelRef): { type: string; slug: string } | null;
|
|
42
|
+
}): SessionPreviewBinding {
|
|
43
|
+
let ref: PreviewChannelRef = { type: "_", slug: "_" };
|
|
44
|
+
let known = false;
|
|
45
|
+
let wantPoint = false;
|
|
46
|
+
let editor: PreviewEditorSession | null = null;
|
|
47
|
+
|
|
48
|
+
function remember(next: PreviewChannelRef) {
|
|
49
|
+
ref = {
|
|
50
|
+
type: next.type,
|
|
51
|
+
slug: next.slug,
|
|
52
|
+
...(next.branch ? { branch: next.branch } : {}),
|
|
53
|
+
...(next.tree ? { tree: next.tree } : {}),
|
|
54
|
+
};
|
|
55
|
+
known = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
editor = createPreviewEditor({
|
|
59
|
+
transport: options.transport,
|
|
60
|
+
ref: () => ref,
|
|
61
|
+
onHello(next, capabilities) {
|
|
62
|
+
remember(next);
|
|
63
|
+
options.onCanPoint(capabilities?.includes("point") ?? false);
|
|
64
|
+
editor?.point(wantPoint);
|
|
65
|
+
},
|
|
66
|
+
onGrab(grab) {
|
|
67
|
+
const doc = options.documentAt(grab.ref);
|
|
68
|
+
if (!doc) return;
|
|
69
|
+
options.onPointer({
|
|
70
|
+
type: doc.type,
|
|
71
|
+
slug: doc.slug,
|
|
72
|
+
blockId: grab.blockId,
|
|
73
|
+
blockType: grab.blockType,
|
|
74
|
+
blockIndex: grab.blockIndex,
|
|
75
|
+
quote: grab.quote,
|
|
76
|
+
...(grab.component ? { component: grab.component } : {}),
|
|
77
|
+
...(grab.file ? { file: grab.file } : {}),
|
|
78
|
+
...(grab.line ? { line: grab.line } : {}),
|
|
79
|
+
...(grab.column ? { column: grab.column } : {}),
|
|
80
|
+
...(grab.stack ? { stack: grab.stack } : {}),
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
onPoint(on) {
|
|
84
|
+
if (!on) {
|
|
85
|
+
wantPoint = false;
|
|
86
|
+
options.onDisarm();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
options.onCanPoint(false);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
arm(on) {
|
|
95
|
+
wantPoint = on;
|
|
96
|
+
if (known) editor?.point(on);
|
|
97
|
+
},
|
|
98
|
+
highlight(blockId) {
|
|
99
|
+
if (known) editor?.highlight(blockId);
|
|
100
|
+
},
|
|
101
|
+
nudge() {
|
|
102
|
+
if (!known) editor?.point(false);
|
|
103
|
+
},
|
|
104
|
+
close() {
|
|
105
|
+
editor?.close();
|
|
106
|
+
editor = null;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// packages/studio/src/pages/use-session-preview.ts
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import type { RouteModel } from "@backmatter/core";
|
|
4
|
+
import { POINTER_ARM_EVENT } from "../chat/pointer-arm";
|
|
5
|
+
import { useChatUIOptional } from "../chat/ChatProvider";
|
|
6
|
+
import { registerPreviewHighlight } from "../preview-highlight";
|
|
7
|
+
import { previewFrameTransport } from "./preview-bridge";
|
|
8
|
+
import { bindSessionPreview, sessionDocument } from "./session-preview";
|
|
9
|
+
|
|
10
|
+
/** Point at a block in the code session's host, the same way the document editor does. */
|
|
11
|
+
export function useSessionPreview(input: {
|
|
12
|
+
frame: HTMLIFrameElement | null;
|
|
13
|
+
origin: string | null;
|
|
14
|
+
models: readonly RouteModel[];
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
}): void {
|
|
17
|
+
const ui = useChatUIOptional();
|
|
18
|
+
const uiRef = useRef(ui);
|
|
19
|
+
uiRef.current = ui;
|
|
20
|
+
const modelsRef = useRef(input.models);
|
|
21
|
+
modelsRef.current = input.models;
|
|
22
|
+
const armRef = useRef<(on: boolean) => void>(() => {});
|
|
23
|
+
const highlightRef = useRef<(blockId: string | null) => void>(() => {});
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!input.enabled) return;
|
|
27
|
+
const stop = registerPreviewHighlight((blockId) => highlightRef.current(blockId));
|
|
28
|
+
function onArm(event: Event) {
|
|
29
|
+
const on = (event as CustomEvent<{ on?: boolean }>).detail?.on === true;
|
|
30
|
+
armRef.current(on);
|
|
31
|
+
}
|
|
32
|
+
window.addEventListener(POINTER_ARM_EVENT, onArm);
|
|
33
|
+
return () => {
|
|
34
|
+
window.removeEventListener(POINTER_ARM_EVENT, onArm);
|
|
35
|
+
stop();
|
|
36
|
+
uiRef.current?.setPointerHighlight("local");
|
|
37
|
+
uiRef.current?.setPreviewPointer(false);
|
|
38
|
+
};
|
|
39
|
+
}, [input.enabled]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const chat = uiRef.current;
|
|
43
|
+
const frame = input.frame;
|
|
44
|
+
const origin = input.origin;
|
|
45
|
+
if (!input.enabled || !chat?.codeSessionId || !frame || !origin) return;
|
|
46
|
+
const transport = previewFrameTransport({ frame, siteOrigin: origin });
|
|
47
|
+
if (!transport) return;
|
|
48
|
+
chat.setPointerHighlight("channel");
|
|
49
|
+
const binding = bindSessionPreview({
|
|
50
|
+
transport,
|
|
51
|
+
onCanPoint: (on) => uiRef.current?.setPreviewPointer(on),
|
|
52
|
+
onPointer: (pointer) => uiRef.current?.addPointer(pointer),
|
|
53
|
+
onDisarm: () => {
|
|
54
|
+
window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on: false } }));
|
|
55
|
+
},
|
|
56
|
+
documentAt: (ref) => sessionDocument(ref, modelsRef.current),
|
|
57
|
+
});
|
|
58
|
+
armRef.current = (on) => binding.arm(on);
|
|
59
|
+
highlightRef.current = (blockId) => binding.highlight(blockId);
|
|
60
|
+
binding.nudge();
|
|
61
|
+
return () => {
|
|
62
|
+
armRef.current = () => {};
|
|
63
|
+
highlightRef.current = () => {};
|
|
64
|
+
binding.close();
|
|
65
|
+
transport.close();
|
|
66
|
+
window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on: false } }));
|
|
67
|
+
uiRef.current?.setPreviewPointer(false);
|
|
68
|
+
};
|
|
69
|
+
}, [input.enabled, input.frame, input.origin]);
|
|
70
|
+
}
|
package/src/replica/store.ts
CHANGED
|
@@ -165,6 +165,8 @@ export function createReplicaStore(
|
|
|
165
165
|
cached.head ??
|
|
166
166
|
(slot?.headSha ? { sha: slot.headSha, updatedAt: "" } : null),
|
|
167
167
|
changes: changesFromDocs(slot?.docs ?? []),
|
|
168
|
+
fetchedChanges: entry.git?.fetched,
|
|
169
|
+
recentCommits: entry.git?.recentCommits,
|
|
168
170
|
};
|
|
169
171
|
},
|
|
170
172
|
};
|
|
@@ -241,6 +243,7 @@ export function createReplicaStore(
|
|
|
241
243
|
const existing = checkouts[snapshot.workspace.id];
|
|
242
244
|
const defaultBranch =
|
|
243
245
|
snapshot.workspace.githubDefaultBranch ?? snapshot.checkout.branch;
|
|
246
|
+
const sameBranch = existing?.currentBranch === snapshot.checkout.branch;
|
|
244
247
|
checkouts = {
|
|
245
248
|
...checkouts,
|
|
246
249
|
[snapshot.workspace.id]: {
|
|
@@ -257,6 +260,12 @@ export function createReplicaStore(
|
|
|
257
260
|
: "",
|
|
258
261
|
}
|
|
259
262
|
: null,
|
|
263
|
+
...(sameBranch && existing?.git?.fetched
|
|
264
|
+
? { fetched: existing.git.fetched }
|
|
265
|
+
: {}),
|
|
266
|
+
...(sameBranch && existing?.git?.recentCommits
|
|
267
|
+
? { recentCommits: existing.git.recentCommits }
|
|
268
|
+
: {}),
|
|
260
269
|
},
|
|
261
270
|
branches: {
|
|
262
271
|
...(existing?.branches ?? {}),
|
|
@@ -400,6 +409,8 @@ export function createReplicaStore(
|
|
|
400
409
|
defaultBranch: git.defaultBranch,
|
|
401
410
|
branches: git.branches,
|
|
402
411
|
head: git.head,
|
|
412
|
+
fetched: git.changes,
|
|
413
|
+
recentCommits: git.recentCommits,
|
|
403
414
|
},
|
|
404
415
|
},
|
|
405
416
|
};
|
package/src/replica/types.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import type {
|
|
3
3
|
DocListItem,
|
|
4
4
|
DocStatus,
|
|
5
|
+
GitChange,
|
|
5
6
|
GitState,
|
|
7
|
+
RecentCommit,
|
|
6
8
|
SchemaPayload,
|
|
7
9
|
WorkspaceDetail,
|
|
8
10
|
WorkspaceSummary,
|
|
@@ -61,6 +63,10 @@ export type ReplicaGitCache = {
|
|
|
61
63
|
defaultBranch: string;
|
|
62
64
|
branches: Array<{ name: string }>;
|
|
63
65
|
head: { sha: string; updatedAt: string } | null;
|
|
66
|
+
/** Git status changes for the current branch, once fetched. */
|
|
67
|
+
fetched?: GitChange[];
|
|
68
|
+
/** Latest commits on this branch, once git status has been fetched. */
|
|
69
|
+
recentCommits?: RecentCommit[];
|
|
64
70
|
};
|
|
65
71
|
|
|
66
72
|
export type ReplicaCheckout = {
|