@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.
- package/dist/studio.css +98 -98
- package/dist/studio.js +12577 -12183
- package/dist/studio.js.map +146 -144
- package/dist/workers/editor.worker.js +79 -79
- package/dist/workers/json.worker.js +109 -109
- package/dist/workers/ts.worker.js +82 -82
- package/package.json +17 -6
- package/src/browse/browse.js +25 -19
- package/src/canvas/canvas-diff.js +24 -16
- package/src/canvas/canvas-helpers.js +24 -19
- package/src/canvas/canvas-live-render.js +94 -39
- package/src/canvas/canvas-render.js +119 -73
- package/src/canvas/canvas-utils.js +38 -15
- package/src/canvas/nested-site-style.js +50 -0
- package/src/editor/component-inline-edit.js +14 -13
- package/src/editor/content-inline-edit.js +22 -10
- package/src/editor/context-menu.js +1 -1
- package/src/editor/convert-to-component.js +12 -10
- package/src/editor/inline-edit.js +66 -28
- package/src/editor/insertion-helper.js +16 -12
- package/src/editor/shortcuts.js +4 -2
- package/src/editor/slash-menu.js +6 -4
- package/src/files/components.js +1 -1
- package/src/files/file-ops.js +37 -16
- package/src/files/files.js +78 -52
- package/src/github/github-auth.js +122 -0
- package/src/github/github-publish.js +115 -0
- package/src/markdown/md-convert.js +172 -107
- package/src/new-project/new-project-modal.js +204 -0
- package/src/panels/activity-bar.js +22 -20
- package/src/panels/ai-panel.js +399 -0
- package/src/panels/block-action-bar.js +5 -5
- package/src/panels/canvas-dnd.js +8 -2
- package/src/panels/data-explorer.js +19 -13
- package/src/panels/dnd.js +144 -92
- package/src/panels/editors.js +55 -35
- package/src/panels/elements-panel.js +33 -19
- package/src/panels/events-panel.js +15 -10
- package/src/panels/git-panel.js +469 -98
- package/src/panels/head-panel.js +121 -81
- package/src/panels/imports-panel.js +88 -64
- package/src/panels/layers-panel.js +64 -43
- package/src/panels/left-panel.js +85 -47
- package/src/panels/overlays.js +1 -0
- package/src/panels/panel-events.js +45 -30
- package/src/panels/preview-render.js +23 -18
- package/src/panels/properties-panel.js +365 -249
- package/src/panels/pseudo-preview.js +2 -2
- package/src/panels/quick-search.js +7 -5
- package/src/panels/right-panel.js +26 -2
- package/src/panels/shared.js +4 -4
- package/src/panels/signals-panel.js +295 -166
- package/src/panels/statusbar.js +4 -4
- package/src/panels/style-inputs.js +67 -35
- package/src/panels/style-panel.js +233 -132
- package/src/panels/style-utils.js +34 -16
- package/src/panels/stylebook-layers-panel.js +12 -10
- package/src/panels/stylebook-panel.js +134 -66
- package/src/panels/toolbar.js +333 -109
- package/src/panels/welcome-screen.js +121 -0
- package/src/platform.js +2 -4
- package/src/platforms/devserver.js +113 -7
- package/src/resize-edges.js +98 -0
- package/src/services/cem-export.js +12 -7
- package/src/services/code-services.js +30 -12
- package/src/settings/content-types-editor.js +7 -7
- package/src/settings/css-vars-editor.js +30 -24
- package/src/settings/defs-editor.js +12 -7
- package/src/settings/general-settings.js +85 -3
- package/src/settings/head-editor.js +30 -24
- package/src/settings/schema-field-ui.js +58 -39
- package/src/site-context.js +41 -31
- package/src/state.js +70 -29
- package/src/store.js +21 -26
- package/src/studio.js +103 -81
- package/src/tabs/tab.js +67 -43
- package/src/tabs/transact.js +23 -13
- package/src/ui/button-group.js +24 -24
- package/src/ui/color-selector.js +28 -23
- package/src/ui/field-row.js +3 -3
- package/src/ui/icons.js +2 -2
- package/src/ui/media-picker.js +7 -7
- package/src/ui/spectrum.js +5 -1
- package/src/ui/unit-selector.js +18 -16
- package/src/ui/value-selector.js +21 -15
- package/src/ui/widgets.js +22 -19
- package/src/utils/canvas-media.js +5 -4
- package/src/utils/edit-display.js +31 -20
- package/src/utils/google-fonts.js +11 -11
- package/src/utils/inherited-style.js +9 -8
- package/src/utils/studio-utils.js +7 -7
- package/src/view.js +58 -1
- package/src/workspace/workspace.js +10 -7
package/src/files/files.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
137
|
+
requireProjectState().expanded.add(e.path || e.name);
|
|
138
138
|
await loadDirectory(e.path || e.name);
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
-
|
|
141
|
+
requireProjectState().projectDirs = foundDirs;
|
|
142
142
|
|
|
143
143
|
view.leftTab = "files";
|
|
144
|
-
addRecentProject(
|
|
144
|
+
addRecentProject(requireProjectState().name, requireProjectState().projectRoot);
|
|
145
145
|
renderActivityBar();
|
|
146
146
|
renderLeftPanel();
|
|
147
|
-
statusMessage(`Opened project: ${
|
|
147
|
+
statusMessage(`Opened project: ${requireProjectState().name}`);
|
|
148
148
|
|
|
149
149
|
await openHomePage();
|
|
150
|
-
} catch (/** @type {
|
|
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
|
-
|
|
254
|
+
requireProjectState().dirs.clear();
|
|
255
255
|
await loadDirectory(".");
|
|
256
|
-
for (const dir of
|
|
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=${
|
|
267
|
+
value=${requireProjectState().searchQuery}
|
|
268
268
|
@input=${(/** @type {Event} */ e) => {
|
|
269
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
312
|
-
const isSelected =
|
|
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)
|
|
327
|
+
if (isExpanded) requireProjectState().expanded.delete(entry.path);
|
|
328
328
|
else {
|
|
329
|
-
|
|
330
|
-
if (!
|
|
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 (!
|
|
376
|
-
|
|
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 (
|
|
391
|
-
|
|
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 {
|
|
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 (
|
|
528
|
-
|
|
527
|
+
if (requireProjectState().selectedPath === entry.path) {
|
|
528
|
+
requireProjectState().selectedPath = newPath;
|
|
529
529
|
}
|
|
530
530
|
renderLeftPanel();
|
|
531
531
|
statusMessage(`Renamed to ${newName}`);
|
|
532
|
-
} catch (/** @type {
|
|
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 (
|
|
549
|
-
|
|
548
|
+
if (requireProjectState().selectedPath === entry.path) {
|
|
549
|
+
requireProjectState().selectedPath = null;
|
|
550
550
|
}
|
|
551
551
|
renderLeftPanel();
|
|
552
552
|
statusMessage(`Deleted ${entry.name}`);
|
|
553
|
-
} catch (/** @type {
|
|
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:
|
|
565
|
-
* commit: (s:
|
|
564
|
+
* S: import("../state.js").StudioState;
|
|
565
|
+
* commit: (s: import("../state.js").StudioState) => void;
|
|
566
566
|
* render: () => void;
|
|
567
|
-
* loadMarkdown: (source: string, handle:
|
|
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 {
|
|
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
|
-
|
|
615
|
+
requireProjectState().selectedPath = path;
|
|
616
616
|
|
|
617
617
|
ctx.render();
|
|
618
618
|
statusMessage(`Opened ${path}`);
|
|
619
|
-
} catch (/** @type {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|