@backmatter/studio 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-D7vllRQE.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-DH1LD-2Q.js} +30013 -27965
  3. package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-Dsaeud2x.js} +55 -54
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-DHJvh5Uy.js → index-CGMqw2NH.js} +1 -1
  6. package/dist/dashboard.js +2 -2
  7. package/dist/define.js +1 -1
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.css +49 -1
  10. package/dist/site-widget.js +8341 -8058
  11. package/dist/studio.css +279 -13
  12. package/package.json +2 -2
  13. package/src/WorkspaceRoutes.tsx +5 -2
  14. package/src/api.ts +82 -7
  15. package/src/chat/ChangeCard.tsx +8 -2
  16. package/src/chat/ChatMessage.tsx +171 -102
  17. package/src/chat/ChatPanel.tsx +54 -34
  18. package/src/chat/ChatProvider.tsx +122 -20
  19. package/src/chat/Composer.tsx +51 -8
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +67 -3
  23. package/src/chat/chat-helpers.ts +12 -0
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +12 -0
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/Toast.tsx +115 -0
  29. package/src/chrome/UserMenu.tsx +34 -3
  30. package/src/chrome/WorkspaceStatusBar.tsx +221 -30
  31. package/src/chrome/commit-error.ts +13 -0
  32. package/src/chrome/content-api-url.ts +0 -78
  33. package/src/chrome/content-tree-visibility.ts +13 -3
  34. package/src/chrome/explorer-icons.tsx +5 -0
  35. package/src/chrome/pull-failure.ts +61 -0
  36. package/src/code-session-entry.ts +9 -0
  37. package/src/components/WorkspaceSwitcher.tsx +2 -2
  38. package/src/element-origin.ts +149 -0
  39. package/src/fields/FieldControl.tsx +748 -0
  40. package/src/fields/choose-field-input.ts +64 -0
  41. package/src/fields/load-relation-documents.ts +35 -0
  42. package/src/fields/omit-field.ts +12 -0
  43. package/src/fields/relation-targets.ts +44 -0
  44. package/src/fields/resolve-object-fields.ts +21 -0
  45. package/src/host-pointer-bootstrap.ts +11 -0
  46. package/src/host-pointer.ts +147 -0
  47. package/src/install-preview-pointer.ts +19 -3
  48. package/src/pages/BlockSettingsDialog.tsx +19 -115
  49. package/src/pages/BlocksList.tsx +43 -35
  50. package/src/pages/BodyProse.tsx +16 -1
  51. package/src/pages/CodeSessionFiles.tsx +221 -0
  52. package/src/pages/CodeSessionPage.tsx +1361 -0
  53. package/src/pages/DocEditorPage.tsx +120 -107
  54. package/src/pages/PreviewSize.tsx +181 -0
  55. package/src/pages/RenderedBody.tsx +17 -2
  56. package/src/pages/SettingsGeneralPage.tsx +67 -2
  57. package/src/pages/WorkspaceHomePage.tsx +313 -0
  58. package/src/pages/WorkspaceLayout.tsx +10 -1
  59. package/src/pages/branch-notice.ts +16 -0
  60. package/src/pages/code-session-boot.ts +43 -0
  61. package/src/pages/code-session-context.tsx +53 -0
  62. package/src/pages/code-session-files.ts +42 -0
  63. package/src/pages/code-session-frame.ts +52 -0
  64. package/src/pages/code-session-routes.ts +42 -0
  65. package/src/pages/home-rows.ts +140 -0
  66. package/src/pages/preview-size.ts +33 -0
  67. package/src/pages/session-preview.ts +109 -0
  68. package/src/pages/use-session-preview.ts +70 -0
  69. package/src/replica/store.ts +11 -0
  70. package/src/replica/types.ts +6 -0
  71. package/src/shell-resets.css +15 -0
  72. package/src/site-pointer.ts +24 -5
  73. package/src/studio-config.ts +1 -1
  74. package/src/ui/Input.tsx +178 -20
  75. package/src/ui/ListRow.tsx +12 -3
  76. package/src/ui/Page.tsx +12 -1
  77. package/src/ui/popup.ts +11 -2
  78. package/src/workspace-path.ts +35 -3
  79. package/src/chrome/ContentApiPopover.tsx +0 -215
  80. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -7,9 +7,11 @@ import { Popover } from "@base-ui/react/popover";
