@jxsuite/studio 0.19.0 → 0.20.0

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 (93) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +12577 -12183
  3. package/dist/studio.js.map +146 -144
  4. package/dist/workers/editor.worker.js +79 -79
  5. package/dist/workers/json.worker.js +109 -109
  6. package/dist/workers/ts.worker.js +82 -82
  7. package/package.json +17 -6
  8. package/src/browse/browse.js +25 -19
  9. package/src/canvas/canvas-diff.js +24 -16
  10. package/src/canvas/canvas-helpers.js +24 -19
  11. package/src/canvas/canvas-live-render.js +94 -39
  12. package/src/canvas/canvas-render.js +119 -73
  13. package/src/canvas/canvas-utils.js +38 -15
  14. package/src/canvas/nested-site-style.js +50 -0
  15. package/src/editor/component-inline-edit.js +14 -13
  16. package/src/editor/content-inline-edit.js +22 -10
  17. package/src/editor/context-menu.js +1 -1
  18. package/src/editor/convert-to-component.js +12 -10
  19. package/src/editor/inline-edit.js +66 -28
  20. package/src/editor/insertion-helper.js +16 -12
  21. package/src/editor/shortcuts.js +4 -2
  22. package/src/editor/slash-menu.js +6 -4
  23. package/src/files/components.js +1 -1
  24. package/src/files/file-ops.js +37 -16
  25. package/src/files/files.js +78 -52
  26. package/src/github/github-auth.js +122 -0
  27. package/src/github/github-publish.js +115 -0
  28. package/src/markdown/md-convert.js +172 -107
  29. package/src/new-project/new-project-modal.js +204 -0
  30. package/src/panels/activity-bar.js +22 -20
  31. package/src/panels/ai-panel.js +399 -0
  32. package/src/panels/block-action-bar.js +5 -5
  33. package/src/panels/canvas-dnd.js +8 -2
  34. package/src/panels/data-explorer.js +19 -13
  35. package/src/panels/dnd.js +144 -92
  36. package/src/panels/editors.js +55 -35
  37. package/src/panels/elements-panel.js +33 -19
  38. package/src/panels/events-panel.js +15 -10
  39. package/src/panels/git-panel.js +469 -98
  40. package/src/panels/head-panel.js +121 -81
  41. package/src/panels/imports-panel.js +88 -64
  42. package/src/panels/layers-panel.js +64 -43
  43. package/src/panels/left-panel.js +85 -47
  44. package/src/panels/overlays.js +1 -0
  45. package/src/panels/panel-events.js +45 -30
  46. package/src/panels/preview-render.js +23 -18
  47. package/src/panels/properties-panel.js +365 -249
  48. package/src/panels/pseudo-preview.js +2 -2
  49. package/src/panels/quick-search.js +7 -5
  50. package/src/panels/right-panel.js +26 -2
  51. package/src/panels/shared.js +4 -4
  52. package/src/panels/signals-panel.js +295 -166
  53. package/src/panels/statusbar.js +4 -4
  54. package/src/panels/style-inputs.js +67 -35
  55. package/src/panels/style-panel.js +233 -132
  56. package/src/panels/style-utils.js +34 -16
  57. package/src/panels/stylebook-layers-panel.js +12 -10
  58. package/src/panels/stylebook-panel.js +134 -66
  59. package/src/panels/toolbar.js +333 -109
  60. package/src/panels/welcome-screen.js +121 -0
  61. package/src/platform.js +2 -4
  62. package/src/platforms/devserver.js +113 -7
  63. package/src/resize-edges.js +98 -0
  64. package/src/services/cem-export.js +12 -7
  65. package/src/services/code-services.js +30 -12
  66. package/src/settings/content-types-editor.js +7 -7
  67. package/src/settings/css-vars-editor.js +30 -24
  68. package/src/settings/defs-editor.js +12 -7
  69. package/src/settings/general-settings.js +85 -3
  70. package/src/settings/head-editor.js +30 -24
  71. package/src/settings/schema-field-ui.js +58 -39
  72. package/src/site-context.js +41 -31
  73. package/src/state.js +70 -29
  74. package/src/store.js +21 -26
  75. package/src/studio.js +103 -81
  76. package/src/tabs/tab.js +67 -43
  77. package/src/tabs/transact.js +23 -13
  78. package/src/ui/button-group.js +24 -24
  79. package/src/ui/color-selector.js +28 -23
  80. package/src/ui/field-row.js +3 -3
  81. package/src/ui/icons.js +2 -2
  82. package/src/ui/media-picker.js +7 -7
  83. package/src/ui/spectrum.js +5 -1
  84. package/src/ui/unit-selector.js +18 -16
  85. package/src/ui/value-selector.js +21 -15
  86. package/src/ui/widgets.js +22 -19
  87. package/src/utils/canvas-media.js +5 -4
  88. package/src/utils/edit-display.js +31 -20
  89. package/src/utils/google-fonts.js +11 -11
  90. package/src/utils/inherited-style.js +9 -8
  91. package/src/utils/studio-utils.js +7 -7
  92. package/src/view.js +58 -1
  93. package/src/workspace/workspace.js +10 -7
