@backmatter/studio 0.0.2 → 0.0.4

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 (36) hide show
  1. package/dist/chunks/EmbedPublishPage-hx1xoAhI.js +149 -0
  2. package/dist/chunks/{UiKitPage-CTZtLwJ0.js → UiKitPage-BOunq6mr.js} +12 -11
  3. package/dist/chunks/WorkspaceRoutes-CTFTWu3L.js +57731 -0
  4. package/dist/chunks/{dashboard-nvFBzpZv.js → dashboard-upmiawx5.js} +216 -215
  5. package/dist/chunks/{index-Cr-IOgdC.js → index-CBvevwfT.js} +9 -8
  6. package/dist/chunks/status-bar-action-DiXhfcj3.js +30254 -0
  7. package/dist/dashboard.js +2 -2
  8. package/dist/index.js +139 -228
  9. package/dist/site-widget.js +292 -0
  10. package/dist/studio.css +46 -6
  11. package/package.json +7 -2
  12. package/src/api.ts +4 -0
  13. package/src/chat/ChangeCard.tsx +11 -6
  14. package/src/chat/ChatMessage.tsx +27 -2
  15. package/src/chat/ChatPanel.tsx +12 -2
  16. package/src/chat/ChatProvider.tsx +40 -3
  17. package/src/chat/Composer.tsx +92 -6
  18. package/src/chat/DocLink.tsx +31 -0
  19. package/src/chat/DocListCard.tsx +3 -3
  20. package/src/chat/PointerChip.tsx +139 -0
  21. package/src/chat/chat-context.ts +80 -1
  22. package/src/chat/icons.tsx +20 -0
  23. package/src/chat/session-expiry.ts +13 -5
  24. package/src/chrome/OpenSiteButton.tsx +33 -25
  25. package/src/index.css +9 -1
  26. package/src/pages/EmbedChatPage.tsx +25 -1
  27. package/src/pages/EmbedPublishPage.tsx +279 -84
  28. package/src/pages/GithubConnectPage.tsx +44 -19
  29. package/src/pages/SelectRepositoriesLink.tsx +30 -0
  30. package/src/pages/SettingsGeneralPage.tsx +2 -0
  31. package/src/pages/SettingsGithubPage.tsx +8 -3
  32. package/src/pages/host-env.ts +7 -2
  33. package/src/site-pointer.ts +271 -0
  34. package/src/site-widget.tsx +391 -0
  35. package/src/site.tsx +11 -7
  36. package/dist/chunks/WorkspaceRoutes-DyRVES9t.js +0 -87308
@@ -1,6 +1,6 @@
1
1
  // packages/studio/src/pages/EmbedPublishPage.tsx
2
2
  // The CMS commit flow, alone, so the on-site bar can frame it.
3
- import { useEffect, useMemo, useState, type FormEvent } from "react";
3
+ import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
4
4
  import { installParentChatHotkey } from "../chrome/hotkeys";
5
5
  import * as stylex from "@stylexjs/stylex";
6
6
  import {
@@ -13,14 +13,13 @@ import {
13
13
  publishCommittedFiles,
14
14
  resolveDoc,
15
15
  type ApiError,
16
+ type ChangeStatus,
16
17
  type GitChange,
17
18
  type GitState,
18
19
  } from "../api";
19
20
  import { statusBarAction } from "../chrome/status-bar-action";
20
- import { colors, space, type } from "../styles/tokens.stylex";
21
- import { Button } from "../ui/Button";
22
- import { Input } from "../ui/Input";
23
- import { Text } from "../ui/Text";
21
+ import { colors, radius, space, type } from "../styles/tokens.stylex";
22
+ import { popup } from "../ui/popup";
24
23
  import { canPublish } from "../workspace/permissions";
25
24
 
26
25
  const POLL_MS = 8000;
@@ -50,7 +49,24 @@ function changeKey(doc: GitChange) {
50
49
  return `${doc.locale}/${doc.type}/${doc.slug}`;
51
50
  }
52
51
 
53
- export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
52
+ const STATUS_LABEL: Record<ChangeStatus, string> = {
53
+ modified: "modified",
54
+ added: "new",
55
+ conflict: "conflict",
56
+ deleted: "deleted",
57
+ };
58
+
59
+ const hover = "@media (hover: hover)";
60
+
61
+ export function EmbedPublishPage({
62
+ workspaceId,
63
+ onStatus,
64
+ onPublished,
65
+ }: {
66
+ workspaceId: string;
67
+ onStatus?: (status: { changeCount: number; conflictCount: number }) => void;
68
+ onPublished?: () => void;
69
+ }) {
54
70
  useEffect(() => installParentChatHotkey(), []);
55
71
  const [git, setGit] = useState<GitState | null>(null);
56
72
  const [role, setRole] = useState("viewer");
@@ -73,6 +89,9 @@ export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
73
89
  };
74
90
  }, [workspaceId]);