7
7
  import * as stylex from "@stylexjs/stylex";
8
8
  import {
9
9
  commitWorkspace,
10
+ commitCodeSessionEdits,
10
11
  countConflicts,
11
12
  createGitBranch,
12
13
  defaultCommitMessage,
14
+ fetchCodeSessionEdits,
13
15
  fetchGit,
14
16
  notifyHeadChanged,
15
17
  notifyTreeChanged,
@@ -17,12 +19,13 @@ import {
17
19
  notifyWorkspaceChanged,
18
20
  pullFromGithub,
19
21
  resolveDoc,
22
+ type ApiError,
20
23
  type ChangeStatus,
21
24
  type GitChange,
22
25
  } from "../api";
23
- import { contentDocPath } from "../workspace-path";
26
+ import { contentDocPath, isWorkspaceCodePath } from "../workspace-path";
27
+ import { useCodeSessionContext } from "../pages/code-session-context";
24
28
  import {
25
- commitErrorSummary,
26
29
  explainCommitError,
27
30
  problemsForRef,
28
31
  type CommitErrorView,
@@ -33,10 +36,12 @@ import { WorkspaceSwitcher } from "../components/WorkspaceSwitcher";
33
36
  import { colors, radius, space, type } from "../styles/tokens.stylex";
34
37
  import { cx, popup, popupClassName } from "../ui/popup";
35
38
  import { useEditorStatus } from "./EditorStatusContext";
36
- import { ContentApiPopover } from "./ContentApiPopover";
37
- import { OpenSiteButton } from "./OpenSiteButton";
39
+ import { openableSites } from "./open-site";
40
+ import { OpenSiteButton, showsOpenSite } from "./OpenSiteButton";
38
41
  import { GitBranchIcon } from "./icons";
42
+ import { pullFailureProblems, type PullProblem } from "./pull-failure";
39
43
  import { StatusBar, StatusItem, StatusSpacer } from "./StatusBar";
44
+ import { Toast } from "./Toast";
40
45
  import { replicaStore } from "../replica/instance";
41
46
  import { useReplicaSession } from "../replica/ReplicaProvider";
42
47
  import { useReplicaState } from "../replica/useReplica";
@@ -168,7 +173,17 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
168
173
  const [resolvingKey, setResolvingKey] = useState<string | null>(null);
169
174
  const [message, setMessage] = useState("");
170
175
  const [error, setError] = useState<string | null>(null);
176
+ const [pullProblems, setPullProblems] = useState<PullProblem[] | null>(null);
171
177
  const [commitFailure, setCommitFailure] = useState<CommitErrorView | null>(null);
178
+ const onCodePage = isWorkspaceCodePath(location.pathname);
179
+ const codeSession = useCodeSessionContext();
180
+ const codeWorkspaceId = workspaceNav?.workspaceId ?? workspaceId;
181
+ const [codeFiles, setCodeFiles] = useState<Array<{ path: string; deleted: boolean }>>([]);
182
+ const [foldPending, setFoldPending] = useState(false);
183
+ const [codeFoldErrors, setCodeFoldErrors] = useState<string[]>([]);
184
+ const [codeCommitOpen, setCodeCommitOpen] = useState(false);
185
+ const [codeMessage, setCodeMessage] = useState("");
186
+ const [codeCommitting, setCodeCommitting] = useState(false);
172
187
  // Commit sha from the last poll. A new sha means HEAD moved. The preview
173
188
  // iframe does not reload for that; its page follows the SSE head event.
174
189
  const lastHead = useRef<string | null | undefined>(undefined);
@@ -192,6 +207,35 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
192
207
  }
193
208
  }
