@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.
Files changed (67) hide show
  1. package/dist/chunks/{UiKitApp-fYeVEf4-.js → UiKitApp-_EDqWID3.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-Dt7JvEWT.js → WorkspaceRoutes-B2PrFgpz.js} +29019 -27476
  3. package/dist/chunks/{dashboard-BuY2aGkO.js → dashboard-BnlnMnnr.js} +55 -54
  4. package/dist/chunks/{index-NWDqEZYW.js → index-D_BHF_8q.js} +1 -1
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +51 -52
  7. package/dist/site-widget.css +4 -0
  8. package/dist/site-widget.js +9419 -9156
  9. package/dist/studio.css +186 -12
  10. package/package.json +2 -2
  11. package/src/WorkspaceRoutes.tsx +5 -2
  12. package/src/api.ts +64 -11
  13. package/src/chat/ChangeCard.tsx +9 -15
  14. package/src/chat/ChatMessage.tsx +172 -103
  15. package/src/chat/ChatPanel.tsx +54 -34
  16. package/src/chat/ChatProvider.tsx +120 -24
  17. package/src/chat/Composer.tsx +48 -7
  18. package/src/chat/DocListCard.tsx +2 -16
  19. package/src/chat/MentionPopover.tsx +8 -9
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +74 -10
  23. package/src/chat/chat-helpers.ts +15 -8
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +14 -10
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/UserMenu.tsx +34 -3
  29. package/src/chrome/WorkspaceStatusBar.tsx +215 -50
  30. package/src/chrome/content-api-url.ts +0 -78
  31. package/src/chrome/content-tree-visibility.ts +13 -3
  32. package/src/chrome/explorer-icons.tsx +5 -0
  33. package/src/code-session-entry.ts +9 -0
  34. package/src/components/WorkspaceSwitcher.tsx +2 -2
  35. package/src/element-origin.ts +149 -0
  36. package/src/host-pointer-bootstrap.ts +11 -0
  37. package/src/host-pointer.ts +147 -0
  38. package/src/install-preview-pointer.ts +19 -3
  39. package/src/pages/CodeSessionFiles.tsx +221 -0
  40. package/src/pages/CodeSessionPage.tsx +1188 -0
  41. package/src/pages/DocEditorPage.tsx +33 -15
  42. package/src/pages/EmbedChatPage.tsx +3 -3
  43. package/src/pages/EmbedPublishPage.tsx +2 -3
  44. package/src/pages/PreviewSize.tsx +181 -0
  45. package/src/pages/SettingsGeneralPage.tsx +67 -2
  46. package/src/pages/WorkspaceHomePage.tsx +313 -0
  47. package/src/pages/WorkspaceLayout.tsx +10 -1
  48. package/src/pages/code-session-boot.ts +43 -0
  49. package/src/pages/code-session-context.tsx +51 -0
  50. package/src/pages/code-session-files.ts +42 -0
  51. package/src/pages/code-session-frame.ts +52 -0
  52. package/src/pages/code-session-routes.ts +42 -0
  53. package/src/pages/home-rows.ts +140 -0
  54. package/src/pages/preview-size.ts +33 -0
  55. package/src/pages/session-preview.ts +109 -0
  56. package/src/pages/type-docs.ts +3 -6
  57. package/src/pages/use-session-preview.ts +70 -0
  58. package/src/replica/store.ts +13 -10
  59. package/src/replica/types.ts +7 -2
  60. package/src/site-pointer.ts +24 -5
  61. package/src/site-widget.tsx +2 -3
  62. package/src/studio-config.ts +1 -1
  63. package/src/ui/ListRow.tsx +12 -3
  64. package/src/ui/Page.tsx +12 -1
  65. package/src/workspace-path.ts +35 -3
  66. package/src/chrome/ContentApiPopover.tsx +0 -215
  67. 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,10 +19,12 @@ 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
29
  commitErrorSummary,
26
30
  explainCommitError,
@@ -33,8 +37,8 @@ import { WorkspaceSwitcher } from "../components/WorkspaceSwitcher";
33
37
  import { colors, radius, space, type } from "../styles/tokens.stylex";
34
38
  import { cx, popup, popupClassName } from "../ui/popup";
35
39
  import { useEditorStatus } from "./EditorStatusContext";