75
91
 
92
+ const onStatusRef = useRef(onStatus);
93
+ onStatusRef.current = onStatus;
94
+
76
95
  useEffect(() => {
77
96
  let stop = false;
78
97
  const load = () => {
@@ -80,14 +99,13 @@ export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
80
99
  .then((next) => {
81
100
  if (stop) return;
82
101
  setGit(next);
102
+ const changeCount = next.changes.length;
103
+ const conflictCount = countConflicts(next);
104
+ onStatusRef.current?.({ changeCount, conflictCount });
83
105
  const origin = parentOrigin();
84
- if (!origin) return;
106
+ if (!origin || window.parent === window) return;
85
107
  window.parent.postMessage(
86
- {
87
- type: "backmatter:git",
88
- changeCount: next.changes.length,
89
- conflictCount: countConflicts(next),
90
- },
108
+ { type: "backmatter:git", changeCount, conflictCount },
91
109
  origin,
92
110
  );
93
111
  })
@@ -143,8 +161,11 @@ export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
143
161
  const result = await commitWorkspace(workspaceId, message);
144
162
  setMessage("");
145
163
  publishCommittedFiles(workspaceId, result.files);
164
+ onPublished?.();
146
165
  const origin = parentOrigin();
147
- if (origin) window.parent.postMessage({ type: "backmatter:published" }, origin);
166
+ if (origin && window.parent !== window) {
167
+ window.parent.postMessage({ type: "backmatter:published" }, origin);
168
+ }
148
169
  setGit(await fetchGit(workspaceId));
149
170
  } catch (err) {
150
171
  setError(formatCommitError(err));
@@ -153,107 +174,281 @@ export function EmbedPublishPage({ workspaceId }: { workspaceId: string }) {
153
174
  }
154
175
  }
155
176
 
156
- const label = committing
157
- ? "Committing…"
158
- : conflictCount > 0
159
- ? `Resolve ${conflictCount} ${conflictCount === 1 ? "conflict" : "conflicts"}`
160
- : action === "blocked"
161
- ? "Writers cannot publish"
162
- : changes.length === 0
163
- ? "Nothing to publish"
164
- : `Deploy ${changes.length} ${changes.length === 1 ? "change" : "changes"}`;
177
+ const branch = git?.currentBranch ?? "main";
165
178
 
166
- return (
167
- <form {...stylex.props(styles.panel)} onSubmit={(event) => void onCommit(event)}>
168
- <Text as="p" size="sm" weight="medium" tone="muted">
169
- {git?.currentBranch ?? "Publish"}
170
- </Text>
171
- {changes.length === 0 ? (
172
- <Text as="p" size="sm" tone="muted">
173
- Nothing to publish
174
- </Text>
175
- ) : (
176
- <ul {...stylex.props(styles.list)}>
177
- {changes.map((change) => (
178
- <li key={changeKey(change)} {...stylex.props(styles.row)}>
179
- <span>
180
- {change.status} {change.type}/{change.slug}
181
- </span>
182
- {change.status === "conflict" ? (
183
- <span {...stylex.props(styles.resolve)}>
184
- <Button
185
- size="sm"
186
- variant="secondary"
179
+ if (conflicts.length > 0) {
180
+ return (
181
+ <div {...stylex.props(styles.form)}>
182
+ <p {...stylex.props(popup.groupLabel)}>Resolve conflicts</p>
183
+ <p {...stylex.props(styles.hint)}>
184
+ Pick a side for each file. Commit stays blocked until every conflict is resolved.
185
+ </p>
186
+ {error ? <p {...stylex.props(styles.error)}>{error}</p> : null}
187
+ <ul {...stylex.props(styles.files, styles.conflictList)}>
188
+ {conflicts.map((doc) => {
189
+ const key = changeKey(doc);
190
+ return (
191
+ <li key={key} {...stylex.props(styles.conflictRow)}>
192
+ <span {...stylex.props(styles.fileName)}>
193
+ {doc.type}/{doc.slug}
194
+ </span>
195
+ <div {...stylex.props(styles.conflictActions)}>
196
+ <button
197
+ type="button"
187
198
  disabled={resolvingKey !== null}
188
- onClick={() => void onResolve(change, "mine")}
199
+ onClick={() => void onResolve(doc, "mine")}
200
+ {...stylex.props(styles.conflictBtn)}
189
201
  >
190
- Keep mine
191
- </Button>
192
- <Button
193
- size="sm"
194
- variant="secondary"
202
+ {resolvingKey === `${key}:mine` ? "Keeping…" : "Keep mine"}
203
+ </button>
204
+ <button
205
+ type="button"
195
206
  disabled={resolvingKey !== null}
196
- onClick={() => void onResolve(change, "theirs")}
207
+ onClick={() => void onResolve(doc, "theirs")}
208
+ {...stylex.props(styles.conflictBtn, styles.conflictBtnDanger)}
197
209
  >
198
- Take theirs
199
- </Button>
210
+ {resolvingKey === `${key}:theirs` ? "Taking…" : "Take theirs"}
211
+ </button>
212
+ </div>
213
+ </li>
214
+ );
215
+ })}
216
+ </ul>
217
+ </div>
218
+ );
219
+ }
220
+
221
+ return (
222
+ <form {...stylex.props(styles.form)} onSubmit={(event) => void onCommit(event)}>
223
+ <p {...stylex.props(popup.groupLabel)}>
224
+ {changes.length === 0
225
+ ? "Nothing to publish"
226
+ : `${changes.length} ${changes.length === 1 ? "file" : "files"} → ${branch}`}
227
+ </p>
228
+ {changes.length > 0 ? (
229
+ <ul {...stylex.props(styles.files)}>
230
+ {changes.map((doc) => (
231
+ <li key={changeKey(doc)}>
232
+ <div {...stylex.props(styles.file)}>
233
+ <span {...stylex.props(styles.fileName)}>
234
+ {doc.type}/{doc.slug}
200
235
  </span>
201
- ) : null}
236
+ <span {...stylex.props(popup.itemMeta)}>{STATUS_LABEL[doc.status]}</span>
237
+ </div>
202
238
  </li>
203
239
  ))}
204
240
  </ul>
205
- )}
206
- {conflicts.length > 0 ? null : (
207
- <Input
208
- value={message}
209
- placeholder={placeholder}
210
- aria-label="Commit message"
211
- onChange={(event) => setMessage(event.target.value)}
212
- />
213
- )}
214
- {error ? (
215
- <Text as="p" size="sm" tone="danger">
216
- {error}
217
- </Text>
218
241
  ) : null}
219
- <Button
220
- type="submit"
221
- size="sm"
222
- disabled={committing || action !== "commit" || changes.length === 0}
223
- >
224
- {label}
225
- </Button>
242
+ {changes.length > 0 ? <div {...stylex.props(popup.separator)} /> : null}
243
+ {changes.length > 0 ? (
244
+ <div {...stylex.props(styles.footer)}>
245
+ <input
246
+ value={message}
247
+ onChange={(event) => setMessage(event.target.value)}
248
+ placeholder={placeholder}
249
+ aria-label="Commit message"
250
+ autoComplete="off"
251
+ spellCheck={false}
252
+ data-no-ring
253
+ data-1p-ignore
254
+ data-lpignore
255
+ disabled={committing || action !== "commit"}
256
+ {...stylex.props(styles.field)}
257
+ />
258
+ {error ? <p {...stylex.props(styles.error, styles.errorInFooter)}>{error}</p> : null}
259
+ {action === "blocked" ? (
260
+ <p {...stylex.props(styles.hint)}>Writers cannot publish</p>
261
+ ) : (
262
+ <button
263
+ type="submit"
264
+ disabled={committing || action !== "commit"}
265
+ {...stylex.props(styles.submit)}
266
+ >
267
+ {committing ? "Committing…" : "Commit & push"}
268
+ </button>
269
+ )}
270
+ </div>
271
+ ) : error ? (
272
+ <p {...stylex.props(styles.error)}>{error}</p>
273
+ ) : null}
226
274
  </form>
227
275
  );
228
276
  }
229
277
 
230
278
  const styles = stylex.create({
231
- panel: {
279
+ form: {
232
280
  display: "flex",
233
281
  flexDirection: "column",
234
- gap: space.lg,
235
- minHeight: "100%",
236
- boxSizing: "border-box",
237
- padding: space.xl,
282
+ paddingTop: space.xs,
238
283
  backgroundColor: colors.bgSurface,
239
284
  color: colors.text,
240
- fontSize: type.sm,
241
285
  },
242
- list: {
286
+ files: {
243
287
  margin: 0,
244
288
  padding: 0,
245
289
  listStyle: "none",
290
+ maxHeight: "160px",
291
+ overflowY: "auto",
292
+ paddingBottom: space.xs,
293
+ },
294
+ file: {
295
+ display: "flex",
296
+ alignItems: "center",
297
+ gap: space.sm,
298
+ minHeight: 30,
299
+ margin: 0,
300
+ marginInline: space.xs,
301
+ paddingInline: space.md,
302
+ paddingBlock: space.sm,
303
+ fontFamily: type.mono,
304
+ fontSize: type.xs,
305
+ color: colors.textMuted,
306
+ },
307
+ fileName: {
308
+ minWidth: 0,
309
+ flex: 1,
310
+ overflow: "hidden",
311
+ textOverflow: "ellipsis",
312
+ whiteSpace: "nowrap",
313
+ },
314
+ footer: {
246
315
  display: "flex",
247
316
  flexDirection: "column",
317
+ alignItems: "stretch",
248
318
  gap: space.md,
319
+ paddingInline: space.lg,
320
+ paddingTop: space.md,
321
+ paddingBottom: space.lg,
322
+ },
323
+ field: {
324
+ width: "100%",
325
+ boxSizing: "border-box",
326
+ minHeight: "28px",
327
+ paddingInline: space.md,
328
+ paddingBlock: space.sm,
329
+ borderWidth: 1,
330
+ borderStyle: "solid",
331
+ borderColor: colors.border,
332
+ borderRadius: radius.md,
333
+ backgroundColor: colors.bgInput,
334
+ color: colors.text,
335
+ fontSize: type.sm,
336
+ outline: "none",
337
+ ":focus": {
338
+ outline: "none",
339
+ borderColor: `color-mix(in srgb, ${colors.textMuted} 45%, ${colors.border})`,
340
+ },
341
+ ":focus-visible": {
342
+ outline: "none",
343
+ },
344
+ ":disabled": {
345
+ opacity: 0.55,
346
+ },
347
+ "::placeholder": {
348
+ color: colors.textMuted,
349
+ },
249
350
  },
250
- row: {
351
+ submit: {
352
+ display: "flex",
353
+ width: "100%",
354
+ alignItems: "center",
355
+ justifyContent: "center",
356
+ height: 28,
357
+ paddingInline: space.xl,
358
+ borderWidth: 1,
359
+ borderStyle: "solid",
360
+ borderColor: colors.accent,
361
+ borderRadius: radius.md,
362
+ backgroundColor: colors.accent,
363
+ color: colors.textInverse,
364
+ fontSize: type.xs,
365
+ fontWeight: 500,
366
+ whiteSpace: "nowrap",
367
+ cursor: "pointer",
368
+ [hover]: {
369
+ ":hover": {
370
+ backgroundColor: colors.accentHover,
371
+ borderColor: colors.accentHover,
372
+ },
373
+ },
374
+ ":disabled": {
375
+ opacity: 0.55,
376
+ cursor: "not-allowed",
377
+ },
378
+ ":active": {
379
+ transform: "scale(0.97)",
380
+ },
381
+ ":focus-visible": {
382
+ outlineWidth: 2,
383
+ outlineStyle: "solid",
384
+ outlineColor: colors.focus,
385
+ outlineOffset: 1,
386
+ },
387
+ "@media (prefers-reduced-motion: reduce)": {
388
+ ":active": {
389
+ transform: "none",
390
+ },
391
+ },
392
+ },
393
+ hint: {
394
+ margin: 0,
395
+ paddingInline: space.lg,
396
+ paddingBottom: space.sm,
397
+ fontSize: type.xs,
398
+ lineHeight: 1.4,
399
+ color: colors.textMuted,
400
+ },
401
+ error: {
402
+ margin: 0,
403
+ paddingInline: space.lg,
404
+ paddingBottom: space.sm,
405
+ fontSize: type.xs,
406
+ color: colors.danger,
407
+ },
408
+ errorInFooter: {
409
+ paddingInline: 0,
410
+ paddingBottom: 0,
411
+ },
412
+ conflictList: {
413
+ maxHeight: "280px",
414
+ },
415
+ conflictRow: {
251
416
  display: "flex",
252
417
  flexDirection: "column",
253
- gap: space.sm,
418
+ gap: space.xs,
419
+ paddingInline: space.md,
420
+ paddingBlock: space.sm,
421
+ fontFamily: type.mono,
422
+ fontSize: type.xs,
254
423
  },
255
- resolve: {
424
+ conflictActions: {
256
425
  display: "flex",
257
- gap: space.sm,
426
+ gap: space.xs,
427
+ },
428
+ conflictBtn: {
429
+ height: 24,
430
+ paddingInline: space.md,
431
+ borderWidth: 1,
432
+ borderStyle: "solid",
433
+ borderColor: colors.border,
434
+ borderRadius: radius.md,
435
+ backgroundColor: colors.bgSurface,
436
+ color: colors.text,
437
+ fontSize: type.xs,
438
+ cursor: "pointer",
439
+ ":disabled": {
440
+ opacity: 0.55,
441
+ cursor: "not-allowed",
442
+ },
443
+ ":focus-visible": {
444
+ outlineWidth: 2,
445
+ outlineStyle: "solid",
446
+ outlineColor: colors.focus,
447
+ outlineOffset: 1,
448
+ },
449
+ },
450
+ conflictBtnDanger: {
451
+ borderColor: colors.danger,
452
+ color: colors.danger,
258
453
  },
259
454
  });
@@ -2,6 +2,7 @@
2
2
  import { useEffect, useState } from "react";
3
3
  import { useNavigate, useOutletContext } from "react-router";
4
4
  import { bindGithub, fetchGithubRepos, type ApiError } from "../api";
5
+ import { SelectRepositoriesLink } from "./SelectRepositoriesLink";
5
6
  import { BackLink } from "../ui/BackLink";
6
7
  import { Button, ButtonLink } from "../ui/Button";
7
8
  import { Field } from "../ui/Field";
@@ -30,24 +31,43 @@ export function GithubConnectPage() {
30
31
 
31
32
  useEffect(() => {
32
33
  if (!workspaceId) return;
33
- void fetchGithubRepos(workspaceId)
34
- .then((data) => {
35
- setRepos(data.repos);
36
- setSelected((current) =>
37
- current && data.repos.some((repo) => repo.fullName === current)
38
- ? current
39
- : (data.repos[0]?.fullName ?? ""),
40
- );
41
- })
42
- .catch((err: Error) => {
43
- const code = err.message;
44
- setError(
45
- code === "github_not_installed" || code === "github_list_failed"
46
- ? "The saved GitHub installation is missing or this app cannot access it."
47
- : code,
48
- );
49
- })
50
- .finally(() => setLoading(false));
34
+ let cancelled = false;
35
+
36
+ function load() {
37
+ void fetchGithubRepos(workspaceId)
38
+ .then((data) => {
39
+ if (cancelled) return;
40
+ setRepos(data.repos);
41
+ setSelected((current) =>
42
+ current && data.repos.some((repo) => repo.fullName === current)
43
+ ? current
44
+ : (data.repos[0]?.fullName ?? ""),
45
+ );
46
+ setError(null);
47
+ })
48
+ .catch((err: Error) => {
49
+ if (cancelled) return;
50
+ const code = err.message;
51
+ setError(
52
+ code === "github_not_installed" || code === "github_list_failed"
53
+ ? "The saved GitHub installation is missing or this app cannot access it."
54
+ : code,
55
+ );
56
+ })
57
+ .finally(() => {
58
+ if (!cancelled) setLoading(false);
59
+ });
60
+ }
61
+
62
+ load();
63
+ function onVisible() {
64
+ if (document.visibilityState === "visible") load();
65
+ }
66
+ document.addEventListener("visibilitychange", onVisible);
67
+ return () => {
68
+ cancelled = true;
69
+ document.removeEventListener("visibilitychange", onVisible);
70
+ };
51
71
  }, [workspaceId]);
52
72
 
53
73
  async function onSubmit(e: React.FormEvent) {
@@ -100,6 +120,7 @@ export function GithubConnectPage() {
100
120
  ? "GitHub did not return repositories for the saved installation."
101
121
  : "No repositories are available for this installation."}
102
122
  </Text>
123
+ <SelectRepositoriesLink workspaceId={workspaceId} />
103
124
  <div>
104
125
  <ButtonLink to={settingsPath(workspace.slug, "github")} variant="secondary">
105
126
  Back to GitHub settings
@@ -109,7 +130,10 @@ export function GithubConnectPage() {
109
130
  ) : (
110
131
  <form onSubmit={onSubmit}>
111
132
  <Stack>
112
- <Field label="Repository">
133
+ <Field
134
+ label="Repository"
135
+ hint="Only repositories granted to the GitHub App are listed."
136
+ >
113
137
  <Select
114
138
  aria-label="Repository"
115
139
  value={selected}
@@ -120,6 +144,7 @@ export function GithubConnectPage() {
120
144
  }))}
121
145
  />
122
146
  </Field>
147
+ <SelectRepositoriesLink workspaceId={workspaceId} />
123
148
  <Field
124
149
  label="Directory"
125
150
  hint="Folder inside the repository that contains this site. Leave empty for the repository root."
@@ -0,0 +1,30 @@
1
+ // packages/studio/src/pages/SelectRepositoriesLink.tsx
2
+ import { useEffect, useState } from "react";
3
+ import { fetchGithubRepositoryAccess } from "../api";
4
+ import { ButtonLink } from "../ui/Button";
5
+
6
+ /** Opens the GitHub page where this installation's repository access is chosen. */
7
+ export function SelectRepositoriesLink({ workspaceId }: { workspaceId: string }) {
8
+ const [url, setUrl] = useState<string | null>(null);
9
+
10
+ useEffect(() => {
11
+ let cancelled = false;
12
+ void fetchGithubRepositoryAccess(workspaceId)
13
+ .then((data) => {
14
+ if (!cancelled) setUrl(data.url);
15
+ })
16
+ .catch(() => {
17
+ if (!cancelled) setUrl(null);
18
+ });
19
+ return () => {
20
+ cancelled = true;
21
+ };
22
+ }, [workspaceId]);
23
+
24
+ if (!url) return null;
25
+ return (
26
+ <ButtonLink href={url} variant="secondary">
27
+ Select repositories on GitHub
28
+ </ButtonLink>
29
+ );
30
+ }
@@ -8,6 +8,7 @@ import {
8
8
  rotatePreviewToken,
9
9
  } from "../api";
10
10
  import { hostEnvSnippet } from "./host-env";
11
+ import { isStudioDev, publicApiUrl } from "../studio-config";
11
12
  import { revalidateWebhookUrl } from "./preview-url";
12
13
  import { Button } from "../ui/Button";
13
14
  import { Field } from "../ui/Field";
@@ -48,6 +49,7 @@ export function SettingsGeneralPage() {
48
49
  previewToken: previewTokenOnce,
49
50
  hostToken: hostTokenOnce,
50
51
  siteUrl: workspace.siteUrls?.[0],
52
+ apiUrl: isStudioDev() ? publicApiUrl() : undefined,
51
53
  });
52
54
 
53
55
  async function onSaveSiteUrl(e: React.FormEvent) {
@@ -18,6 +18,7 @@ import * as stylex from "@stylexjs/stylex";
18
18
  import { colors, space, type } from "../styles/tokens.stylex";
19
19
  import type { WorkspaceOutletContext } from "../workspace/types";
20
20
  import { githubConnectPath } from "../workspace-path";
21
+ import { SelectRepositoriesLink } from "./SelectRepositoriesLink";
21
22
 
22
23
  export function SettingsGithubPage() {
23
24
  const { workspace, reload } = useOutletContext<WorkspaceOutletContext>();
@@ -172,6 +173,7 @@ export function SettingsGithubPage() {
172
173
  >
173
174
  Change repository
174
175
  </ButtonLink>
176
+ <SelectRepositoriesLink workspaceId={workspace.id} />
175
177
  <Button
176
178
  variant="danger"
177
179
  disabled={disconnecting}
@@ -185,9 +187,9 @@ export function SettingsGithubPage() {
185
187
  ) : (
186
188
  <Stack>
187
189
  <Text tone="muted" size="sm">
188
- Connect finds the backmatter CMS installation on your GitHub
189
- account and opens the repository list here. Saving on GitHub does
190
- not return to Backmatter.
190
+ {workspace.githubInstallationId
191
+ ? "The list only includes repositories this GitHub App can access. Select repositories on GitHub to grant more. Saving there does not return to Backmatter."
192
+ : "Connect finds the backmatter CMS installation on your GitHub account and opens the repository list here. Saving on GitHub does not return to Backmatter."}
191
193
  </Text>
192
194
  {canEdit ? (
193
195
  <div {...stylex.props(styles.actions)}>
@@ -210,6 +212,9 @@ export function SettingsGithubPage() {
210
212
  {connecting ? "Connecting…" : "Connect GitHub"}
211
213
  </Button>
212
214
  )}
215
+ {workspace.githubInstallationId ? (
216
+ <SelectRepositoriesLink workspaceId={workspace.id} />
217
+ ) : null}
213
218
  {installUrl ? (
214
219
  <ButtonLink href={installUrl} newTab={false} variant="secondary">
215
220
  Install on GitHub
@@ -7,13 +7,18 @@ export function hostEnvSnippet(input: {
7
7
  previewToken?: string | null;
8
8
  hostToken?: string | null;
9
9
  siteUrl?: string | null;
10
+ /** Local API origin. Passed only while the dashboard is running in dev. */
11
+ apiUrl?: string | null;
10
12
  }): string {
11
13
  const line = (name: string, value: string | null | undefined) =>
12
14
  `${name}=${value?.trim() ?? ""}`;
15
+ const apiUrl = input.apiUrl?.trim();
13
16
  return [
14
17
  `# backmatter.config.ts → workspaceId: "${input.workspaceId}"`,
15
- "# API defaults to https://api.backmatter.app in production",
16
- "# BACKMATTER_API_URL=http://localhost:3001 # local or self-hosted CMS",
18
+ apiUrl
19
+ ? line("BACKMATTER_API_URL", apiUrl)
20
+ : "# API defaults to https://api.backmatter.app in production",
21
+ line("BACKMATTER_WORKSPACE_ID", input.workspaceId),
17
22
  line("BACKMATTER_PREVIEW_TOKEN", input.previewToken),
18
23
  line("BACKMATTER_HOST_TOKEN", input.hostToken),
19
24
  line("BACKMATTER_CONTENT_TOKEN", input.contentToken),