@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,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
|
+
}
|
package/src/pages/type-docs.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
// packages/studio/src/pages/type-docs.ts
|
|
2
2
|
|
|
3
|
-
/** Slug order
|
|
4
|
-
export function compareTypeDocs(
|
|
5
|
-
a
|
|
6
|
-
b: { slug: string; locale?: string },
|
|
7
|
-
) {
|
|
8
|
-
return a.slug.localeCompare(b.slug) || (a.locale ?? "").localeCompare(b.locale ?? "");
|
|
3
|
+
/** Slug order for type index lists. */
|
|
4
|
+
export function compareTypeDocs(a: { slug: string }, b: { slug: string }) {
|
|
5
|
+
return a.slug.localeCompare(b.slug);
|
|
9
6
|
}
|
|
@@ -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
|
@@ -14,25 +14,19 @@ import type {
|
|
|
14
14
|
} from "./types";
|
|
15
15
|
|
|
16
16
|
function docKey(ref: DocRef) {
|
|
17
|
-
return `${ref.
|
|
17
|
+
return `${ref.type}/${ref.slug}`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const OVERLAY_STATUSES = new Set(["modified", "added", "conflict", "deleted"]);
|
|
21
21
|
|
|
22
22
|
function validOverlay(row: ReplicaHit["overlay"][number]) {
|
|
23
|
-
return Boolean(
|
|
24
|
-
typeof row.locale === "string" &&
|
|
25
|
-
row.type &&
|
|
26
|
-
row.slug &&
|
|
27
|
-
OVERLAY_STATUSES.has(row.status),
|
|
28
|
-
);
|
|
23
|
+
return Boolean(row.type && row.slug && OVERLAY_STATUSES.has(row.status));
|
|
29
24
|
}
|
|
30
25
|
|
|
31
26
|
function changesFromDocs(docs: DocListItem[]) {
|
|
32
27
|
return docs
|
|
33
28
|
.filter((doc): doc is DocListItem & { status: ChangeStatus } => doc.status !== "clean")
|
|
34
29
|
.map((doc) => ({
|
|
35
|
-
locale: doc.locale,
|
|
36
30
|
type: doc.type,
|
|
37
31
|
slug: doc.slug,
|
|
38
32
|
status: doc.status,
|
|
@@ -171,6 +165,8 @@ export function createReplicaStore(
|
|
|
171
165
|
cached.head ??
|
|
172
166
|
(slot?.headSha ? { sha: slot.headSha, updatedAt: "" } : null),
|
|
173
167
|
changes: changesFromDocs(slot?.docs ?? []),
|
|
168
|
+
fetchedChanges: entry.git?.fetched,
|
|
169
|
+
recentCommits: entry.git?.recentCommits,
|
|
174
170
|
};
|
|
175
171
|
},
|
|
176
172
|
};
|
|
@@ -247,6 +243,7 @@ export function createReplicaStore(
|
|
|
247
243
|
const existing = checkouts[snapshot.workspace.id];
|
|
248
244
|
const defaultBranch =
|
|
249
245
|
snapshot.workspace.githubDefaultBranch ?? snapshot.checkout.branch;
|
|
246
|
+
const sameBranch = existing?.currentBranch === snapshot.checkout.branch;
|
|
250
247
|
checkouts = {
|
|
251
248
|
...checkouts,
|
|
252
249
|
[snapshot.workspace.id]: {
|
|
@@ -263,6 +260,12 @@ export function createReplicaStore(
|
|
|
263
260
|
: "",
|
|
264
261
|
}
|
|
265
262
|
: null,
|
|
263
|
+
...(sameBranch && existing?.git?.fetched
|
|
264
|
+
? { fetched: existing.git.fetched }
|
|
265
|
+
: {}),
|
|
266
|
+
...(sameBranch && existing?.git?.recentCommits
|
|
267
|
+
? { recentCommits: existing.git.recentCommits }
|
|
268
|
+
: {}),
|
|
266
269
|
},
|
|
267
270
|
branches: {
|
|
268
271
|
...(existing?.branches ?? {}),
|
|
@@ -308,7 +311,6 @@ export function createReplicaStore(
|
|
|
308
311
|
pending.delete(docKey(doc));
|
|
309
312
|
if (over.status === "deleted") continue;
|
|
310
313
|
docs.push({
|
|
311
|
-
locale: over.locale,
|
|
312
314
|
type: over.type,
|
|
313
315
|
slug: over.slug,
|
|
314
316
|
status: over.status,
|
|
@@ -325,7 +327,6 @@ export function createReplicaStore(
|
|
|
325
327
|
for (const over of pending.values()) {
|
|
326
328
|
if (over.status === "deleted") continue;
|
|
327
329
|
docs.push({
|
|
328
|
-
locale: over.locale,
|
|
329
330
|
type: over.type,
|
|
330
331
|
slug: over.slug,
|
|
331
332
|
status: over.status,
|
|
@@ -408,6 +409,8 @@ export function createReplicaStore(
|
|
|
408
409
|
defaultBranch: git.defaultBranch,
|
|
409
410
|
branches: git.branches,
|
|
410
411
|
head: git.head,
|
|
412
|
+
fetched: git.changes,
|
|
413
|
+
recentCommits: git.recentCommits,
|
|
411
414
|
},
|
|
412
415
|
},
|
|
413
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,
|
|
@@ -29,7 +31,6 @@ export type ReplicaSnapshot = {
|
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
export type ReplicaOverlayItem = {
|
|
32
|
-
locale: string;
|
|
33
34
|
type: string;
|
|
34
35
|
slug: string;
|
|
35
36
|
status: Exclude<DocStatus, "clean">;
|
|
@@ -62,6 +63,10 @@ export type ReplicaGitCache = {
|
|
|
62
63
|
defaultBranch: string;
|
|
63
64
|
branches: Array<{ name: string }>;
|
|
64
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[];
|
|
65
70
|
};
|
|
66
71
|
|
|
67
72
|
export type ReplicaCheckout = {
|
|
@@ -77,7 +82,7 @@ export type ReplicaRecord = {
|
|
|
77
82
|
checkouts: Record<string, ReplicaCheckout>;
|
|
78
83
|
};
|
|
79
84
|
|
|
80
|
-
export type DocRef = {
|
|
85
|
+
export type DocRef = { type: string; slug: string };
|
|
81
86
|
|
|
82
87
|
export type ReplicaPersistence = {
|
|
83
88
|
load(userId: string): Promise<ReplicaRecord | null>;
|
package/src/site-pointer.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// packages/studio/src/site-pointer.ts
|
|
2
2
|
// Hit-test a click on the host page and draw the armed pointer outline.
|
|
3
3
|
|
|
4
|
+
import { readElementOrigin, type ElementOrigin } from "./element-origin";
|
|
5
|
+
|
|
4
6
|
export const POINTER_QUOTE_MAX = 240;
|
|
5
7
|
|
|
6
8
|
export type PointerGrab = {
|
|
@@ -8,6 +10,14 @@ export type PointerGrab = {
|
|
|
8
10
|
blockType: string | null;
|
|
9
11
|
blockIndex: number | null;
|
|
10
12
|
quote: string;
|
|
13
|
+
} & ElementOrigin;
|
|
14
|
+
|
|
15
|
+
const EMPTY_ORIGIN: ElementOrigin = {
|
|
16
|
+
component: null,
|
|
17
|
+
file: null,
|
|
18
|
+
line: null,
|
|
19
|
+
column: null,
|
|
20
|
+
stack: [],
|
|
11
21
|
};
|
|
12
22
|
|
|
13
23
|
function quoteOf(target: Element): string {
|
|
@@ -29,9 +39,10 @@ export function grabAt(target: Element | null, bar: Element | null): PointerGrab
|
|
|
29
39
|
|
|
30
40
|
const marker = target.closest("[data-backmatter-block]");
|
|
31
41
|
const quote = quoteOf(target);
|
|
42
|
+
const origin = readElementOrigin(target) ?? EMPTY_ORIGIN;
|
|
32
43
|
if (!marker) {
|
|
33
|
-
if (!quote) return null;
|
|
34
|
-
return { blockId: null, blockType: null, blockIndex: null, quote };
|
|
44
|
+
if (!quote && !origin.component && !origin.file) return null;
|
|
45
|
+
return { blockId: null, blockType: null, blockIndex: null, quote, ...origin };
|
|
35
46
|
}
|
|
36
47
|
const indexRaw = marker.getAttribute("data-backmatter-index");
|
|
37
48
|
const blockIndex = indexRaw !== null && /^\d+$/.test(indexRaw) ? Number(indexRaw) : null;
|
|
@@ -40,6 +51,7 @@ export function grabAt(target: Element | null, bar: Element | null): PointerGrab
|
|
|
40
51
|
blockType: presentAttr(marker, "data-backmatter-type"),
|
|
41
52
|
blockIndex,
|
|
42
53
|
quote,
|
|
54
|
+
...origin,
|
|
43
55
|
};
|
|
44
56
|
}
|
|
45
57
|
|
|
@@ -104,10 +116,13 @@ export function installSitePointer(
|
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
function onClick(event: MouseEvent) {
|
|
107
|
-
if (!armed
|
|
119
|
+
if (!armed) return;
|
|
120
|
+
const raw = event.target instanceof Element ? event.target : null;
|
|
121
|
+
// The outline sits on the element. A click on it belongs to the element under it.
|
|
122
|
+
const target = raw?.closest("[data-backmatter-pointer]") ? current : raw;
|
|
123
|
+
if (!target || ignored(target)) return;
|
|
108
124
|
event.preventDefault();
|
|
109
125
|
event.stopPropagation();
|
|
110
|
-
const target = event.target instanceof Element ? event.target : null;
|
|
111
126
|
const grab = grabAt(target, options.bar());
|
|
112
127
|
if (grab) options.onGrab(grab, isOpenModifier(event));
|
|
113
128
|
}
|
|
@@ -174,6 +189,7 @@ function createPointerOutline(doc: Document): HTMLDivElement {
|
|
|
174
189
|
label.style.background = "#18181b";
|
|
175
190
|
label.style.color = "#ffffff";
|
|
176
191
|
label.style.font = "11px/1.4 ui-sans-serif, system-ui, sans-serif";
|
|
192
|
+
label.style.pointerEvents = "none";
|
|
177
193
|
label.style.display = "none";
|
|
178
194
|
el.appendChild(label);
|
|
179
195
|
doc.body.appendChild(el);
|
|
@@ -216,7 +232,10 @@ function positionPointerOutline(outline: HTMLDivElement, target: Element) {
|
|
|
216
232
|
outline.style.width = `${rect.width}px`;
|
|
217
233
|
outline.style.height = `${rect.height}px`;
|
|
218
234
|
const label = outline.firstElementChild as HTMLElement | null;
|
|
219
|
-
const name =
|
|
235
|
+
const name =
|
|
236
|
+
target.closest("[data-backmatter-block]")?.getAttribute("data-backmatter-type") ||
|
|
237
|
+
readElementOrigin(target)?.component ||
|
|
238
|
+
"";
|
|
220
239
|
if (!label) return;
|
|
221
240
|
if (!name) {
|
|
222
241
|
label.textContent = "";
|
package/src/site-widget.tsx
CHANGED
|
@@ -27,7 +27,6 @@ import { applyThemeClass } from "./theme/apply-theme-class";
|
|
|
27
27
|
import { TooltipProvider } from "./ui/Tooltip";
|
|
28
28
|
|
|
29
29
|
export type SiteWidgetDoc = {
|
|
30
|
-
locale: string;
|
|
31
30
|
type: string;
|
|
32
31
|
slug: string;
|
|
33
32
|
workspaceId: string;
|
|
@@ -200,12 +199,12 @@ function DocFocus({ doc }: { doc: SiteWidgetDoc }) {
|
|
|
200
199
|
const { setFocus } = useEditorFocus();
|
|
201
200
|
useEffect(() => {
|
|
202
201
|
setFocus({
|
|
203
|
-
docRef: {
|
|
202
|
+
docRef: { type: doc.type, slug: doc.slug },
|
|
204
203
|
frontmatter: {},
|
|
205
204
|
body: "",
|
|
206
205
|
selection: null,
|
|
207
206
|
});
|
|
208
|
-
}, [doc.
|
|
207
|
+
}, [doc.type, doc.slug, setFocus]);
|
|
209
208
|
return null;
|
|
210
209
|
}
|
|
211
210
|
|
package/src/studio-config.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type StudioConfig = {
|
|
|
14
14
|
dev: boolean;
|
|
15
15
|
/**
|
|
16
16
|
* The CMS API origin as the outside world reaches it, for URLs the Studio
|
|
17
|
-
* shows rather than fetches (the Content API
|
|
17
|
+
* shows rather than fetches (the Content API URL in General settings). A site Studio fetches
|
|
18
18
|
* through a relative proxy, so its host passes the real origin here.
|
|
19
19
|
* Defaults to `baseUrl`.
|
|
20
20
|
*/
|
package/src/ui/ListRow.tsx
CHANGED
|
@@ -11,13 +11,13 @@ export function ListRow({
|
|
|
11
11
|
meta,
|
|
12
12
|
trailing,
|
|
13
13
|
}: {
|
|
14
|
-
to
|
|
14
|
+
to?: string;
|
|
15
15
|
title: string;
|
|
16
16
|
meta?: string;
|
|
17
17
|
trailing?: string;
|
|
18
18
|
}) {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const body = (
|
|
20
|
+
<>
|
|
21
21
|
<span {...stylex.props(styles.body)}>
|
|
22
22
|
<span {...stylex.props(styles.title)}>{title}</span>
|
|
23
23
|
{meta ? <span {...stylex.props(styles.meta)}>{meta}</span> : null}
|
|
@@ -25,6 +25,12 @@ export function ListRow({
|
|
|
25
25
|
{trailing ? (
|
|
26
26
|
<span {...stylex.props(styles.trailing)}>{trailing}</span>
|
|
27
27
|
) : null}
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
if (!to) return <div {...stylex.props(styles.row, styles.static)}>{body}</div>;
|
|
31
|
+
return (
|
|
32
|
+
<Link to={to} {...stylex.props(styles.row)}>
|
|
33
|
+
{body}
|
|
28
34
|
</Link>
|
|
29
35
|
);
|
|
30
36
|
}
|
|
@@ -66,6 +72,9 @@ const styles = stylex.create({
|
|
|
66
72
|
},
|
|
67
73
|
},
|
|
68
74
|
},
|
|
75
|
+
static: {
|
|
76
|
+
cursor: "default",
|
|
77
|
+
},
|
|
69
78
|
body: {
|
|
70
79
|
display: "flex",
|
|
71
80
|
minWidth: 0,
|
package/src/ui/Page.tsx
CHANGED
|
@@ -6,10 +6,12 @@ export function PageFrame({
|
|
|
6
6
|
children,
|
|
7
7
|
flush,
|
|
8
8
|
centered,
|
|
9
|
+
wide,
|
|
9
10
|
}: {
|
|
10
11
|
children: React.ReactNode;
|
|
11
12
|
flush?: boolean;
|
|
12
13
|
centered?: boolean;
|
|
14
|
+
wide?: boolean;
|
|
13
15
|
}) {
|
|
14
16
|
return (
|
|
15
17
|
<div
|
|
@@ -22,7 +24,13 @@ export function PageFrame({
|
|
|
22
24
|
{flush ? (
|
|
23
25
|
children
|
|
24
26
|
) : (
|
|
25
|
-
<div
|
|
27
|
+
<div
|
|
28
|
+
{...stylex.props(
|
|
29
|
+
styles.inner,
|
|
30
|
+
centered && styles.innerCentered,
|
|
31
|
+
wide && styles.innerWide,
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
26
34
|
{children}
|
|
27
35
|
</div>
|
|
28
36
|
)}
|
|
@@ -108,6 +116,9 @@ const styles = stylex.create({
|
|
|
108
116
|
innerCentered: {
|
|
109
117
|
maxWidth: "440px",
|
|
110
118
|
},
|
|
119
|
+
innerWide: {
|
|
120
|
+
maxWidth: "960px",
|
|
121
|
+
},
|
|
111
122
|
header: {
|
|
112
123
|
display: "flex",
|
|
113
124
|
alignItems: "flex-start",
|