194
209
 
210
+ useEffect(() => {
211
+ if (!onCodePage || !codeSession) {
212
+ setCodeFiles([]);
213
+ return;
214
+ }
215
+ let cancelled = false;
216
+ const load = () => {
217
+ void fetchCodeSessionEdits(codeWorkspaceId, codeSession.sessionId)
218
+ .then((list) => {
219
+ if (!cancelled) {
220
+ setCodeFiles(list.files);
221
+ setFoldPending(list.foldPending);
222
+ }
223
+ })
224
+ .catch(() => {
225
+ if (!cancelled) {
226
+ setCodeFiles([]);
227
+ setFoldPending(false);
228
+ }
229
+ });
230
+ };
231
+ load();
232
+ const timer = window.setInterval(load, POLL_MS);
233
+ return () => {
234
+ cancelled = true;
235
+ window.clearInterval(timer);
236
+ };
237
+ }, [onCodePage, codeSession, codeWorkspaceId]);
238
+
195
239
  useEffect(() => {
196
240
  lastHead.current = undefined;
197
241
  void reloadGit();
@@ -264,13 +308,15 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
264
308
  async function onPull() {
265
309
  setPulling(true);
266
310
  setError(null);
311
+ setPullProblems(null);
267
312
  try {
268
313
  await pullFromGithub(workspaceId);
269
314
  notifyHeadChanged();
270
315
  await reloadGit();
271
316
  setOpen(false);
272
317
  } catch (err) {
273
- setError(err instanceof Error ? err.message : "Pull failed");
318
+ setOpen(false);
319
+ setPullProblems(pullFailureProblems(err));
274
320
  } finally {
275
321
  setPulling(false);
276
322
  }
@@ -304,6 +350,46 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
304
350
  }
305
351
  }
306
352
 
353
+ function defaultCodeSessionMessage(files: Array<{ path: string }>) {
354
+ if (files.length === 1) return `Update ${files[0]!.path}`;
355
+ return `Commit ${files.length} files from a code session`;
356
+ }
357
+
358
+ async function onCodeSessionCommit(e?: React.FormEvent) {
359
+ e?.preventDefault();
360
+ if (!codeSession || codeCommitting) return;
361
+ setCodeCommitting(true);
362
+ setError(null);
363
+ try {
364
+ const result = await commitCodeSessionEdits(
365
+ codeWorkspaceId,
366
+ codeSession.sessionId,
367
+ codeMessage,
368
+ );
369
+ setCodeMessage("");
370
+ setCodeFoldErrors(result.foldErrors);
371
+ if (result.foldErrors.length === 0) setCodeCommitOpen(false);
372
+ codeSession.refresh();
373
+ const list = await fetchCodeSessionEdits(codeWorkspaceId, codeSession.sessionId);
374
+ setCodeFiles(list.files);
375
+ setFoldPending(list.foldPending);
376
+ notifyHeadChanged();
377
+ await reloadGit();
378
+ } catch (err) {
379
+ const apiErr = err as ApiError;
380
+ if (apiErr.code === "branch_moved") {
381
+ setError("The branch changed while committing. Try again.");
382
+ } else if (apiErr.code === "working_tree") {
383
+ const paths = apiErr.details?.length ? apiErr.details.join(", ") : "";
384
+ setError(paths ? `Uncommitted Studio changes: ${paths}` : "Uncommitted Studio changes block this commit.");
385
+ } else {
386
+ setError(err instanceof Error ? err.message : "Commit failed");
387
+ }
388
+ } finally {
389
+ setCodeCommitting(false);
390
+ }
391
+ }
392
+
307
393
  async function onCommit(e?: React.FormEvent) {
308
394
  e?.preventDefault();
309
395
  if (committing || conflictCount > 0 || changeCount === 0) return;
@@ -338,9 +424,26 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
338
424
  ? "Committing…"
339
425
  : conflictCount > 0
340
426
  ? `Resolve ${conflictCount} ${conflictCount === 1 ? "conflict" : "conflicts"}`
341
- : `Deploy ${changeCount} changes`;
427
+ : changeCount === 0
428
+ ? "Deploy"
429
+ : `Deploy ${changeCount} changes`;
430
+
431
+ const codeEditCount =
432
+ codeFiles.length > 0 ? codeFiles.length : (codeSession?.editCount ?? 0);
433
+ const codeCommitReady =
434
+ Boolean(codeSession) &&
435
+ (codeEditCount > 0 || codeFoldErrors.length > 0 || foldPending);
436
+ const codeCommitLabel = codeCommitting
437
+ ? "Committing…"
438
+ : codeEditCount > 0
439
+ ? `Commit ${codeEditCount} ${codeEditCount === 1 ? "file" : "files"}`
440
+ : "Commit";
342
441
 
343
442
  return (
443
+ <>
444
+ {pullProblems ? (
445
+ <Toast title="Pull failed" problems={pullProblems} onDismiss={() => setPullProblems(null)} />
446
+ ) : null}
344
447
  <StatusBar>
345
448
  <div {...stylex.props(styles.identity)}>
346
449
  {workspaceNav ? (
@@ -478,27 +581,21 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
478
581
  </Combobox.Positioner>
479
582
  </Combobox.Portal>
480
583
  </Combobox.Root>
481
- {workspaceNav?.workspaceId ? (
584
+ {workspaceNav?.workspaceId &&
585
+ replica.sessionUserId &&
586
+ showsOpenSite() &&
587
+ openableSites(workspaceNav.siteUrls).length > 0 ? (
482
588
  <>
483
589
  <span {...stylex.props(styles.divider)} aria-hidden />
484
- <ContentApiPopover
590
+ <OpenSiteButton
485
591
  workspaceId={workspaceNav.workspaceId}
486
- branch={git?.currentBranch}
487
- defaultBranch={git?.defaultBranch}
488
- pathname={location.pathname}
489
- search={location.search}
592
+ userId={replica.sessionUserId}
593
+ siteUrls={workspaceNav.siteUrls}
490
594
  />
491
- {replica.sessionUserId ? (
492
- <OpenSiteButton
493
- workspaceId={workspaceNav.workspaceId}
494
- userId={replica.sessionUserId}
495
- siteUrls={workspaceNav.siteUrls}
496
- />
497
- ) : null}
498
595
  </>
499
596
  ) : null}
500
597
  </div>
501
- {changeCount > 0 ? (
598
+ {!onCodePage && changeCount > 0 ? (
502
599
  <StatusItem tone={conflictCount > 0 ? "danger" : "muted"}>
503
600
  {changeCount} uncommitted
504
601
  </StatusItem>
@@ -506,7 +603,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
506
603
  {error ? (
507
604
  <StatusItem tone="danger">{error}</StatusItem>
508
605
  ) : commitFailure ? (
509
- <StatusItem tone="danger">{commitErrorSummary(commitFailure)}</StatusItem>
606
+ <StatusItem tone="danger">Can't commit</StatusItem>
510
607
  ) : refreshError ? (
511
608
  <StatusItem tone="danger">{refreshError}</StatusItem>
512
609
  ) : null}
@@ -514,24 +611,117 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
514
611
  {inEditor && editor.saveLabel ? (
515
612
  <StatusItem tone={editor.saveTone}>{editor.saveLabel}</StatusItem>
516
613
  ) : null}
517
- {changeCount > 0 ? (
614
+ {onCodePage ? (
615
+ <Popover.Root
616
+ open={codeCommitOpen}
617
+ onOpenChange={(next) => {
618
+ if (!codeCommitReady) return;
619
+ setCodeCommitOpen(next);
620
+ }}
621
+ >
622
+ <Popover.Trigger
623
+ disabled={!codeCommitReady || codeCommitting}
624
+ aria-label={codeCommitLabel}
625
+ className={cx(styles.commit)}
626
+ >
627
+ {codeCommitLabel}
628
+ </Popover.Trigger>
629
+ <Popover.Portal>
630
+ <Popover.Positioner
631
+ side="bottom"
632
+ align="end"
633
+ sideOffset={6}
634
+ className={cx(popup.positioner)}
635
+ >
636
+ <Popover.Popup
637
+ aria-label="Commit code session"
638
+ className={(state) => popupClassName(state, styles.commitPopup)}
639
+ >
640
+ <form
641
+ onSubmit={(e) => void onCodeSessionCommit(e)}
642
+ {...stylex.props(styles.commitForm)}
643
+ >
644
+ <p {...stylex.props(popup.groupLabel)}>
645
+ {codeEditCount} {codeEditCount === 1 ? "file" : "files"} →{" "}
646
+ {git?.currentBranch}
647
+ </p>
648
+ <ul {...stylex.props(styles.commitFiles)}>
649
+ {codeFiles.map((file) => (
650
+ <li key={file.path}>
651
+ <div {...stylex.props(styles.changeItem)}>
652
+ <span {...stylex.props(styles.changeName)}>{file.path}</span>
653
+ <span {...stylex.props(popup.itemMeta)}>
654
+ {file.deleted ? "deleted" : "modified"}
655
+ </span>
656
+ </div>
657
+ </li>
658
+ ))}
659
+ </ul>
660
+ {codeFoldErrors.length > 0 ? (
661
+ <ul {...stylex.props(styles.commitFiles)}>
662
+ {codeFoldErrors.map((foldError) => (
663
+ <li key={foldError}>
664
+ <p {...stylex.props(styles.conflictError)}>{foldError}</p>
665
+ </li>
666
+ ))}
667
+ </ul>
668
+ ) : null}
669
+ <div {...stylex.props(popup.separator)} />
670
+ <div {...stylex.props(styles.commitFooter)}>
671
+ <input
672
+ value={codeMessage}
673
+ onChange={(e) => setCodeMessage(e.target.value)}
674
+ placeholder={defaultCodeSessionMessage(codeFiles)}
675
+ aria-label="Commit message"
676
+ autoComplete="off"
677
+ spellCheck={false}
678
+ data-no-ring
679
+ data-1p-ignore
680
+ data-lpignore
681
+ autoFocus
682
+ disabled={codeCommitting}
683
+ {...stylex.props(styles.commitField)}
684
+ />
685
+ <div {...stylex.props(styles.commitSubmit)}>
686
+ <button
687
+ type="submit"
688
+ disabled={codeCommitting}
689
+ {...stylex.props(styles.commitSubmitBtn)}
690
+ >
691
+ {codeCommitting ? "Committing…" : "Commit"}
692
+ </button>
693
+ </div>
694
+ </div>
695
+ </form>
696
+ </Popover.Popup>
697
+ </Popover.Positioner>
698
+ </Popover.Portal>
699
+ </Popover.Root>
700
+ ) : null}
701
+ {!onCodePage ? (
518
702
  <Popover.Root
519
703
  open={commitOpen}
520
704
  onOpenChange={(next) => {
521
- if (action === "blocked") return;
705
+ if (changeCount === 0 || action === "blocked") return;
522
706
  setCommitOpen(next);
523
707
  }}
524
708
  >
525
709
  <Popover.Trigger
526
- disabled={commitTriggerDisabled(action, { committing, syncing })}
710
+ disabled={
711
+ changeCount === 0 || commitTriggerDisabled(action, { committing, syncing })
712
+ }
527
713
  aria-label={
528
- action === "blocked"
529
- ? "Writers cannot publish"
530
- : action === "resolve"
531
- ? commitLabel
532
- : `Commit ${changeCount} ${changeCount === 1 ? "file" : "files"}`
714
+ changeCount === 0
715
+ ? "Deploy"
716
+ : action === "blocked"
717
+ ? "Writers cannot publish"
718
+ : action === "resolve"
719
+ ? commitLabel
720
+ : `Commit ${changeCount} ${changeCount === 1 ? "file" : "files"}`
721
+ }
722
+ title={
723
+ changeCount > 0 && action === "blocked" ? "Writers cannot publish" : undefined
533
724
  }
534
- title={action === "blocked" ? "Writers cannot publish" : undefined}
535
725
  className={cx(styles.commit, action === "resolve" && styles.commitBlocked)}
536
726
  >
537
727
  {commitLabel}
@@ -628,6 +818,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
628
818
  </Popover.Root>
629
819
  ) : null}
630
820
  </StatusBar>
821
+ </>
631
822
  );
632
823
  }
633
824
 
@@ -27,6 +27,9 @@ export function explainCommitError(err: unknown): CommitErrorView {
27
27
  if (apiErr.code === "github_not_bound") {
28
28
  return { kind: "message", text: "Connect GitHub first" };
29
29
  }
30
+ if (apiErr.code === "unknown_type") {
31
+ return { kind: "message", text: unknownTypeMessage(apiErr.details ?? []) };
32
+ }
30
33
  if (apiErr.code === "validation") {
31
34
  return { kind: "validation", files: groupValidation(validationLines(apiErr)) };
32
35
  }
@@ -52,6 +55,16 @@ export function problemsForRef(
52
55
  return view.files.find((file) => file.ref === ref)?.problems ?? [];
53
56
  }
54
57
 
58
+ function unknownTypeMessage(types: string[]): string {
59
+ if (types.length === 0) {
60
+ return "A file uses a model this workspace doesn't have, so it can't be committed.";
61
+ }
62
+ if (types.length === 1) {
63
+ return `${types[0]} isn't a model in this workspace. Pull couldn't load that model file, so this draft can't be committed.`;
64
+ }
65
+ return `${types.join(", ")} aren't models in this workspace. Pull couldn't load those model files, so these drafts can't be committed.`;
66
+ }
67
+
55
68
  function validationLines(err: ApiError): string[] {
56
69
  if (err.details && err.details.length > 0) return err.details;
57
70
  const body = err.message.replace(/^validation:\s*/, "");
@@ -1,48 +1,6 @@
1
1
  // packages/studio/src/chrome/content-api-url.ts
2
2
  // Public content GET the host reads — same URLs as @backmatter/core api-loader.
3
3
 
4
- export type ContentApiDoc = { type: string; slug: string };
5
-
6
- export type ContentApiContext = {
7
- doc: ContentApiDoc | null;
8
- type: string | null;
9
- };
10
-
11
- export function contentApiContextFromPath(
12
- pathname: string,
13
- _search = "",
14
- ): ContentApiContext {
15
- const doc = pathname.match(/\/content\/([^/]+)\/([^/]+)\/?$/);
16
- if (doc) {
17
- return {
18
- doc: {
19
- type: decodeURIComponent(doc[1]!),
20
- slug: decodeURIComponent(doc[2]!),
21
- },
22
- type: null,
23
- };
24
- }
25
- const type = pathname.match(/\/content\/([^/]+)\/?$/);
26
- if (type) {
27
- return { doc: null, type: decodeURIComponent(type[1]!) };
28
- }
29
- const legacyDoc = pathname.match(/\/docs\/([^/]+)\/([^/]+)\/([^/]+)\/?$/);
30
- if (legacyDoc) {
31
- return {
32
- doc: {
33
- type: decodeURIComponent(legacyDoc[2]!),
34
- slug: decodeURIComponent(legacyDoc[3]!),
35
- },
36
- type: null,
37
- };
38
- }
39
- const legacyType = pathname.match(/\/types\/([^/]+)\/?$/);
40
- if (legacyType) {
41
- return { doc: null, type: decodeURIComponent(legacyType[1]!) };
42
- }
43
- return { doc: null, type: null };
44
- }
45
-
46
4
  export function publicContentApiUrl(input: {
47
5
  apiUrl: string;
48
6
  workspaceId: string;
@@ -64,39 +22,3 @@ export function publicContentApiUrl(input: {
64
22
  if (!input.slug && input.type) url.searchParams.set("type", input.type);
65
23
  return url.toString();
66
24
  }
67
-
68
- export function contentApiLinks(input: {
69
- apiUrl: string;
70
- workspaceId: string;
71
- branch?: string | null;
72
- defaultBranch?: string | null;
73
- pathname: string;
74
- search?: string;
75
- }): Array<{ label: string; href: string }> {
76
- const ctx = contentApiContextFromPath(input.pathname, input.search);
77
- const base = {
78
- apiUrl: input.apiUrl,
79
- workspaceId: input.workspaceId,
80
- branch: input.branch,
81
- defaultBranch: input.defaultBranch,
82
- };
83
- const links: Array<{ label: string; href: string }> = [];
84
- if (ctx.doc) {
85
- links.push({
86
- label: "This document",
87
- href: publicContentApiUrl({
88
- ...base,
89
- type: ctx.doc.type,
90
- slug: ctx.doc.slug,
91
- }),
92
- });
93
- }
94
- links.push({
95
- label: ctx.type ? `${ctx.type} docs` : "All docs",
96
- href: publicContentApiUrl({
97
- ...base,
98
- type: ctx.type ?? undefined,
99
- }),
100
- });
101
- return links;
102
- }
@@ -1,21 +1,31 @@
1
1
  // packages/studio/src/chrome/content-tree-visibility.ts
2
2
  import { useEffect, useState } from "react";
3
3
  import type { KeyValueStore } from "../last-workspace";
4
- import { isWorkspaceContentPath, isWorkspaceEditorPath } from "../workspace-path";
4
+ import {
5
+ isWorkspaceContentPath,
6
+ isWorkspaceEditorPath,
7
+ isWorkspaceHomePath,
8
+ } from "../workspace-path";
5
9
 
6
10
  export const CONTENT_TREE_OPEN_KEY = "backmatter-content-tree-open";
7
11
  const CONTENT_TREE_EVENT = "backmatter-content-tree";
8
12
 
9
13
  /**
10
- * The type index is the file tree, so it stays open.
11
- * A document follows the editor toggle. Other routes have no tree.
14
+ * The content index and a type index keep the file tree open.
15
+ * A document follows the editor toggle. Home, settings, and media have no tree.
12
16
  */
13
17
  export function contentTreeVisible(pathname: string, open: boolean) {
18
+ if (isWorkspaceHomePath(pathname)) return false;
14
19
  if (!isWorkspaceContentPath(pathname)) return false;
15
20
  if (isWorkspaceEditorPath(pathname)) return open;
16
21
  return true;
17
22
  }
18
23
 
24
+ /** Content rail is current wherever the file tree is open. */
25
+ export function contentRailCurrent(pathname: string, treeOpen: boolean) {
26
+ return contentTreeVisible(pathname, treeOpen);
27
+ }
28
+
19
29
  export function readContentTreeOpen(storage: KeyValueStore = localStorage) {
20
30
  try {
21
31
  return storage.getItem(CONTENT_TREE_OPEN_KEY) !== "0";
@@ -7,6 +7,7 @@ import {
7
7
  IconChevronRightSmall,
8
8
  IconBrackets2,
9
9
  IconFileText,
10
+ IconCode,
10
11
  IconFiles,
11
12
  IconFolder1,
12
13
  IconGit,
@@ -70,6 +71,10 @@ export function ExplorerRailMediaIcon() {
70
71
  return <Central icon={IconImages1} size={RAIL} />;
71
72
  }
72
73
 
74
+ export function ExplorerRailCodeIcon() {
75
+ return <Central icon={IconCode} size={RAIL} />;
76
+ }
77
+
73
78
  export function ExplorerRailSettingsIcon() {
74
79
  return <Central icon={IconSettingsGear2} size={RAIL} />;
75
80
  }
@@ -0,0 +1,61 @@
1
+ // packages/studio/src/chrome/pull-failure.ts
2
+ import type { ApiError } from "../api";
3
+
4
+ export type PullProblem = {
5
+ /** Repo path, when the failure names a file. */
6
+ file: string;
7
+ text: string;
8
+ };
9
+
10
+ /** What the pull-failed notice says. One problem per file, in words. */
11
+ export function pullFailureProblems(err: unknown): PullProblem[] {
12
+ const lines = rawLines(err);
13
+ if (lines.length === 0) return [{ file: "", text: "Pull failed." }];
14
+ return lines.map(explainPullLine);
15
+ }
16
+
17
+ function rawLines(err: unknown): string[] {
18
+ const details = (err as ApiError).details;
19
+ if (Array.isArray(details) && details.length > 0 && details.every((line) => typeof line === "string")) {
20
+ return details;
21
+ }
22
+ if (err instanceof Error) {
23
+ const message = err.message.replace(/^github_import_failed:\s*/, "");
24
+ if (message && message !== "github_import_failed") return [message];
25
+ }
26
+ return [];
27
+ }
28
+
29
+ function explainPullLine(line: string): PullProblem {
30
+ const split = line.indexOf(": ");
31
+ const file = split === -1 ? "" : line.slice(0, split);
32
+ const rest = split === -1 ? line : line.slice(split + 2);
33
+
34
+ const renamed = rest.match(/defines name "([^"]+)" but the file is named "([^"]+)"/);
35
+ if (renamed) {
36
+ return {
37
+ file,
38
+ text: `The name is ${renamed[1]}, but the file is ${renamed[2]}. Rename one so they match.`,
39
+ };
40
+ }
41
+ if (/field\.locale/.test(rest)) {
42
+ return {
43
+ file,
44
+ text: "This file calls field.locale(), which was removed, so it didn't load.",
45
+ };
46
+ }
47
+ if (/import is not allowed/.test(rest)) {
48
+ return {
49
+ file,
50
+ text: "This file imports something other than defineModel, field, or an object, so it didn't load.",
51
+ };
52
+ }
53
+
54
+ const cleaned = rest
55
+ .replace(/^model "[^"]+" failed to evaluate:\s*/i, "")
56
+ .replace(/^object "[^"]+" failed to evaluate:\s*/i, "")
57
+ .replace(/TypeError:\s*/g, "")
58
+ .replace(/\s*\([^)]*\)/g, "")
59
+ .trim();
60
+ return { file, text: cleaned || "This file didn't load." };
61
+ }
@@ -0,0 +1,9 @@
1
+ // packages/studio/src/code-session-entry.ts
2
+ import { canDevelop } from "./workspace/permissions";
3
+ import { codeSessionPath } from "./workspace-path";
4
+
5
+ /** Where a developer opens a code session. Writers and editors get nothing. */
6
+ export function codeSessionEntry(role: string, slug: string): string | null {
7
+ if (!canDevelop(role)) return null;
8
+ return codeSessionPath(slug);
9
+ }
@@ -47,9 +47,9 @@ export function WorkspaceSwitcher({
47
47
  (id: string) => {
48
48
  setOpen(false);
49
49
  const next = workspaces.find((workspace) => workspace.id === id);
50
- if (next && id !== workspaceId) navigate(workspaceHomePath(next.slug));
50
+ if (next) navigate(workspaceHomePath(next.slug));
51
51
  },
52
- [navigate, workspaceId, workspaces],
52
+ [navigate, workspaces],
53
53
  );
54
54
 
55
55
  // Number keys 1–9 jump straight to a workspace while the menu is open.