36
- import { ContentApiPopover } from "./ContentApiPopover";
37
- import { OpenSiteButton } from "./OpenSiteButton";
40
+ import { openableSites } from "./open-site";
41
+ import { OpenSiteButton, showsOpenSite } from "./OpenSiteButton";
38
42
  import { GitBranchIcon } from "./icons";
39
43
  import { StatusBar, StatusItem, StatusSpacer } from "./StatusBar";
40
44
  import { replicaStore } from "../replica/instance";
@@ -56,12 +60,11 @@ export const STATUS_LABEL: Record<ChangeStatus, string> = {
56
60
  };
57
61
 
58
62
  function changeKey(doc: GitChange) {
59
- return `${doc.locale}/${doc.type}/${doc.slug}`;
63
+ return `${doc.type}/${doc.slug}`;
60
64
  }
61
65
 
62
66
  function ConflictResolvePanel({
63
67
  workspaceSlug,
64
- defaultLocale,
65
68
  conflicts,
66
69
  resolvingKey,
67
70
  error,
@@ -69,7 +72,6 @@ function ConflictResolvePanel({
69
72
  onResolve,
70
73
  }: {
71
74
  workspaceSlug: string | null;
72
- defaultLocale: string;
73
75
  conflicts: GitChange[];
74
76
  resolvingKey: string | null;
75
77
  error: string | null;
@@ -88,13 +90,7 @@ function ConflictResolvePanel({
88
90
  {conflicts.map((doc) => {
89
91
  const key = changeKey(doc);
90
92
  const href = workspaceSlug
91
- ? contentDocPath(
92
- workspaceSlug,
93
- doc.type,
94
- doc.slug,
95
- doc.locale,
96
- defaultLocale,
97
- )
93
+ ? contentDocPath(workspaceSlug, doc.type, doc.slug)
98
94
  : null;
99
95
  return (
100
96
  <li key={key} {...stylex.props(styles.conflictRow)}>
@@ -107,7 +103,6 @@ function ConflictResolvePanel({
107
103
  <span {...stylex.props(styles.changeName)}>
108
104
  {doc.type}/{doc.slug}
109
105
  </span>
110
- <span {...stylex.props(popup.itemMeta)}>{doc.locale}</span>
111
106
  </Link>
112
107
  ) : (
113
108
  <span {...stylex.props(styles.changeName)}>
@@ -178,6 +173,15 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
178
173
  const [message, setMessage] = useState("");
179
174
  const [error, setError] = useState<string | null>(null);
180
175
  const [commitFailure, setCommitFailure] = useState<CommitErrorView | null>(null);
176
+ const onCodePage = isWorkspaceCodePath(location.pathname);
177
+ const codeSession = useCodeSessionContext();
178
+ const codeWorkspaceId = workspaceNav?.workspaceId ?? workspaceId;
179
+ const [codeFiles, setCodeFiles] = useState<Array<{ path: string; deleted: boolean }>>([]);
180
+ const [foldPending, setFoldPending] = useState(false);
181
+ const [codeFoldErrors, setCodeFoldErrors] = useState<string[]>([]);
182
+ const [codeCommitOpen, setCodeCommitOpen] = useState(false);
183
+ const [codeMessage, setCodeMessage] = useState("");
184
+ const [codeCommitting, setCodeCommitting] = useState(false);
181
185
  // Commit sha from the last poll. A new sha means HEAD moved. The preview
182
186
  // iframe does not reload for that; its page follows the SSE head event.
183
187
  const lastHead = useRef<string | null | undefined>(undefined);
@@ -201,6 +205,35 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
201
205
  }
202
206
  }
203
207
 
208
+ useEffect(() => {
209
+ if (!onCodePage || !codeSession) {
210
+ setCodeFiles([]);
211
+ return;
212
+ }
213
+ let cancelled = false;
214
+ const load = () => {
215
+ void fetchCodeSessionEdits(codeWorkspaceId, codeSession.sessionId)
216
+ .then((list) => {
217
+ if (!cancelled) {
218
+ setCodeFiles(list.files);
219
+ setFoldPending(list.foldPending);
220
+ }
221
+ })
222
+ .catch(() => {
223
+ if (!cancelled) {
224
+ setCodeFiles([]);
225
+ setFoldPending(false);
226
+ }
227
+ });
228
+ };
229
+ load();
230
+ const timer = window.setInterval(load, POLL_MS);
231
+ return () => {
232
+ cancelled = true;
233
+ window.clearInterval(timer);
234
+ };
235
+ }, [onCodePage, codeSession, codeWorkspaceId]);
236
+
204
237
  useEffect(() => {
205
238
  lastHead.current = undefined;
206
239
  void reloadGit();
@@ -293,10 +326,9 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
293
326
  setResolvingKey(key);
294
327
  setError(null);
295
328
  try {
296
- await resolveDoc(workspaceId, doc.locale, doc.type, doc.slug, strategy);
329
+ await resolveDoc(workspaceId, doc.type, doc.slug, strategy);
297
330
  notifyTreeChanged({
298
331
  workspaceId,
299
- locale: doc.locale,
300
332
  type: doc.type,
301
333
  slug: doc.slug,
302
334
  status: strategy === "theirs" ? "clean" : "modified",
@@ -314,6 +346,46 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
314
346
  }
315
347
  }
316
348
 
349
+ function defaultCodeSessionMessage(files: Array<{ path: string }>) {
350
+ if (files.length === 1) return `Update ${files[0]!.path}`;
351
+ return `Commit ${files.length} files from a code session`;
352
+ }
353
+
354
+ async function onCodeSessionCommit(e?: React.FormEvent) {
355
+ e?.preventDefault();
356
+ if (!codeSession || codeCommitting) return;
357
+ setCodeCommitting(true);
358
+ setError(null);
359
+ try {
360
+ const result = await commitCodeSessionEdits(
361
+ codeWorkspaceId,
362
+ codeSession.sessionId,
363
+ codeMessage,
364
+ );
365
+ setCodeMessage("");
366
+ setCodeFoldErrors(result.foldErrors);
367
+ if (result.foldErrors.length === 0) setCodeCommitOpen(false);
368
+ codeSession.refresh();
369
+ const list = await fetchCodeSessionEdits(codeWorkspaceId, codeSession.sessionId);
370
+ setCodeFiles(list.files);
371
+ setFoldPending(list.foldPending);
372
+ notifyHeadChanged();
373
+ await reloadGit();
374
+ } catch (err) {
375
+ const apiErr = err as ApiError;
376
+ if (apiErr.code === "branch_moved") {
377
+ setError("The branch changed while committing. Try again.");
378
+ } else if (apiErr.code === "working_tree") {
379
+ const paths = apiErr.details?.length ? apiErr.details.join(", ") : "";
380
+ setError(paths ? `Uncommitted Studio changes: ${paths}` : "Uncommitted Studio changes block this commit.");
381
+ } else {
382
+ setError(err instanceof Error ? err.message : "Commit failed");
383
+ }
384
+ } finally {
385
+ setCodeCommitting(false);
386
+ }
387
+ }
388
+
317
389
  async function onCommit(e?: React.FormEvent) {
318
390
  e?.preventDefault();
319
391
  if (committing || conflictCount > 0 || changeCount === 0) return;
@@ -341,20 +413,27 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
341
413
  const slug = ref.slice(slash + 1);
342
414
  const doc = changes.find((change) => change.type === type && change.slug === slug);
343
415
  if (!doc) return null;
344
- return contentDocPath(
345
- workspaceNav.workspaceSlug,
346
- doc.type,
347
- doc.slug,
348
- doc.locale,
349
- workspaceNav.defaultLocale,
350
- );
416
+ return contentDocPath(workspaceNav.workspaceSlug, doc.type, doc.slug);
351
417
  }
352
418
 
353
419
  const commitLabel = committing
354
420
  ? "Committing…"
355
421
  : conflictCount > 0
356
422
  ? `Resolve ${conflictCount} ${conflictCount === 1 ? "conflict" : "conflicts"}`
357
- : `Deploy ${changeCount} changes`;
423
+ : changeCount === 0
424
+ ? "Deploy"
425
+ : `Deploy ${changeCount} changes`;
426
+
427
+ const codeEditCount =
428
+ codeFiles.length > 0 ? codeFiles.length : (codeSession?.editCount ?? 0);
429
+ const codeCommitReady =
430
+ Boolean(codeSession) &&
431
+ (codeEditCount > 0 || codeFoldErrors.length > 0 || foldPending);
432
+ const codeCommitLabel = codeCommitting
433
+ ? "Committing…"
434
+ : codeEditCount > 0
435
+ ? `Commit ${codeEditCount} ${codeEditCount === 1 ? "file" : "files"}`
436
+ : "Commit";
358
437
 
359
438
  return (
360
439
  <StatusBar>
@@ -444,7 +523,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
444
523
  <div {...stylex.props(styles.changes)}>
445
524
  <p {...stylex.props(popup.groupLabel)}>Uncommitted</p>
446
525
  {changes.map((doc) => (
447
- <ChangeRow key={`${doc.locale}/${doc.type}/${doc.slug}`} doc={doc} />
526
+ <ChangeRow key={changeKey(doc)} doc={doc} />
448
527
  ))}
449
528
  </div>
450
529
  </>
@@ -494,27 +573,21 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
494
573
  </Combobox.Positioner>
495
574
  </Combobox.Portal>
496
575
  </Combobox.Root>
497
- {workspaceNav?.workspaceId ? (
576
+ {workspaceNav?.workspaceId &&
577
+ replica.sessionUserId &&
578
+ showsOpenSite() &&
579
+ openableSites(workspaceNav.siteUrls).length > 0 ? (
498
580
  <>
499
581
  <span {...stylex.props(styles.divider)} aria-hidden />
500
- <ContentApiPopover
582
+ <OpenSiteButton
501
583
  workspaceId={workspaceNav.workspaceId}
502
- branch={git?.currentBranch}
503
- defaultBranch={git?.defaultBranch}
504
- pathname={location.pathname}
505
- search={location.search}
584
+ userId={replica.sessionUserId}
585
+ siteUrls={workspaceNav.siteUrls}
506
586
  />
507
- {replica.sessionUserId ? (
508
- <OpenSiteButton
509
- workspaceId={workspaceNav.workspaceId}
510
- userId={replica.sessionUserId}
511
- siteUrls={workspaceNav.siteUrls}
512
- />
513
- ) : null}
514
587
  </>
515
588
  ) : null}
516
589
  </div>
517
- {changeCount > 0 ? (
590
+ {!onCodePage && changeCount > 0 ? (
518
591
  <StatusItem tone={conflictCount > 0 ? "danger" : "muted"}>
519
592
  {changeCount} uncommitted
520
593
  </StatusItem>
@@ -530,24 +603,117 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
530
603
  {inEditor && editor.saveLabel ? (
531
604
  <StatusItem tone={editor.saveTone}>{editor.saveLabel}</StatusItem>
532
605
  ) : null}
533
- {changeCount > 0 ? (
606
+ {onCodePage ? (
607
+ <Popover.Root
608
+ open={codeCommitOpen}
609
+ onOpenChange={(next) => {
610
+ if (!codeCommitReady) return;
611
+ setCodeCommitOpen(next);
612
+ }}
613
+ >
614
+ <Popover.Trigger
615
+ disabled={!codeCommitReady || codeCommitting}
616
+ aria-label={codeCommitLabel}
617
+ className={cx(styles.commit)}
618
+ >
619
+ {codeCommitLabel}
620
+ </Popover.Trigger>
621
+ <Popover.Portal>
622
+ <Popover.Positioner
623
+ side="bottom"
624
+ align="end"
625
+ sideOffset={6}
626
+ className={cx(popup.positioner)}
627
+ >
628
+ <Popover.Popup
629
+ aria-label="Commit code session"
630
+ className={(state) => popupClassName(state, styles.commitPopup)}
631
+ >
632
+ <form
633
+ onSubmit={(e) => void onCodeSessionCommit(e)}
634
+ {...stylex.props(styles.commitForm)}
635
+ >
636
+ <p {...stylex.props(popup.groupLabel)}>
637
+ {codeEditCount} {codeEditCount === 1 ? "file" : "files"} →{" "}
638
+ {git?.currentBranch}
639
+ </p>
640
+ <ul {...stylex.props(styles.commitFiles)}>
641
+ {codeFiles.map((file) => (
642
+ <li key={file.path}>
643
+ <div {...stylex.props(styles.changeItem)}>
644
+ <span {...stylex.props(styles.changeName)}>{file.path}</span>
645
+ <span {...stylex.props(popup.itemMeta)}>
646
+ {file.deleted ? "deleted" : "modified"}
647
+ </span>
648
+ </div>
649
+ </li>
650
+ ))}
651
+ </ul>
652
+ {codeFoldErrors.length > 0 ? (
653
+ <ul {...stylex.props(styles.commitFiles)}>
654
+ {codeFoldErrors.map((foldError) => (
655
+ <li key={foldError}>
656
+ <p {...stylex.props(styles.conflictError)}>{foldError}</p>
657
+ </li>
658
+ ))}
659
+ </ul>
660
+ ) : null}
661
+ <div {...stylex.props(popup.separator)} />
662
+ <div {...stylex.props(styles.commitFooter)}>
663
+ <input
664
+ value={codeMessage}
665
+ onChange={(e) => setCodeMessage(e.target.value)}
666
+ placeholder={defaultCodeSessionMessage(codeFiles)}
667
+ aria-label="Commit message"
668
+ autoComplete="off"
669
+ spellCheck={false}
670
+ data-no-ring
671
+ data-1p-ignore
672
+ data-lpignore
673
+ autoFocus
674
+ disabled={codeCommitting}
675
+ {...stylex.props(styles.commitField)}
676
+ />
677
+ <div {...stylex.props(styles.commitSubmit)}>
678
+ <button
679
+ type="submit"
680
+ disabled={codeCommitting}
681
+ {...stylex.props(styles.commitSubmitBtn)}
682
+ >
683
+ {codeCommitting ? "Committing…" : "Commit"}
684
+ </button>
685
+ </div>
686
+ </div>
687
+ </form>
688
+ </Popover.Popup>
689
+ </Popover.Positioner>
690
+ </Popover.Portal>
691
+ </Popover.Root>
692
+ ) : null}
693
+ {!onCodePage ? (
534
694
  <Popover.Root
535
695
  open={commitOpen}
536
696
  onOpenChange={(next) => {
537
- if (action === "blocked") return;
697
+ if (changeCount === 0 || action === "blocked") return;
538
698
  setCommitOpen(next);
539
699
  }}
540
700
  >
541
701
  <Popover.Trigger
542
- disabled={commitTriggerDisabled(action, { committing, syncing })}
702
+ disabled={
703
+ changeCount === 0 || commitTriggerDisabled(action, { committing, syncing })
704
+ }
543
705
  aria-label={
544
- action === "blocked"
545
- ? "Writers cannot publish"
546
- : action === "resolve"
547
- ? commitLabel
548
- : `Commit ${changeCount} ${changeCount === 1 ? "file" : "files"}`
706
+ changeCount === 0
707
+ ? "Deploy"
708
+ : action === "blocked"
709
+ ? "Writers cannot publish"
710
+ : action === "resolve"
711
+ ? commitLabel
712
+ : `Commit ${changeCount} ${changeCount === 1 ? "file" : "files"}`
713
+ }
714
+ title={
715
+ changeCount > 0 && action === "blocked" ? "Writers cannot publish" : undefined
549
716
  }
550
- title={action === "blocked" ? "Writers cannot publish" : undefined}
551
717
  className={cx(styles.commit, action === "resolve" && styles.commitBlocked)}
552
718
  >
553
719
  {commitLabel}
@@ -566,7 +732,6 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
566
732
  {action === "resolve" ? (
567
733
  <ConflictResolvePanel
568
734
  workspaceSlug={workspaceNav?.workspaceSlug ?? null}
569
- defaultLocale={workspaceNav?.defaultLocale ?? "en"}
570
735
  conflicts={conflicts}
571
736
  resolvingKey={resolvingKey}
572
737
  error={error}
@@ -598,7 +763,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
598
763
  ) : null}
599
764
  <ul {...stylex.props(styles.commitFiles)}>
600
765
  {changes.map((doc) => (
601
- <li key={`${doc.locale}/${doc.type}/${doc.slug}`}>
766
+ <li key={changeKey(doc)}>
602
767
  <ChangeRow
603
768
  doc={doc}
604
769
  invalid={
@@ -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,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.