@backmatter/studio 0.0.10 → 0.0.12
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-BxvTwsko.js → UiKitApp-ZZXQt3zy.js} +29 -29
- package/dist/chunks/{WorkspaceRoutes-BKKEkAq7.js → WorkspaceRoutes-CCDPtLla.js} +22712 -22498
- package/dist/chunks/{dashboard-8rjkfEXl.js → dashboard-Dw0zfTXg.js} +35 -35
- package/dist/chunks/{index-laWAz5_p.js → index-DhZGCAru.js} +1 -1
- package/dist/dashboard.js +2 -2
- package/dist/index.js +199 -193
- package/dist/site-widget.css +9 -1
- package/dist/site-widget.js +6060 -5936
- package/dist/studio.css +5 -25
- package/package.json +5 -1
- package/src/api.ts +1 -1
- package/src/chat/ChatProvider.tsx +13 -2
- package/src/chat/Composer.tsx +2 -1
- package/src/chat/PointerChip.tsx +15 -2
- package/src/chat/chat-helpers.ts +6 -0
- package/src/chat/pointer-arm.ts +3 -0
- package/src/chrome/WorkspaceStatusBar.tsx +64 -25
- package/src/chrome/branch-head.ts +14 -0
- package/src/chrome/commit-error.ts +82 -0
- package/src/chrome/commit-failure.tsx +111 -0
- package/src/install-preview-pointer.ts +116 -0
- package/src/pages/BlocksList.tsx +17 -2
- package/src/pages/DocEditorPage.tsx +121 -23
- package/src/pages/EmbedPublishPage.tsx +27 -24
- package/src/pages/RenderedBody.tsx +141 -322
- package/src/pages/document-paint.ts +79 -0
- package/src/pages/editor-grab.ts +39 -0
- package/src/pages/html-to-markdown.ts +26 -0
- package/src/pages/preview-bridge.ts +8 -1
- package/src/pages/preview-url.ts +15 -0
- package/src/pages/selection-format.ts +5 -2
- package/src/preview-highlight.ts +16 -0
- package/src/preview-pointer-host.ts +30 -0
- package/src/shell-resets.css +13 -0
- package/src/site-changes.ts +46 -0
- package/src/site-pointer.ts +39 -6
- package/src/site-widget.tsx +66 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/studio",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "The Backmatter editing surface: document editor, AI chat, publish. Mounted on your site or hosted on the dashboard.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
"./site-widget.css": "./dist/site-widget.css",
|
|
37
37
|
"./prose-ui-node-shim": "./prose-ui-node-shim.ts",
|
|
38
38
|
"./package.json": "./package.json",
|
|
39
|
+
"./src/chat/pointer-arm": "./src/chat/pointer-arm.ts",
|
|
40
|
+
"./src/install-preview-pointer": "./src/install-preview-pointer.ts",
|
|
41
|
+
"./src/preview-pointer-host": "./src/preview-pointer-host.ts",
|
|
42
|
+
"./src/site-pointer": "./src/site-pointer.ts",
|
|
39
43
|
"./src/*": "./src/*"
|
|
40
44
|
},
|
|
41
45
|
"files": [
|
package/src/api.ts
CHANGED
|
@@ -501,7 +501,7 @@ export function notifyTreeChanged(detail?: TreeChangeDetail) {
|
|
|
501
501
|
);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
-
/** Branch HEAD moved (teammate push, pull, commit)
|
|
504
|
+
/** Branch HEAD moved (teammate push, pull, commit). Clean docs reload their fields. The preview frame stays up. */
|
|
505
505
|
export function notifyHeadChanged() {
|
|
506
506
|
window.dispatchEvent(new Event("backmatter:head-changed"));
|
|
507
507
|
}
|
|
@@ -88,8 +88,12 @@ export type ChatUIValue = {
|
|
|
88
88
|
mentions: DocRef[];
|
|
89
89
|
addMention: (ref: DocRef) => void;
|
|
90
90
|
removeMention: (ref: DocRef) => void;
|
|
91
|
-
/**
|
|
91
|
+
/** Point button. The dev bar sets this, and the document editor turns it on when the preview can be pointed at. */
|
|
92
92
|
pointer: boolean;
|
|
93
|
+
setPreviewPointer: (on: boolean) => void;
|
|
94
|
+
/** Local outline on this page, or a highlight message on the preview channel. */
|
|
95
|
+
pointerHighlight: "local" | "channel";
|
|
96
|
+
setPointerHighlight: (mode: "local" | "channel") => void;
|
|
93
97
|
pointers: ChatPointer[];
|
|
94
98
|
addPointer: (pointer: ChatPointer) => void;
|
|
95
99
|
removePointer: (pointer: ChatPointer) => void;
|
|
@@ -202,6 +206,8 @@ export function ChatProvider({
|
|
|
202
206
|
const [pendingText, setPendingText] = useState<string | null>(null);
|
|
203
207
|
const [mentions, setMentions] = useState<DocRef[]>([]);
|
|
204
208
|
const [pointers, setPointers] = useState<ChatPointer[]>([]);
|
|
209
|
+
const [previewPointer, setPreviewPointer] = useState(false);
|
|
210
|
+
const [pointerHighlight, setPointerHighlight] = useState<"local" | "channel">("local");
|
|
205
211
|
const [includeFocus, setIncludeFocus] = useState(true);
|
|
206
212
|
const [threadsVersion, setThreadsVersion] = useState(0);
|
|
207
213
|
const { focus } = useEditorFocus();
|
|
@@ -318,7 +324,10 @@ export function ChatProvider({
|
|
|
318
324
|
mentions,
|
|
319
325
|
addMention,
|
|
320
326
|
removeMention,
|
|
321
|
-
pointer,
|
|
327
|
+
pointer: pointer || previewPointer,
|
|
328
|
+
setPreviewPointer,
|
|
329
|
+
pointerHighlight,
|
|
330
|
+
setPointerHighlight,
|
|
322
331
|
pointers,
|
|
323
332
|
addPointer,
|
|
324
333
|
removePointer,
|
|
@@ -344,6 +353,8 @@ export function ChatProvider({
|
|
|
344
353
|
addMention,
|
|
345
354
|
removeMention,
|
|
346
355
|
pointer,
|
|
356
|
+
previewPointer,
|
|
357
|
+
pointerHighlight,
|
|
347
358
|
pointers,
|
|
348
359
|
addPointer,
|
|
349
360
|
removePointer,
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { ModelPicker } from "./ModelPicker";
|
|
|
10
10
|
import { AtIcon, CloseIcon, FileIcon, FocusIcon, PointerIcon, SendIcon, StopIcon } from "./icons";
|
|
11
11
|
import { mentionQueryAt } from "./chat-helpers";
|
|
12
12
|
import { PointerChip, pointerKey } from "./PointerChip";
|
|
13
|
+
import { POINTER_ARM_EVENT } from "./pointer-arm";
|
|
13
14
|
|
|
14
15
|
const hover = "@media (hover: hover)";
|
|
15
16
|
|
|
@@ -23,7 +24,7 @@ function isMentionKey(key: string): key is MentionKey {
|
|
|
23
24
|
return MENTION_KEYS.has(key);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export
|
|
27
|
+
export { POINTER_ARM_EVENT } from "./pointer-arm";
|
|
27
28
|
|
|
28
29
|
function postPointer(on: boolean) {
|
|
29
30
|
if (typeof window === "undefined") return;
|
package/src/chat/PointerChip.tsx
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { useEffect } from "react";
|
|
3
3
|
import * as stylex from "@stylexjs/stylex";
|
|
4
4
|
import { colors, space, type } from "../styles/tokens.stylex";
|
|
5
|
+
import { emitPreviewHighlight } from "../preview-highlight";
|
|
5
6
|
import { findPointedElement, highlightPointedElement } from "../site-pointer";
|
|
6
7
|
import type { ChatPointer } from "./chat-context";
|
|
8
|
+
import { useChatUI } from "./ChatProvider";
|
|
7
9
|
import { CloseIcon, PointerIcon } from "./icons";
|
|
8
10
|
|
|
9
11
|
const hover = "@media (hover: hover)";
|
|
@@ -39,19 +41,30 @@ export function PointerChip({
|
|
|
39
41
|
}) {
|
|
40
42
|
const label = pointerChipLabel(pointer);
|
|
41
43
|
const detail = pointerLabel(pointer);
|
|
44
|
+
const { pointerHighlight } = useChatUI();
|
|
45
|
+
const channel = pointerHighlight === "channel";
|
|
42
46
|
|
|
43
47
|
useEffect(() => {
|
|
44
48
|
return () => {
|
|
45
|
-
if (
|
|
49
|
+
if (channel) emitPreviewHighlight(null);
|
|
50
|
+
else if (canHighlightHere()) highlightPointedElement(document, null);
|
|
46
51
|
};
|
|
47
|
-
}, []);
|
|
52
|
+
}, [channel]);
|
|
48
53
|
|
|
49
54
|
function show() {
|
|
55
|
+
if (channel) {
|
|
56
|
+
emitPreviewHighlight(pointer.blockId);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
50
59
|
if (!canHighlightHere()) return;
|
|
51
60
|
highlightPointedElement(document, findPointedElement(document, pointer));
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
function hide() {
|
|
64
|
+
if (channel) {
|
|
65
|
+
emitPreviewHighlight(null);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
55
68
|
if (!canHighlightHere()) return;
|
|
56
69
|
highlightPointedElement(document, null);
|
|
57
70
|
}
|
package/src/chat/chat-helpers.ts
CHANGED
|
@@ -72,6 +72,12 @@ export function toolLabel(name: string, input: unknown, output: unknown, state:
|
|
|
72
72
|
return running ? `Reading ${doc}…` : `Read ${doc}`;
|
|
73
73
|
case "searchDocs":
|
|
74
74
|
return running ? `Searching “${q ?? ""}”…` : `Searched “${q ?? ""}”`;
|
|
75
|
+
case "searchWeb":
|
|
76
|
+
return running ? `Searching “${q ?? ""}”…` : `Searched “${q ?? ""}”`;
|
|
77
|
+
case "readUrl": {
|
|
78
|
+
const url = (input as { url?: string } | undefined)?.url ?? "";
|
|
79
|
+
return running ? `Reading ${url}…` : `Read ${url}`;
|
|
80
|
+
}
|
|
75
81
|
case "writeDoc":
|
|
76
82
|
return running ? `Editing ${doc}…` : `Edited ${doc}`;
|
|
77
83
|
case "createDoc":
|
|
@@ -17,11 +17,17 @@ import {
|
|
|
17
17
|
notifyWorkspaceChanged,
|
|
18
18
|
pullFromGithub,
|
|
19
19
|
resolveDoc,
|
|
20
|
-
type ApiError,
|
|
21
20
|
type ChangeStatus,
|
|
22
21
|
type GitChange,
|
|
23
22
|
} from "../api";
|
|
24
23
|
import { contentDocPath } from "../workspace-path";
|
|
24
|
+
import {
|
|
25
|
+
commitErrorSummary,
|
|
26
|
+
explainCommitError,
|
|
27
|
+
problemsForRef,
|
|
28
|
+
type CommitErrorView,
|
|
29
|
+
} from "./commit-error";
|
|
30
|
+
import { CommitFailureNotice, commitFailureLink } from "./commit-failure";
|
|
25
31
|
import { CheckIcon } from "../chat/icons";
|
|
26
32
|
import { WorkspaceSwitcher } from "../components/WorkspaceSwitcher";
|
|
27
33
|
import { colors, radius, space, type } from "../styles/tokens.stylex";
|
|
@@ -35,9 +41,11 @@ import { replicaStore } from "../replica/instance";
|
|
|
35
41
|
import { useReplicaSession } from "../replica/ReplicaProvider";
|
|
36
42
|
import { useReplicaState } from "../replica/useReplica";
|
|
37
43
|
import { useWorkspaceNav } from "./WorkspaceNavContext";
|
|
44
|
+
import { branchHeadMoved } from "./branch-head";
|
|
38
45
|
import { commitTriggerDisabled, statusBarAction } from "./status-bar-action";
|
|
39
46
|
|
|
40
47
|
const hover = "@media (hover: hover)";
|
|
48
|
+
/** Status bar only. The preview frame follows HEAD on its SSE stream, not on this poll. */
|
|
41
49
|
const POLL_MS = 8000;
|
|
42
50
|
|
|
43
51
|
export const STATUS_LABEL: Record<ChangeStatus, string> = {
|
|
@@ -47,18 +55,6 @@ export const STATUS_LABEL: Record<ChangeStatus, string> = {
|
|
|
47
55
|
deleted: "deleted",
|
|
48
56
|
};
|
|
49
57
|
|
|
50
|
-
function formatCommitError(err: unknown): string {
|
|
51
|
-
if (!(err instanceof Error)) return "Commit failed";
|
|
52
|
-
const apiErr = err as ApiError;
|
|
53
|
-
if (apiErr.code === "conflict") return "Resolve conflicts before committing";
|
|
54
|
-
if (apiErr.code === "nothing_to_commit") return "Nothing to commit";
|
|
55
|
-
if (apiErr.code === "validation") {
|
|
56
|
-
return err.message.replace(/^validation:\s*/, "Validation failed: ");
|
|
57
|
-
}
|
|
58
|
-
if (apiErr.code === "github_not_bound") return "Connect GitHub first";
|
|
59
|
-
return err.message;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
58
|
function changeKey(doc: GitChange) {
|
|
63
59
|
return `${doc.locale}/${doc.type}/${doc.slug}`;
|
|
64
60
|
}
|
|
@@ -144,10 +140,10 @@ function ConflictResolvePanel({
|
|
|
144
140
|
);
|
|
145
141
|
}
|
|
146
142
|
|
|
147
|
-
function ChangeRow({ doc }: { doc: GitChange }) {
|
|
143
|
+
function ChangeRow({ doc, invalid = false }: { doc: GitChange; invalid?: boolean }) {
|
|
148
144
|
return (
|
|
149
145
|
<div {...stylex.props(styles.changeItem)}>
|
|
150
|
-
<span {...stylex.props(styles.changeName)}>
|
|
146
|
+
<span {...stylex.props(styles.changeName, invalid && styles.conflictText)}>
|
|
151
147
|
{doc.type}/{doc.slug}
|
|
152
148
|
</span>
|
|
153
149
|
<span
|
|
@@ -181,7 +177,9 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
181
177
|
const [resolvingKey, setResolvingKey] = useState<string | null>(null);
|
|
182
178
|
const [message, setMessage] = useState("");
|
|
183
179
|
const [error, setError] = useState<string | null>(null);
|
|
184
|
-
|
|
180
|
+
const [commitFailure, setCommitFailure] = useState<CommitErrorView | null>(null);
|
|
181
|
+
// Commit sha from the last poll. A new sha means HEAD moved. The preview
|
|
182
|
+
// iframe does not reload for that; its page follows the SSE head event.
|
|
185
183
|
const lastHead = useRef<string | null | undefined>(undefined);
|
|
186
184
|
|
|
187
185
|
async function reloadGit() {
|
|
@@ -195,11 +193,9 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
195
193
|
} else {
|
|
196
194
|
setError(null);
|
|
197
195
|
}
|
|
198
|
-
const
|
|
199
|
-
if (lastHead.current
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
lastHead.current = headKey;
|
|
196
|
+
const nextSha = next.head?.sha ?? null;
|
|
197
|
+
if (branchHeadMoved(lastHead.current, nextSha)) notifyHeadChanged();
|
|
198
|
+
lastHead.current = nextSha;
|
|
203
199
|
} catch (err) {
|
|
204
200
|
setError(err instanceof Error ? err.message : "Git status failed");
|
|
205
201
|
}
|
|
@@ -323,6 +319,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
323
319
|
if (committing || conflictCount > 0 || changeCount === 0) return;
|
|
324
320
|
setCommitting(true);
|
|
325
321
|
setError(null);
|
|
322
|
+
setCommitFailure(null);
|
|
326
323
|
try {
|
|
327
324
|
const result = await commitWorkspace(workspaceId, message);
|
|
328
325
|
setMessage("");
|
|
@@ -330,13 +327,29 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
330
327
|
publishCommittedFiles(workspaceId, result.files);
|
|
331
328
|
await reloadGit();
|
|
332
329
|
} catch (err) {
|
|
333
|
-
|
|
330
|
+
setCommitFailure(explainCommitError(err));
|
|
334
331
|
await reloadGit();
|
|
335
332
|
} finally {
|
|
336
333
|
setCommitting(false);
|
|
337
334
|
}
|
|
338
335
|
}
|
|
339
336
|
|
|
337
|
+
function hrefForChange(ref: string): string | null {
|
|
338
|
+
const slash = ref.indexOf("/");
|
|
339
|
+
if (slash <= 0 || !workspaceNav) return null;
|
|
340
|
+
const type = ref.slice(0, slash);
|
|
341
|
+
const slug = ref.slice(slash + 1);
|
|
342
|
+
const doc = changes.find((change) => change.type === type && change.slug === slug);
|
|
343
|
+
if (!doc) return null;
|
|
344
|
+
return contentDocPath(
|
|
345
|
+
workspaceNav.workspaceSlug,
|
|
346
|
+
doc.type,
|
|
347
|
+
doc.slug,
|
|
348
|
+
doc.locale,
|
|
349
|
+
workspaceNav.defaultLocale,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
340
353
|
const commitLabel = committing
|
|
341
354
|
? "Committing…"
|
|
342
355
|
: conflictCount > 0
|
|
@@ -506,8 +519,11 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
506
519
|
{changeCount} uncommitted
|
|
507
520
|
</StatusItem>
|
|
508
521
|
) : null}
|
|
509
|
-
{error ?
|
|
510
|
-
|
|
522
|
+
{error ? (
|
|
523
|
+
<StatusItem tone="danger">{error}</StatusItem>
|
|
524
|
+
) : commitFailure ? (
|
|
525
|
+
<StatusItem tone="danger">{commitErrorSummary(commitFailure)}</StatusItem>
|
|
526
|
+
) : refreshError ? (
|
|
511
527
|
<StatusItem tone="danger">{refreshError}</StatusItem>
|
|
512
528
|
) : null}
|
|
513
529
|
<StatusSpacer />
|
|
@@ -562,10 +578,33 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
562
578
|
<p {...stylex.props(popup.groupLabel)}>
|
|
563
579
|
{changeCount} {changeCount === 1 ? "file" : "files"} → {git?.currentBranch}
|
|
564
580
|
</p>
|
|
581
|
+
{commitFailure ? (
|
|
582
|
+
<CommitFailureNotice
|
|
583
|
+
failure={commitFailure}
|
|
584
|
+
renderRef={(ref) => {
|
|
585
|
+
const href = hrefForChange(ref);
|
|
586
|
+
if (!href) return ref;
|
|
587
|
+
return (
|
|
588
|
+
<Link
|
|
589
|
+
to={href}
|
|
590
|
+
onClick={() => setCommitOpen(false)}
|
|
591
|
+
{...stylex.props(commitFailureLink.link)}
|
|
592
|
+
>
|
|
593
|
+
{ref}
|
|
594
|
+
</Link>
|
|
595
|
+
);
|
|
596
|
+
}}
|
|
597
|
+
/>
|
|
598
|
+
) : null}
|
|
565
599
|
<ul {...stylex.props(styles.commitFiles)}>
|
|
566
600
|
{changes.map((doc) => (
|
|
567
601
|
<li key={`${doc.locale}/${doc.type}/${doc.slug}`}>
|
|
568
|
-
<ChangeRow
|
|
602
|
+
<ChangeRow
|
|
603
|
+
doc={doc}
|
|
604
|
+
invalid={
|
|
605
|
+
problemsForRef(commitFailure, `${doc.type}/${doc.slug}`).length > 0
|
|
606
|
+
}
|
|
607
|
+
/>
|
|
569
608
|
</li>
|
|
570
609
|
))}
|
|
571
610
|
</ul>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// packages/studio/src/chrome/branch-head.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a later git poll is a new commit.
|
|
5
|
+
* The first sample only records the sha. `updatedAt` is not part of the
|
|
6
|
+
* identity: rewriting the head row must not look like HEAD moved.
|
|
7
|
+
*/
|
|
8
|
+
export function branchHeadMoved(
|
|
9
|
+
previousSha: string | null | undefined,
|
|
10
|
+
nextSha: string | null,
|
|
11
|
+
): boolean {
|
|
12
|
+
if (previousSha === undefined) return false;
|
|
13
|
+
return previousSha !== nextSha;
|
|
14
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// packages/studio/src/chrome/commit-error.ts
|
|
2
|
+
import type { ApiError } from "../api";
|
|
3
|
+
|
|
4
|
+
export type CommitFileProblem = {
|
|
5
|
+
/** `type/slug`, the same label the commit list uses. */
|
|
6
|
+
ref: string;
|
|
7
|
+
problems: string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type CommitErrorView =
|
|
11
|
+
| { kind: "validation"; files: CommitFileProblem[] }
|
|
12
|
+
| { kind: "message"; text: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Turn a failed commit into what the deploy window should say.
|
|
16
|
+
* Validation lists every file and field. Other failures stay one sentence.
|
|
17
|
+
*/
|
|
18
|
+
export function explainCommitError(err: unknown): CommitErrorView {
|
|
19
|
+
if (!(err instanceof Error)) return { kind: "message", text: "Commit failed" };
|
|
20
|
+
const apiErr = err as ApiError;
|
|
21
|
+
if (apiErr.code === "conflict") {
|
|
22
|
+
return { kind: "message", text: "Resolve conflicts before committing" };
|
|
23
|
+
}
|
|
24
|
+
if (apiErr.code === "nothing_to_commit") {
|
|
25
|
+
return { kind: "message", text: "Nothing to commit" };
|
|
26
|
+
}
|
|
27
|
+
if (apiErr.code === "github_not_bound") {
|
|
28
|
+
return { kind: "message", text: "Connect GitHub first" };
|
|
29
|
+
}
|
|
30
|
+
if (apiErr.code === "validation") {
|
|
31
|
+
return { kind: "validation", files: groupValidation(validationLines(apiErr)) };
|
|
32
|
+
}
|
|
33
|
+
return { kind: "message", text: err.message };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One line for the status bar. The window lists every problem. */
|
|
37
|
+
export function commitErrorSummary(view: CommitErrorView): string {
|
|
38
|
+
if (view.kind === "message") return view.text;
|
|
39
|
+
const first = view.files[0];
|
|
40
|
+
if (!first || first.problems.length === 0) return "Can't commit";
|
|
41
|
+
const extra =
|
|
42
|
+
view.files.reduce((n, file) => n + file.problems.length, 0) - 1;
|
|
43
|
+
const lead = `${first.ref} — ${first.problems[0]}`;
|
|
44
|
+
return extra > 0 ? `${lead} (+${extra} more)` : lead;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function problemsForRef(
|
|
48
|
+
view: CommitErrorView | null,
|
|
49
|
+
ref: string,
|
|
50
|
+
): string[] {
|
|
51
|
+
if (!view || view.kind !== "validation") return [];
|
|
52
|
+
return view.files.find((file) => file.ref === ref)?.problems ?? [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validationLines(err: ApiError): string[] {
|
|
56
|
+
if (err.details && err.details.length > 0) return err.details;
|
|
57
|
+
const body = err.message.replace(/^validation:\s*/, "");
|
|
58
|
+
if (!body || body === err.message) return [];
|
|
59
|
+
return body.split(", ").filter((line) => line.length > 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function groupValidation(lines: string[]): CommitFileProblem[] {
|
|
63
|
+
const order: string[] = [];
|
|
64
|
+
const byRef = new Map<string, string[]>();
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const split = line.indexOf(": ");
|
|
67
|
+
const ref = split === -1 ? line : line.slice(0, split);
|
|
68
|
+
const problem = sentence(split === -1 ? line : line.slice(split + 2));
|
|
69
|
+
if (!byRef.has(ref)) {
|
|
70
|
+
byRef.set(ref, []);
|
|
71
|
+
order.push(ref);
|
|
72
|
+
}
|
|
73
|
+
byRef.get(ref)!.push(problem);
|
|
74
|
+
}
|
|
75
|
+
return order.map((ref) => ({ ref, problems: byRef.get(ref)! }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sentence(problem: string): string {
|
|
79
|
+
const trimmed = problem.trim();
|
|
80
|
+
if (!trimmed) return trimmed;
|
|
81
|
+
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
|
|
82
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// packages/studio/src/chrome/commit-failure.tsx
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import * as stylex from "@stylexjs/stylex";
|
|
4
|
+
import type { CommitErrorView } from "./commit-error";
|
|
5
|
+
import { colors, space, type } from "../styles/tokens.stylex";
|
|
6
|
+
|
|
7
|
+
const hover = "@media (hover: hover)";
|
|
8
|
+
|
|
9
|
+
/** The explanation inside the deploy window after a commit is refused. */
|
|
10
|
+
export function CommitFailureNotice({
|
|
11
|
+
failure,
|
|
12
|
+
renderRef,
|
|
13
|
+
}: {
|
|
14
|
+
failure: CommitErrorView;
|
|
15
|
+
renderRef?: (ref: string) => ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
if (failure.kind === "message") {
|
|
18
|
+
return (
|
|
19
|
+
<p role="alert" {...stylex.props(styles.line)}>
|
|
20
|
+
{failure.text}
|
|
21
|
+
</p>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div role="alert" {...stylex.props(styles.box)}>
|
|
27
|
+
<p {...stylex.props(styles.title)}>Can't commit yet</p>
|
|
28
|
+
<ul {...stylex.props(styles.list)}>
|
|
29
|
+
{failure.files.map((file) => (
|
|
30
|
+
<li key={file.ref} {...stylex.props(styles.item)}>
|
|
31
|
+
<span {...stylex.props(styles.ref)}>
|
|
32
|
+
{renderRef ? renderRef(file.ref) : file.ref}
|
|
33
|
+
</span>
|
|
34
|
+
{file.problems.map((problem, index) => (
|
|
35
|
+
<span key={`${problem}-${index}`} {...stylex.props(styles.problem)}>
|
|
36
|
+
{problem}
|
|
37
|
+
</span>
|
|
38
|
+
))}
|
|
39
|
+
</li>
|
|
40
|
+
))}
|
|
41
|
+
</ul>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const styles = stylex.create({
|
|
47
|
+
box: {
|
|
48
|
+
display: "flex",
|
|
49
|
+
flexDirection: "column",
|
|
50
|
+
gap: space.xs,
|
|
51
|
+
paddingInline: space.lg,
|
|
52
|
+
paddingBottom: space.sm,
|
|
53
|
+
},
|
|
54
|
+
title: {
|
|
55
|
+
margin: 0,
|
|
56
|
+
fontSize: type.xs,
|
|
57
|
+
fontWeight: 500,
|
|
58
|
+
color: colors.danger,
|
|
59
|
+
},
|
|
60
|
+
list: {
|
|
61
|
+
margin: 0,
|
|
62
|
+
padding: 0,
|
|
63
|
+
listStyle: "none",
|
|
64
|
+
display: "flex",
|
|
65
|
+
flexDirection: "column",
|
|
66
|
+
gap: space.sm,
|
|
67
|
+
maxHeight: "140px",
|
|
68
|
+
overflowY: "auto",
|
|
69
|
+
},
|
|
70
|
+
item: {
|
|
71
|
+
display: "flex",
|
|
72
|
+
flexDirection: "column",
|
|
73
|
+
gap: "2px",
|
|
74
|
+
minWidth: 0,
|
|
75
|
+
},
|
|
76
|
+
ref: {
|
|
77
|
+
display: "block",
|
|
78
|
+
minWidth: 0,
|
|
79
|
+
overflow: "hidden",
|
|
80
|
+
textOverflow: "ellipsis",
|
|
81
|
+
whiteSpace: "nowrap",
|
|
82
|
+
fontFamily: type.mono,
|
|
83
|
+
fontSize: type.xs,
|
|
84
|
+
color: colors.text,
|
|
85
|
+
},
|
|
86
|
+
problem: {
|
|
87
|
+
fontSize: type.xs,
|
|
88
|
+
lineHeight: 1.4,
|
|
89
|
+
color: colors.danger,
|
|
90
|
+
},
|
|
91
|
+
line: {
|
|
92
|
+
margin: 0,
|
|
93
|
+
paddingInline: space.lg,
|
|
94
|
+
paddingBottom: space.sm,
|
|
95
|
+
fontSize: type.xs,
|
|
96
|
+
lineHeight: 1.4,
|
|
97
|
+
color: colors.danger,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const commitFailureLink = stylex.create({
|
|
102
|
+
link: {
|
|
103
|
+
color: "inherit",
|
|
104
|
+
textDecoration: "none",
|
|
105
|
+
[hover]: {
|
|
106
|
+
":hover": {
|
|
107
|
+
textDecoration: "underline",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// packages/studio/src/install-preview-pointer.ts
|
|
2
|
+
import { routePointerHit, type PointerArmSource, type RoutedGrab } from "@backmatter/core/preview";
|
|
3
|
+
import {
|
|
4
|
+
grabAt,
|
|
5
|
+
highlightPointedElement,
|
|
6
|
+
installSitePointer,
|
|
7
|
+
isOpenModifier,
|
|
8
|
+
type PointerGrab,
|
|
9
|
+
} from "./site-pointer";
|
|
10
|
+
|
|
11
|
+
export function installPreviewPointer(
|
|
12
|
+
doc: Document,
|
|
13
|
+
options: {
|
|
14
|
+
bar: () => Element | null;
|
|
15
|
+
ignore?: (target: EventTarget | null) => boolean;
|
|
16
|
+
onEditorGrab: (grab: RoutedGrab) => void;
|
|
17
|
+
onDevbarGrab: (grab: PointerGrab) => void;
|
|
18
|
+
onEscape?: (source: PointerArmSource) => void;
|
|
19
|
+
},
|
|
20
|
+
): {
|
|
21
|
+
arm: (source: PointerArmSource) => void;
|
|
22
|
+
disarm: () => void;
|
|
23
|
+
source: () => PointerArmSource | null;
|
|
24
|
+
dispose: () => void;
|
|
25
|
+
} {
|
|
26
|
+
let source: PointerArmSource | null = null;
|
|
27
|
+
|
|
28
|
+
function deliver(grab: PointerGrab, shift: boolean) {
|
|
29
|
+
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);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pointer = installSitePointer(doc, {
|
|
35
|
+
bar: options.bar,
|
|
36
|
+
ignore: options.ignore,
|
|
37
|
+
onGrab: (grab, shift) => deliver(grab, shift),
|
|
38
|
+
onEscape: () => {
|
|
39
|
+
const armed = source;
|
|
40
|
+
source = null;
|
|
41
|
+
if (armed) options.onEscape?.(armed);
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let under: Element | null = null;
|
|
46
|
+
|
|
47
|
+
function elementFrom(raw: EventTarget | null): Element | null {
|
|
48
|
+
if (!raw || typeof raw !== "object" || !("nodeType" in raw) || raw.nodeType !== 1) return null;
|
|
49
|
+
return raw as Element;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function markedBlock(target: Element | null): Element | null {
|
|
53
|
+
if (!target || options.ignore?.(target)) return null;
|
|
54
|
+
const marker = target.closest("[data-backmatter-block]");
|
|
55
|
+
if (!marker?.getAttribute("data-backmatter-block")) return null;
|
|
56
|
+
return marker;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function paint(held: boolean) {
|
|
60
|
+
highlightPointedElement(doc, held ? markedBlock(under) : null, { scroll: false });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onMove(event: PointerEvent) {
|
|
64
|
+
under = elementFrom(event.target);
|
|
65
|
+
paint(isOpenModifier(event));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function onModifier(event: KeyboardEvent) {
|
|
69
|
+
if (event.key !== "Meta" && event.key !== "Control") return;
|
|
70
|
+
paint(event.type === "keydown" || isOpenModifier(event));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function onOpen(event: MouseEvent) {
|
|
74
|
+
if (!isOpenModifier(event) || source !== null) return;
|
|
75
|
+
const target = elementFrom(event.target);
|
|
76
|
+
const grab = grabAt(target, options.bar());
|
|
77
|
+
if (!grab?.blockId) return;
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
event.stopPropagation();
|
|
80
|
+
deliver(grab, true);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function onBlur() {
|
|
84
|
+
paint(false);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const view = doc.defaultView;
|
|
88
|
+
doc.addEventListener("pointermove", onMove, true);
|
|
89
|
+
doc.addEventListener("keydown", onModifier, true);
|
|
90
|
+
doc.addEventListener("keyup", onModifier, true);
|
|
91
|
+
doc.addEventListener("click", onOpen, true);
|
|
92
|
+
view?.addEventListener("blur", onBlur);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
arm(next) {
|
|
96
|
+
source = next;
|
|
97
|
+
pointer.arm();
|
|
98
|
+
},
|
|
99
|
+
disarm() {
|
|
100
|
+
source = null;
|
|
101
|
+
pointer.disarm();
|
|
102
|
+
},
|
|
103
|
+
source: () => source,
|
|
104
|
+
dispose() {
|
|
105
|
+
doc.removeEventListener("pointermove", onMove, true);
|
|
106
|
+
doc.removeEventListener("keydown", onModifier, true);
|
|
107
|
+
doc.removeEventListener("keyup", onModifier, true);
|
|
108
|
+
doc.removeEventListener("click", onOpen, true);
|
|
109
|
+
view?.removeEventListener("blur", onBlur);
|
|
110
|
+
highlightPointedElement(doc, null);
|
|
111
|
+
source = null;
|
|
112
|
+
under = null;
|
|
113
|
+
pointer.dispose();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|