@@ -13,7 +13,7 @@ import { renderPopover } from "../ui/layers.js";
13
13
  import remarkDirective from "remark-directive";
14
14
  import { stringify as stringifyYaml } from "yaml";
15
15
  import { jxToMd } from "../markdown/md-convert.js";
16
- import { createState, projectState, setProjectState } from "../store.js";
16
+ import { createState, projectState, setProjectState, requireProjectState } from "../store.js";
17
17
  import { getPlatform } from "../platform.js";
18
18
  import { statusMessage } from "../panels/statusbar.js";
19
19
  import { loadComponentRegistry } from "./components.js";
@@ -65,7 +65,7 @@ export async function loadProject() {
65
65
  name: info.isSiteProject ? info.projectConfig?.name || meta.name : meta.name,
66
66
  projectRoot: ".",
67
67
  isSiteProject: info.isSiteProject,
68
- projectConfig: info.isSiteProject ? info.projectConfig : null,
68
+ projectConfig: (info.isSiteProject ? info.projectConfig : null) || null,
69
69
  projectDirs: info.directories || [],
70
70
  dirs: new Map(),
71
71
  expanded: new Set(),
@@ -105,7 +105,7 @@ export async function openProject({ renderActivityBar, renderLeftPanel }) {
105
105
  replaceAllTabs({ id: "initial", document: { tagName: "div", children: [] } });
106
106
 
107
107
  setProjectState({
108
- ...projectState,
108
+ .../** @type {ProjectState} */ (projectState),
109
109
  projectRoot: handle.root,
110
110
  isSiteProject: true,
111
111
  projectConfig: config,
@@ -129,26 +129,26 @@ export async function openProject({ renderActivityBar, renderLeftPanel }) {
129
129
  "public",
130
130
  "styles",
131
131
  ];
132
- const entries = projectState.dirs.get(".") || [];
132
+ const entries = requireProjectState().dirs.get(".") || [];
133
133
  const foundDirs = [];
134
134
  for (const e of entries) {
135
135
  if (e.type === "directory" && conventionalDirs.includes(e.name)) {
136
136
  foundDirs.push(e.name);
137
- projectState.expanded.add(e.path || e.name);
137
+ requireProjectState().expanded.add(e.path || e.name);
138
138
  await loadDirectory(e.path || e.name);
139
139
  }
140
140
  }
141
- projectState.projectDirs = foundDirs;
141
+ requireProjectState().projectDirs = foundDirs;
142
142
 
143
143
  view.leftTab = "files";
144
- addRecentProject(projectState.name, projectState.projectRoot);
144
+ addRecentProject(requireProjectState().name, requireProjectState().projectRoot);
145
145
  renderActivityBar();
146
146
  renderLeftPanel();
147
- statusMessage(`Opened project: ${projectState.name}`);
147
+ statusMessage(`Opened project: ${requireProjectState().name}`);
148
148
 
149
149
  await openHomePage();
150
- } catch (/** @type {any} */ e) {
151
- statusMessage(`Error: ${e.message}`);
150
+ } catch (/** @type {unknown} */ e) {
151
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
152
152
  }
153
153
  }
154
154
 
@@ -251,9 +251,9 @@ export function renderFilesTemplate({
251
251
  size="xs"
252
252
  label="Refresh"
253
253
  @click=${async () => {
254
- projectState.dirs.clear();
254
+ requireProjectState().dirs.clear();
255
255
  await loadDirectory(".");
256
- for (const dir of projectState.expanded) await loadDirectory(dir);
256
+ for (const dir of requireProjectState().expanded) await loadDirectory(dir);
257
257
  renderLeftPanel();
258
258
  }}
259
259
  >
@@ -264,9 +264,9 @@ export function renderFilesTemplate({
264
264
  size="s"
265
265
  quiet
266
266
  placeholder="Filter files…"
267
- value=${projectState.searchQuery}
267
+ value=${requireProjectState().searchQuery}
268
268
  @input=${(/** @type {Event} */ e) => {
269
- projectState.searchQuery = /** @type {HTMLInputElement} */ (e.target).value;
269
+ requireProjectState().searchQuery = /** @type {HTMLInputElement} */ (e.target).value;
270
270
  renderLeftPanel();
271
271
  }}
272
272
  @submit=${(/** @type {Event} */ e) => e.preventDefault()}
@@ -284,7 +284,7 @@ function renderTreeLevelTemplate(
284
284
  /** @type {number} */ depth,
285
285
  /** @type {{ openFileFn: (path: string) => void; renderLeftPanel: () => void }} */ ctx,
286
286
  ) {
287
- const entries = projectState.dirs.get(dirPath);
287
+ const entries = requireProjectState().dirs.get(dirPath);
288
288
  if (!entries) {
289
289
  loadDirectory(dirPath).then(() => ctx.renderLeftPanel());
290
290
  return html`<div
@@ -301,15 +301,15 @@ function renderTreeLevelTemplate(
301
301
  return a.name.localeCompare(b.name);
302
302
  });
303
303
 
304
- const query = projectState.searchQuery.toLowerCase();
304
+ const query = requireProjectState().searchQuery.toLowerCase();
305
305
  const filtered = query
306
306
  ? sorted.filter((e) => e.type === "directory" || e.name.toLowerCase().includes(query))
307
307
  : sorted;
308
308
 
309
309
  return filtered.map((entry) => {
310
310
  const isDir = entry.type === "directory";
311
- const isExpanded = projectState.expanded.has(entry.path);
312
- const isSelected = projectState.selectedPath === entry.path;
311
+ const isExpanded = requireProjectState().expanded.has(entry.path);
312
+ const isSelected = requireProjectState().selectedPath === entry.path;
313
313
 
314
314
  return html`
315
315
  <div
@@ -324,10 +324,10 @@ function renderTreeLevelTemplate(
324
324
  @click=${async (/** @type {MouseEvent} */ e) => {
325
325
  e.stopPropagation();
326
326
  if (isDir) {
327
- if (isExpanded) projectState.expanded.delete(entry.path);
327
+ if (isExpanded) requireProjectState().expanded.delete(entry.path);
328
328
  else {
329
- projectState.expanded.add(entry.path);
330
- if (!projectState.dirs.has(entry.path)) await loadDirectory(entry.path);
329
+ requireProjectState().expanded.add(entry.path);
330
+ if (!requireProjectState().dirs.has(entry.path)) await loadDirectory(entry.path);
331
331
  }
332
332
  ctx.renderLeftPanel();
333
333
  } else {
@@ -372,8 +372,8 @@ export function setupTreeKeyboard(/** @type {HTMLElement} */ tree) {
372
372
  case "ArrowRight":
373
373
  if (focused.dataset.type === "directory") {
374
374
  const path = /** @type {string} */ (focused.dataset.path);
375
- if (!projectState.expanded.has(path)) {
376
- projectState.expanded.add(path);
375
+ if (!requireProjectState().expanded.has(path)) {
376
+ requireProjectState().expanded.add(path);
377
377
  loadDirectory(path).then(() => {
378
378
  const panel = tree.closest(".panel-body");
379
379
  if (panel)
@@ -386,9 +386,9 @@ export function setupTreeKeyboard(/** @type {HTMLElement} */ tree) {
386
386
  break;
387
387
  case "ArrowLeft":
388
388
  if (focused.dataset.type === "directory") {
389
- const path = focused.dataset.path;
390
- if (projectState.expanded.has(path)) {
391
- projectState.expanded.delete(path);
389
+ const path = /** @type {string} */ (focused.dataset.path);
390
+ if (requireProjectState().expanded.has(path)) {
391
+ requireProjectState().expanded.delete(path);
392
392
  // renderLeftPanel will be called by the caller who sets up keyboard
393
393
  }
394
394
  }
@@ -497,15 +497,15 @@ async function createNewFile(dirPath = ".", /** @type {() => void} */ renderLeft
497
497
  const path = dirPath === "." ? name : `${dirPath}/${name}`;
498
498
  const content = name.endsWith(".md")
499
499
  ? "---\ntitle: Untitled\n---\n\n"
500
- : JSON.stringify({ tagName: "div", children: [] }, null, 2);
500
+ : JSON.stringify({ tagName: "div", children: [{ tagName: "p", children: [] }] }, null, 2);
501
501
  try {
502
502
  const platform = getPlatform();
503
503
  await platform.writeFile(path, content);
504
504
  await loadDirectory(dirPath);
505
505
  renderLeftPanel();
506
506
  statusMessage(`Created ${path}`);
507
- } catch (/** @type {any} */ e) {
508
- statusMessage(`Error: ${e.message}`);
507
+ } catch (/** @type {unknown} */ e) {
508
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
509
509
  }
510
510
  }
511
511
 
@@ -524,13 +524,13 @@ async function renameFile(
524
524
  const platform = getPlatform();
525
525
  await platform.renameFile(entry.path, newPath);
526
526
  await loadDirectory(parentDir);
527
- if (projectState.selectedPath === entry.path) {
528
- projectState.selectedPath = newPath;
527
+ if (requireProjectState().selectedPath === entry.path) {
528
+ requireProjectState().selectedPath = newPath;
529
529
  }
530
530
  renderLeftPanel();
531
531
  statusMessage(`Renamed to ${newName}`);
532
- } catch (/** @type {any} */ e) {
533
- statusMessage(`Error: ${e.message}`);
532
+ } catch (/** @type {unknown} */ e) {
533
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
534
534
  }
535
535
  }
536
536
 
@@ -545,13 +545,13 @@ async function deleteFile(
545
545
  const delPath = entry.path.replaceAll("\\", "/");
546
546
  const parentDir = delPath.includes("/") ? delPath.substring(0, delPath.lastIndexOf("/")) : ".";
547
547
  await loadDirectory(parentDir);
548
- if (projectState.selectedPath === entry.path) {
549
- projectState.selectedPath = null;
548
+ if (requireProjectState().selectedPath === entry.path) {
549
+ requireProjectState().selectedPath = null;
550
550
  }
551
551
  renderLeftPanel();
552
552
  statusMessage(`Deleted ${entry.name}`);
553
- } catch (/** @type {any} */ e) {
554
- statusMessage(`Error: ${e.message}`);
553
+ } catch (/** @type {unknown} */ e) {
554
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
555
555
  }
556
556
  }
557
557
 
@@ -561,10 +561,10 @@ async function deleteFile(
561
561
  * Open a file from the file tree — auto-saves current dirty doc, then loads the new one.
562
562
  *
563
563
  * @param {{
564
- * S: any;
565
- * commit: (s: any) => void;
564
+ * S: import("../state.js").StudioState;
565
+ * commit: (s: import("../state.js").StudioState) => void;
566
566
  * render: () => void;
567
- * loadMarkdown: (source: string, handle: any) => void;
567
+ * loadMarkdown: (source: string, handle: unknown) => void;
568
568
  * }} ctx
569
569
  * @param {string} path
570
570
  */
@@ -576,11 +576,11 @@ export async function openFileFromTree(ctx, path) {
576
576
  const isContent = ctx.S.mode === "content";
577
577
  let output;
578
578
  if (isContent) {
579
- const mdast = jxToMd(ctx.S.document);
579
+ const mdast = jxToMd(/** @type {JxElement} */ (ctx.S.document));
580
580
  const md = unified()
581
581
  .use(remarkDirective)
582
582
  .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
583
- .stringify(mdast);
583
+ .stringify(/** @type {import("mdast").Root} */ (/** @type {unknown} */ (mdast)));
584
584
  const fm = ctx.S.content?.frontmatter;
585
585
  const hasFrontmatter = fm && Object.keys(fm).length > 0;
586
586
  output = hasFrontmatter ? `---\n${stringifyYaml(fm).trim()}\n---\n\n${md}` : md;
@@ -588,8 +588,8 @@ export async function openFileFromTree(ctx, path) {
588
588
  output = JSON.stringify(ctx.S.document, null, 2);
589
589
  }
590
590
  await platform.writeFile(ctx.S.documentPath, output);
591
- } catch (/** @type {any} */ e) {
592
- statusMessage(`Save error: ${e.message}`);
591
+ } catch (/** @type {unknown} */ e) {
592
+ statusMessage(`Save error: ${/** @type {Error} */ (e).message}`);
593
593
  }
594
594
  }
595
595
 
@@ -612,12 +612,12 @@ export async function openFileFromTree(ctx, path) {
612
612
  }
613
613
 
614
614
  // Update tree selection
615
- projectState.selectedPath = path;
615
+ requireProjectState().selectedPath = path;
616
616
 
617
617
  ctx.render();
618
618
  statusMessage(`Opened ${path}`);
619
- } catch (/** @type {any} */ e) {
620
- statusMessage(`Error: ${e.message}`);
619
+ } catch (/** @type {unknown} */ e) {
620
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
621
621
  }
622
622
  }
623
623
 
@@ -630,7 +630,7 @@ export async function openFileInTab(path) {
630
630
  for (const [id, tab] of workspace.tabs.entries()) {
631
631
  if (tab.documentPath === path) {
632
632
  activateTab(id);
633
- projectState.selectedPath = path;
633
+ requireProjectState().selectedPath = path;
634
634
  return;
635
635
  }
636
636
  }
@@ -657,9 +657,8 @@ export async function openFileInTab(path) {
657
657
  frontmatter,
658
658
  sourceFormat: path.endsWith(".md") ? "md" : null,
659
659
  });
660
- projectState.selectedPath = path;
660
+ requireProjectState().selectedPath = path;
661
661
  trackRecentFile({ path, name: path.split("/").pop() || path });
662
- view.leftTab = "layers";
663
662
 
664
663
  if (path === "project.json") {
665
664
  const tab = activeTab.value;
@@ -667,7 +666,34 @@ export async function openFileInTab(path) {
667
666
  }
668
667
 
669
668
  statusMessage(`Opened ${path.split("/").pop()}`);
670
- } catch (/** @type {any} */ e) {
671
- statusMessage(`Error: ${e.message}`);
669
+ } catch (/** @type {unknown} */ e) {
670
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Reload an already-open tab from disk without changing the active tab. Used to refresh after AI
676
+ * assistant writes to a file.
677
+ *
678
+ * @param {string} path
679
+ */
680
+ export async function reloadFileInTab(path) {
681
+ for (const [, tab] of workspace.tabs.entries()) {
682
+ if (tab.documentPath === path) {
683
+ const platform = getPlatform();
684
+ try {
685
+ const content = await platform.readFile(path);
686
+ if (!content) return;
687
+ if (path.endsWith(".md")) {
688
+ const { document, frontmatter } = await loadMarkdown(content);
689
+ tab.doc.document = document;
690
+ tab.doc.content.frontmatter = frontmatter;
691
+ } else {
692
+ tab.doc.document = JSON.parse(content);
693
+ }
694
+ tab.doc.dirty = false;
695
+ } catch {}
696
+ return;
697
+ }
672
698
  }
673
699
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * GitHub OAuth Device Flow authentication for Jx Studio.
3
+ *
4
+ * Uses the Device Flow so the user authenticates in their browser without needing a server-side
5
+ * redirect endpoint.
6
+ */
7
+
8
+ import { html } from "lit-html";
9
+ import { showDialog } from "../ui/layers.js";
10
+
11
+ const CLIENT_ID = "Ov23liYVlMFpgjOEPXJH";
12
+ const STORAGE_KEY = "jx_github_token";
13
+
14
+ export function getGithubToken() {
15
+ return localStorage.getItem(STORAGE_KEY);
16
+ }
17
+
18
+ export function clearGithubToken() {
19
+ localStorage.removeItem(STORAGE_KEY);
20
+ }
21
+
22
+ /**
23
+ * Authenticate with GitHub via the Device Flow. Shows a dialog with the user code and polls until
24
+ * the user completes authorization or cancels.
25
+ *
26
+ * @returns {Promise<string | null>} The access token, or null if cancelled.
27
+ */
28
+ export async function authenticateGithub() {
29
+ const existing = getGithubToken();
30
+ if (existing) return existing;
31
+
32
+ const codeRes = await fetch("https://github.com/login/device/code", {
33
+ method: "POST",
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ Accept: "application/json",
37
+ },
38
+ body: JSON.stringify({ client_id: CLIENT_ID, scope: "repo" }),
39
+ });
40
+
41
+ if (!codeRes.ok) throw new Error("Failed to initiate GitHub device flow");
42
+ const codeData = await codeRes.json();
43
+
44
+ const { device_code, user_code, verification_uri, interval = 5 } = codeData;
45
+
46
+ return showDialog((done) => {
47
+ let cancelled = false;
48
+ /** @type {ReturnType<typeof setTimeout> | null} */
49
+ let pollTimer = null;
50
+
51
+ const poll = async () => {
52
+ if (cancelled) return;
53
+ try {
54
+ const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
55
+ method: "POST",
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ Accept: "application/json",
59
+ },
60
+ body: JSON.stringify({
61
+ client_id: CLIENT_ID,
62
+ device_code,
63
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
64
+ }),
65
+ });
66
+ const tokenData = await tokenRes.json();
67
+
68
+ if (tokenData.access_token) {
69
+ localStorage.setItem(STORAGE_KEY, tokenData.access_token);
70
+ done(tokenData.access_token);
71
+ return;
72
+ }
73
+
74
+ if (tokenData.error === "authorization_pending") {
75
+ pollTimer = setTimeout(poll, interval * 1000);
76
+ return;
77
+ }
78
+
79
+ if (tokenData.error === "slow_down") {
80
+ pollTimer = setTimeout(poll, (interval + 5) * 1000);
81
+ return;
82
+ }
83
+
84
+ // expired_token or access_denied
85
+ done(null);
86
+ } catch {
87
+ pollTimer = setTimeout(poll, interval * 1000);
88
+ }
89
+ };
90
+
91
+ pollTimer = setTimeout(poll, interval * 1000);
92
+
93
+ return html`
94
+ <sp-dialog-wrapper
95
+ open
96
+ headline="Sign in to GitHub"
97
+ cancel-label="Cancel"
98
+ @cancel=${() => {
99
+ cancelled = true;
100
+ if (pollTimer) clearTimeout(pollTimer);
101
+ done(null);
102
+ }}
103
+ @close=${() => {
104
+ cancelled = true;
105
+ if (pollTimer) clearTimeout(pollTimer);
106
+ done(null);
107
+ }}
108
+ >
109
+ <div class="github-auth-dialog">
110
+ <p>Enter this code on GitHub to authorize Jx Studio:</p>
111
+ <div class="github-auth-code">${user_code}</div>
112
+ <p>
113
+ <a href="${verification_uri}" target="_blank" rel="noopener"
114
+ >Open ${verification_uri}</a
115
+ >
116
+ </p>
117
+ <p class="github-auth-waiting">Waiting for authorization…</p>
118
+ </div>
119
+ </sp-dialog-wrapper>
120
+ `;
121
+ });
122
+ }
@@ -0,0 +1,115 @@
1
+ /** Publish a local project to GitHub — creates a new repo and pushes. */
2
+
3
+ import { html } from "lit-html";
4
+ import { showDialog } from "../ui/layers.js";
5
+ import { authenticateGithub } from "./github-auth.js";
6
+ import { getPlatform } from "../platform.js";
7
+ import { refreshGitStatus } from "../panels/git-panel.js";
8
+ import { statusMessage } from "../panels/statusbar.js";
9
+
10
+ /**
11
+ * Full "Publish to GitHub" flow: 1. Authenticate (or reuse stored token) 2. Prompt for repo name /
12
+ * visibility 3. Create the repo via GitHub API 4. Add remote + push
13
+ *
14
+ * @param {{ projectName: string }} opts
15
+ * @returns {Promise<boolean>} True if published successfully
16
+ */
17
+ export async function publishToGithub({ projectName }) {
18
+ const token = await authenticateGithub();
19
+ if (!token) return false;
20
+
21
+ const repoOpts = await showDialog((done) => {
22
+ return html`
23
+ <sp-dialog-wrapper
24
+ open
25
+ headline="Publish to GitHub"
26
+ confirm-label="Create Repository"
27
+ cancel-label="Cancel"
28
+ @confirm=${(/** @type {Event} */ e) => {
29
+ const dialog = /** @type {HTMLElement} */ (e.target);
30
+ const nameInput = /** @type {HTMLInputElement | null} */ (
31
+ dialog.querySelector('[name="repo-name"]')
32
+ );
33
+ const descInput = /** @type {HTMLInputElement | null} */ (
34
+ dialog.querySelector('[name="repo-desc"]')
35
+ );
36
+ const privateToggle = /** @type {HTMLInputElement | null} */ (
37
+ dialog.querySelector('[name="repo-private"]')
38
+ );
39
+ done({
40
+ name: nameInput?.value || projectName,
41
+ description: descInput?.value || "",
42
+ isPrivate: privateToggle?.checked ?? true,
43
+ });
44
+ }}
45
+ @cancel=${() => done(null)}
46
+ @close=${() => done(null)}
47
+ >
48
+ <div class="github-publish-dialog">
49
+ <sp-field-label for="repo-name">Repository name</sp-field-label>
50
+ <sp-textfield
51
+ id="repo-name"
52
+ name="repo-name"
53
+ value="${projectName}"
54
+ placeholder="my-project"
55
+ ></sp-textfield>
56
+
57
+ <sp-field-label for="repo-desc">Description (optional)</sp-field-label>
58
+ <sp-textfield
59
+ id="repo-desc"
60
+ name="repo-desc"
61
+ placeholder="A brief description"
62
+ ></sp-textfield>
63
+
64
+ <sp-field-label>Visibility</sp-field-label>
65
+ <sp-switch name="repo-private" checked>Private repository</sp-switch>
66
+ </div>
67
+ </sp-dialog-wrapper>
68
+ `;
69
+ });
70
+
71
+ if (!repoOpts) return false;
72
+
73
+ statusMessage("Creating GitHub repository…");
74
+
75
+ const createRes = await fetch("https://api.github.com/user/repos", {
76
+ method: "POST",
77
+ headers: {
78
+ Authorization: `Bearer ${token}`,
79
+ Accept: "application/vnd.github+json",
80
+ "Content-Type": "application/json",
81
+ },
82
+ body: JSON.stringify({
83
+ name: repoOpts.name,
84
+ description: repoOpts.description,
85
+ private: repoOpts.isPrivate,
86
+ auto_init: false,
87
+ }),
88
+ });
89
+
90
+ if (!createRes.ok) {
91
+ const err = await createRes.json();
92
+ const msg = err.errors?.[0]?.message || err.message || "Failed to create repository";
93
+ statusMessage(`Error: ${msg}`);
94
+ return false;
95
+ }
96
+
97
+ const repo = await createRes.json();
98
+ const platform = getPlatform();
99
+
100
+ statusMessage("Setting remote and pushing…");
101
+
102
+ await platform.gitAddRemote("origin", repo.clone_url);
103
+
104
+ statusMessage("Pushing to GitHub…");
105
+ try {
106
+ await platform.gitPush({ setUpstream: true });
107
+ } catch (/** @type {unknown} */ e) {
108
+ statusMessage(`Push failed: ${/** @type {Error} */ (e).message}`);
109
+ return false;
110
+ }
111
+
112
+ await refreshGitStatus();
113
+ statusMessage(`Published to GitHub: ${repo.html_url}`);
114
+ return true;
115
+ }