@jxsuite/studio 0.28.5 → 0.30.1
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 +9368 -6586
- package/dist/studio.js.map +109 -83
- 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 +11 -10
- package/src/browse/browse-modal.ts +2 -2
- package/src/browse/browse.ts +20 -19
- package/src/canvas/canvas-diff.ts +3 -3
- package/src/canvas/canvas-helpers.ts +15 -2
- package/src/canvas/canvas-live-render.ts +168 -86
- package/src/canvas/canvas-patcher.ts +656 -0
- package/src/canvas/canvas-perf.ts +68 -0
- package/src/canvas/canvas-render.ts +135 -23
- package/src/canvas/canvas-subtree-render.ts +113 -0
- package/src/canvas/canvas-utils.ts +4 -0
- package/src/editor/component-inline-edit.ts +4 -35
- package/src/editor/context-menu.ts +96 -76
- package/src/editor/convert-to-component.ts +11 -2
- package/src/editor/convert-to-repeater.ts +4 -5
- package/src/editor/inline-edit.ts +9 -9
- package/src/editor/inline-format.ts +11 -11
- package/src/editor/merge-tags.ts +120 -0
- package/src/editor/shortcuts.ts +4 -4
- package/src/files/components.ts +37 -0
- package/src/files/file-ops.ts +28 -7
- package/src/files/files.ts +68 -24
- package/src/files/fs-events.ts +162 -0
- package/src/github/github-auth.ts +14 -2
- package/src/github/github-publish.ts +12 -2
- package/src/panels/activity-bar.ts +1 -1
- package/src/panels/ai-panel.ts +67 -39
- package/src/panels/block-action-bar.ts +72 -1
- package/src/panels/canvas-dnd.ts +55 -26
- package/src/panels/data-explorer.ts +5 -3
- package/src/panels/dnd.ts +14 -17
- package/src/panels/editors.ts +5 -23
- package/src/panels/elements-panel.ts +2 -12
- package/src/panels/events-panel.ts +1 -1
- package/src/panels/git-panel.ts +6 -6
- package/src/panels/head-panel.ts +1 -1
- package/src/panels/layers-panel.ts +177 -147
- package/src/panels/preview-render.ts +15 -0
- package/src/panels/properties-panel.ts +16 -27
- package/src/panels/quick-search.ts +2 -2
- package/src/panels/right-panel.ts +2 -6
- package/src/panels/shared.ts +3 -0
- package/src/panels/signals-panel.ts +43 -33
- package/src/panels/statusbar.ts +15 -6
- package/src/panels/style-panel.ts +3 -3
- package/src/panels/style-utils.ts +3 -3
- package/src/panels/stylebook-panel.ts +4 -4
- package/src/panels/tab-bar.ts +198 -0
- package/src/panels/tab-strip.ts +2 -2
- package/src/panels/toolbar.ts +6 -75
- package/src/platforms/devserver.ts +113 -35
- package/src/reactivity.ts +2 -0
- package/src/services/cem-export.ts +23 -2
- package/src/services/code-services.ts +16 -2
- package/src/services/monaco-setup.ts +2 -1
- package/src/settings/content-types-editor.ts +16 -16
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/settings/defs-editor.ts +14 -14
- package/src/settings/general-settings.ts +6 -6
- package/src/settings/head-editor.ts +2 -2
- package/src/site-context.ts +1 -1
- package/src/state.ts +66 -12
- package/src/store.ts +15 -3
- package/src/studio.ts +88 -35
- package/src/tabs/patch-ops.ts +143 -0
- package/src/tabs/tab.ts +15 -1
- package/src/tabs/transact.ts +454 -27
- package/src/types.ts +71 -1
- package/src/ui/color-selector.ts +7 -7
- package/src/ui/icons.ts +0 -30
- package/src/ui/media-picker.ts +2 -2
- package/src/ui/panel-resize.ts +14 -8
- package/src/ui/unit-selector.ts +5 -2
- package/src/ui/value-selector.ts +3 -3
- package/src/utils/canvas-media.ts +6 -6
- package/src/utils/edit-display.ts +125 -83
- package/src/utils/google-fonts.ts +1 -1
- package/src/utils/inherited-style.ts +3 -2
- package/src/utils/studio-utils.ts +1 -1
- package/src/view.ts +4 -1
package/src/files/file-ops.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { locateDocument } from "../services/code-services";
|
|
11
11
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
12
12
|
import { statusMessage } from "../panels/statusbar";
|
|
13
|
+
import { validateComponentSlots } from "../services/cem-export";
|
|
13
14
|
import { getPlatform } from "../platform";
|
|
14
15
|
import { activeTab, openTab } from "../workspace/workspace";
|
|
15
16
|
import { isEditing, stopEditing } from "../editor/inline-edit";
|
|
@@ -94,7 +95,7 @@ export async function openFile() {
|
|
|
94
95
|
sourceFormat: format.name,
|
|
95
96
|
});
|
|
96
97
|
} else if (name.endsWith(".json")) {
|
|
97
|
-
const document = JSON.parse(text)
|
|
98
|
+
const document = JSON.parse(text) as Record<string, unknown>;
|
|
98
99
|
openTab({ document, documentPath, fileHandle: handle, id: name });
|
|
99
100
|
} else {
|
|
100
101
|
throw noFormatError(name);
|
|
@@ -108,8 +109,8 @@ export async function openFile() {
|
|
|
108
109
|
showOpenFilePicker: (options?: unknown) => Promise<FileSystemFileHandle[]>;
|
|
109
110
|
}
|
|
110
111
|
).showOpenFilePicker({ types: pickerTypes });
|
|
111
|
-
const file = await handle
|
|
112
|
-
await handleSource(handle
|
|
112
|
+
const file = await handle!.getFile();
|
|
113
|
+
await handleSource(handle!.name, await file.text(), handle!);
|
|
113
114
|
} else {
|
|
114
115
|
// Fallback: file input
|
|
115
116
|
const input = document.createElement("input");
|
|
@@ -131,6 +132,26 @@ export async function openFile() {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Report a slot-validation warning for component documents (root tagName with a hyphen). The save
|
|
137
|
+
* still proceeds — the warning replaces the plain success message.
|
|
138
|
+
*
|
|
139
|
+
* @param {Tab} tab
|
|
140
|
+
* @param {string} savedMsg
|
|
141
|
+
*/
|
|
142
|
+
function savedMessage(tab: Tab, savedMsg: string) {
|
|
143
|
+
const doc = tab.doc.document;
|
|
144
|
+
const warning =
|
|
145
|
+
typeof doc.tagName === "string" && doc.tagName.includes("-")
|
|
146
|
+
? validateComponentSlots(doc)
|
|
147
|
+
: null;
|
|
148
|
+
if (warning) {
|
|
149
|
+
statusMessage(`${savedMsg} — Warning: ${warning}`, 6000);
|
|
150
|
+
} else {
|
|
151
|
+
statusMessage(savedMsg);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
134
155
|
/** Save the current document back to its source location. */
|
|
135
156
|
export async function saveFile() {
|
|
136
157
|
if (isEditing()) {
|
|
@@ -147,7 +168,7 @@ export async function saveFile() {
|
|
|
147
168
|
const platform = getPlatform();
|
|
148
169
|
await platform.writeFile(tab.documentPath, output);
|
|
149
170
|
tab.doc.dirty = false;
|
|
150
|
-
|
|
171
|
+
savedMessage(tab, "Saved");
|
|
151
172
|
} else if (tab.fileHandle && "createWritable" in tab.fileHandle) {
|
|
152
173
|
const writable =
|
|
153
174
|
await /**
|
|
@@ -161,7 +182,7 @@ export async function saveFile() {
|
|
|
161
182
|
await writable.write(output);
|
|
162
183
|
await writable.close();
|
|
163
184
|
tab.doc.dirty = false;
|
|
164
|
-
|
|
185
|
+
savedMessage(tab, "Saved");
|
|
165
186
|
} else {
|
|
166
187
|
statusMessage("No save target — use Export");
|
|
167
188
|
}
|
|
@@ -209,7 +230,7 @@ export async function exportFile() {
|
|
|
209
230
|
await writable.write(output);
|
|
210
231
|
await writable.close();
|
|
211
232
|
tab.doc.dirty = false;
|
|
212
|
-
|
|
233
|
+
savedMessage(tab, `Exported as ${handle.name}`);
|
|
213
234
|
} else {
|
|
214
235
|
// Fallback: download
|
|
215
236
|
const blob = new Blob([output], { type: mimeType });
|
|
@@ -220,7 +241,7 @@ export async function exportFile() {
|
|
|
220
241
|
a.click();
|
|
221
242
|
URL.revokeObjectURL(url);
|
|
222
243
|
tab.doc.dirty = false;
|
|
223
|
-
|
|
244
|
+
savedMessage(tab, "Downloaded");
|
|
224
245
|
}
|
|
225
246
|
} catch (error) {
|
|
226
247
|
if (!(error instanceof Error && error.name === "AbortError")) {
|
package/src/files/files.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { createState, projectState, requireProjectState, setProjectState } from
|
|
|
16
16
|
import { getPlatform } from "../platform";
|
|
17
17
|
import { statusMessage } from "../panels/statusbar";
|
|
18
18
|
import { loadComponentRegistry } from "./components";
|
|
19
|
+
import { markLocalMutation } from "./fs-events";
|
|
19
20
|
import {
|
|
20
21
|
draggable,
|
|
21
22
|
dropTargetForElements,
|
|
@@ -41,8 +42,10 @@ import {
|
|
|
41
42
|
import { view } from "../view";
|
|
42
43
|
import { addRecentProject, trackRecentFile } from "../recent-projects";
|
|
43
44
|
import type { TemplateResult } from "lit-html";
|
|
45
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
44
46
|
import type { StudioState } from "../state.js";
|
|
45
47
|
import type { Tab } from "../tabs/tab.js";
|
|
48
|
+
import type { RenameResult } from "../types";
|
|
46
49
|
|
|
47
50
|
// ─── File icon map ────────────────────────────────────────────────────────────
|
|
48
51
|
|
|
@@ -339,7 +342,7 @@ function renderTreeLevelTemplate(
|
|
|
339
342
|
): TemplateResult | TemplateResult[] {
|
|
340
343
|
const entries = requireProjectState().dirs.get(dirPath);
|
|
341
344
|
if (!entries) {
|
|
342
|
-
loadDirectory(dirPath).then(() => ctx.renderLeftPanel());
|
|
345
|
+
void loadDirectory(dirPath).then(() => ctx.renderLeftPanel());
|
|
343
346
|
return html`<div
|
|
344
347
|
class="file-tree-item"
|
|
345
348
|
style="padding-left:${8 + depth * 16}px;color:var(--fg-dim);font-style:italic"
|
|
@@ -427,13 +430,13 @@ export function setupTreeKeyboard(tree: HTMLElement) {
|
|
|
427
430
|
switch (e.key) {
|
|
428
431
|
case "ArrowDown": {
|
|
429
432
|
if (idx < items.length - 1) {
|
|
430
|
-
items[idx + 1]
|
|
433
|
+
items[idx + 1]!.focus();
|
|
431
434
|
}
|
|
432
435
|
break;
|
|
433
436
|
}
|
|
434
437
|
case "ArrowUp": {
|
|
435
438
|
if (idx > 0) {
|
|
436
|
-
items[idx - 1]
|
|
439
|
+
items[idx - 1]!.focus();
|
|
437
440
|
}
|
|
438
441
|
break;
|
|
439
442
|
}
|
|
@@ -442,7 +445,7 @@ export function setupTreeKeyboard(tree: HTMLElement) {
|
|
|
442
445
|
const path = focused.dataset.path as string;
|
|
443
446
|
if (!requireProjectState().expanded.has(path)) {
|
|
444
447
|
requireProjectState().expanded.add(path);
|
|
445
|
-
loadDirectory(path).then(() => {
|
|
448
|
+
void loadDirectory(path).then(() => {
|
|
446
449
|
const panel = tree.closest(".panel-body");
|
|
447
450
|
if (panel) {
|
|
448
451
|
(panel.querySelector(".file-tree-item:focus") as HTMLElement | null)?.click();
|
|
@@ -619,7 +622,7 @@ export function registerFileTreeDnD({ renderLeftPanel }: { renderLeftPanel: () =
|
|
|
619
622
|
return;
|
|
620
623
|
}
|
|
621
624
|
|
|
622
|
-
moveFileEntry(srcPath, newPath!, renderLeftPanel);
|
|
625
|
+
void moveFileEntry(srcPath, newPath!, renderLeftPanel);
|
|
623
626
|
},
|
|
624
627
|
});
|
|
625
628
|
_fileTreeDndCleanups.push(monitorCleanup);
|
|
@@ -635,8 +638,9 @@ export function registerFileTreeDnD({ renderLeftPanel }: { renderLeftPanel: () =
|
|
|
635
638
|
*/
|
|
636
639
|
async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel: () => void) {
|
|
637
640
|
const platform = getPlatform();
|
|
641
|
+
markLocalMutation(oldPath, newPath);
|
|
638
642
|
try {
|
|
639
|
-
await platform.renameFile(oldPath, newPath);
|
|
643
|
+
const report = await platform.renameFile(oldPath, newPath);
|
|
640
644
|
|
|
641
645
|
// Update open tabs referencing the moved path
|
|
642
646
|
for (const [id] of workspace.tabs.entries()) {
|
|
@@ -661,6 +665,7 @@ async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel:
|
|
|
661
665
|
requireProjectState().expanded.add(newParent);
|
|
662
666
|
}
|
|
663
667
|
|
|
668
|
+
reloadRewrittenTabs(report, newPath);
|
|
664
669
|
renderLeftPanel();
|
|
665
670
|
statusMessage(`Moved to ${newPath}`);
|
|
666
671
|
} catch (error) {
|
|
@@ -707,16 +712,18 @@ function showFileContextMenu(
|
|
|
707
712
|
label: "New File\u2026",
|
|
708
713
|
});
|
|
709
714
|
}
|
|
710
|
-
items.push(
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
715
|
+
items.push(
|
|
716
|
+
{ label: "\u2014" },
|
|
717
|
+
{
|
|
718
|
+
action: () => renameFile(entry, ctx.renderLeftPanel),
|
|
719
|
+
label: "Rename\u2026",
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
action: () => deleteFile(entry, ctx.renderLeftPanel),
|
|
723
|
+
danger: true,
|
|
724
|
+
label: "Delete",
|
|
725
|
+
},
|
|
726
|
+
);
|
|
720
727
|
|
|
721
728
|
let x = e.clientX,
|
|
722
729
|
y = e.clientY;
|
|
@@ -751,7 +758,7 @@ function showFileContextMenu(
|
|
|
751
758
|
style=${item.danger ? "color: var(--danger)" : ""}
|
|
752
759
|
@click=${() => {
|
|
753
760
|
dismissFileContextMenu();
|
|
754
|
-
item.action?.();
|
|
761
|
+
void item.action?.();
|
|
755
762
|
}}
|
|
756
763
|
>${item.label}</sp-menu-item
|
|
757
764
|
>`,
|
|
@@ -776,6 +783,7 @@ async function createNewFile(dirPath: string, renderLeftPanel: () => void) {
|
|
|
776
783
|
return;
|
|
777
784
|
}
|
|
778
785
|
const path = dirPath === "." ? name : `${dirPath}/${name}`;
|
|
786
|
+
markLocalMutation(path);
|
|
779
787
|
await loadFormats();
|
|
780
788
|
const format = formatForPath(name);
|
|
781
789
|
const content =
|
|
@@ -846,6 +854,28 @@ function showRenameFileDialog(currentName: string): Promise<string | null> {
|
|
|
846
854
|
});
|
|
847
855
|
}
|
|
848
856
|
|
|
857
|
+
/** Build the status-bar message for a rename, summarising any reference/tag rewrites. */
|
|
858
|
+
function renameStatus(newName: string, report: RenameResult): string {
|
|
859
|
+
const refs = report.references;
|
|
860
|
+
const tagNote = report.tag ? `; tag → <${report.tag.to}> (${report.tag.refsUpdated})` : "";
|
|
861
|
+
if (refs && refs.refsUpdated > 0) {
|
|
862
|
+
return `Renamed to ${newName}; updated ${refs.refsUpdated} reference(s) in ${refs.filesChanged} file(s)${tagNote}`;
|
|
863
|
+
}
|
|
864
|
+
if (tagNote) {
|
|
865
|
+
return `Renamed to ${newName}${tagNote}`;
|
|
866
|
+
}
|
|
867
|
+
return `Renamed to ${newName}`;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Reload any open tabs whose references the refactor rewrote (so the editor shows new paths). */
|
|
871
|
+
function reloadRewrittenTabs(report: RenameResult, skipPath: string): void {
|
|
872
|
+
for (const f of report.references?.files ?? []) {
|
|
873
|
+
if (f.path !== skipPath && workspace.tabs.has(f.path)) {
|
|
874
|
+
void reloadFileInTab(f.path);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
849
879
|
async function renameFile(
|
|
850
880
|
entry: { name: string; path: string; type: string },
|
|
851
881
|
renderLeftPanel: () => void,
|
|
@@ -859,9 +889,10 @@ async function renameFile(
|
|
|
859
889
|
? entryPath.slice(0, entryPath.lastIndexOf("/"))
|
|
860
890
|
: ".";
|
|
861
891
|
const newPath = parentDirPath === "." ? newName : `${parentDirPath}/${newName}`;
|
|
892
|
+
markLocalMutation(entry.path, newPath);
|
|
862
893
|
try {
|
|
863
894
|
const platform = getPlatform();
|
|
864
|
-
await platform.renameFile(entry.path, newPath);
|
|
895
|
+
const report = await platform.renameFile(entry.path, newPath);
|
|
865
896
|
await loadDirectory(parentDirPath);
|
|
866
897
|
if (requireProjectState().selectedPath === entry.path) {
|
|
867
898
|
requireProjectState().selectedPath = newPath;
|
|
@@ -869,8 +900,9 @@ async function renameFile(
|
|
|
869
900
|
if (workspace.tabs.has(entry.path)) {
|
|
870
901
|
renameTab(entry.path, newPath, newPath);
|
|
871
902
|
}
|
|
903
|
+
reloadRewrittenTabs(report, newPath);
|
|
872
904
|
renderLeftPanel();
|
|
873
|
-
statusMessage(
|
|
905
|
+
statusMessage(renameStatus(newName, report));
|
|
874
906
|
} catch (error) {
|
|
875
907
|
statusMessage(`Error: ${errorMessage(error)}`);
|
|
876
908
|
}
|
|
@@ -889,6 +921,7 @@ async function deleteFile(
|
|
|
889
921
|
}
|
|
890
922
|
try {
|
|
891
923
|
const platform = getPlatform();
|
|
924
|
+
markLocalMutation(entry.path);
|
|
892
925
|
await platform.deleteFile(entry.path);
|
|
893
926
|
const delPath = entry.path.replaceAll("\\", "/");
|
|
894
927
|
const parentDirPath = delPath.includes("/") ? delPath.slice(0, delPath.lastIndexOf("/")) : ".";
|
|
@@ -953,12 +986,12 @@ export async function openFileFromTree(
|
|
|
953
986
|
}
|
|
954
987
|
|
|
955
988
|
if (formatForPath(path)) {
|
|
956
|
-
|
|
989
|
+
ctx.loadMarkdown(content, null);
|
|
957
990
|
ctx.S.documentPath = path;
|
|
958
991
|
ctx.S.dirty = false;
|
|
959
992
|
ctx.commit(ctx.S);
|
|
960
993
|
} else if (path.endsWith(".json")) {
|
|
961
|
-
const doc = JSON.parse(content);
|
|
994
|
+
const doc = JSON.parse(content) as JxMutableNode;
|
|
962
995
|
const newS = createState(doc);
|
|
963
996
|
newS.documentPath = path;
|
|
964
997
|
newS.dirty = false;
|
|
@@ -999,14 +1032,15 @@ export async function openFileInTab(path: string) {
|
|
|
999
1032
|
}
|
|
1000
1033
|
|
|
1001
1034
|
await loadFormats();
|
|
1002
|
-
let document,
|
|
1035
|
+
let document: Record<string, unknown>;
|
|
1036
|
+
let frontmatter: Record<string, unknown> | undefined;
|
|
1003
1037
|
const format = formatForPath(path);
|
|
1004
1038
|
if (format) {
|
|
1005
1039
|
const result = await parseSourceForPath(path, content);
|
|
1006
1040
|
({ document } = result);
|
|
1007
1041
|
({ frontmatter } = result);
|
|
1008
1042
|
} else if (path.endsWith(".json")) {
|
|
1009
|
-
document = JSON.parse(content)
|
|
1043
|
+
document = JSON.parse(content) as Record<string, unknown>;
|
|
1010
1044
|
} else {
|
|
1011
1045
|
throw noFormatError(path);
|
|
1012
1046
|
}
|
|
@@ -1041,6 +1075,16 @@ export async function openFileInTab(path: string) {
|
|
|
1041
1075
|
*
|
|
1042
1076
|
* @param {string} path
|
|
1043
1077
|
*/
|
|
1078
|
+
/** Reload an open tab from disk when an external change arrives — but only if it is not dirty. */
|
|
1079
|
+
export function reloadCleanTab(path: string): void {
|
|
1080
|
+
for (const [, tab] of workspace.tabs.entries()) {
|
|
1081
|
+
if (tab.documentPath === path && !tab.doc.dirty) {
|
|
1082
|
+
void reloadFileInTab(path);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1044
1088
|
export async function reloadFileInTab(path: string) {
|
|
1045
1089
|
for (const [, tab] of workspace.tabs.entries()) {
|
|
1046
1090
|
if (tab.documentPath === path) {
|
|
@@ -1056,7 +1100,7 @@ export async function reloadFileInTab(path: string) {
|
|
|
1056
1100
|
tab.doc.document = document;
|
|
1057
1101
|
tab.doc.content.frontmatter = frontmatter;
|
|
1058
1102
|
} else if (path.endsWith(".json")) {
|
|
1059
|
-
tab.doc.document = JSON.parse(content);
|
|
1103
|
+
tab.doc.document = JSON.parse(content) as JxMutableNode;
|
|
1060
1104
|
}
|
|
1061
1105
|
tab.doc.dirty = false;
|
|
1062
1106
|
} catch {}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Fs-events.ts — reconcile backend filesystem events into the cached sidebar tree.
|
|
4
|
+
*
|
|
5
|
+
* `applyFsEvents` is a pure, in-place reducer over `projectState.dirs`/`expanded` (unit-testable
|
|
6
|
+
* without a DOM). `startFsSync` is the only impure part: it feature-detects the platform's
|
|
7
|
+
* `subscribeFileEvents`, debounces bursts, drops the echoes of the user's own local mutations, and
|
|
8
|
+
* triggers a single re-render through the existing left-panel render path (no imperative DOM).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getPlatform } from "../platform";
|
|
12
|
+
import { projectState } from "../store";
|
|
13
|
+
import type { DirEntry, FsEvent } from "../types";
|
|
14
|
+
|
|
15
|
+
const RECENT_MS = 1500;
|
|
16
|
+
const recentLocal = new Map<string, number>();
|
|
17
|
+
|
|
18
|
+
const norm = (p: string) => p.replaceAll("\\", "/");
|
|
19
|
+
|
|
20
|
+
/** Mark paths the user just mutated locally so the watcher's echo of them is ignored briefly. */
|
|
21
|
+
export function markLocalMutation(...paths: string[]): void {
|
|
22
|
+
const expiry = Date.now() + RECENT_MS;
|
|
23
|
+
for (const p of paths) {
|
|
24
|
+
if (p) {
|
|
25
|
+
recentLocal.set(norm(p), expiry);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True while a path is within the recent-local-mutation window (self-cleans on expiry). */
|
|
31
|
+
export function isRecentLocal(path: string): boolean {
|
|
32
|
+
const key = norm(path);
|
|
33
|
+
const expiry = recentLocal.get(key);
|
|
34
|
+
if (expiry === undefined) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (Date.now() > expiry) {
|
|
38
|
+
recentLocal.delete(key);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parentDir(path: string): string {
|
|
45
|
+
const i = path.lastIndexOf("/");
|
|
46
|
+
return i === -1 ? "." : path.slice(0, i);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function baseName(path: string): string {
|
|
50
|
+
const i = path.lastIndexOf("/");
|
|
51
|
+
return i === -1 ? path : path.slice(i + 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Apply backend FS events to the cached directory tree in place, returning the set of directories
|
|
56
|
+
* whose contents changed. Idempotent — re-adding an existing entry or removing an absent one is a
|
|
57
|
+
* no-op, which absorbs watcher echoes. `change` events touch no tree state (name/type are stable).
|
|
58
|
+
*/
|
|
59
|
+
export function applyFsEvents(
|
|
60
|
+
dirs: Map<string, DirEntry[]>,
|
|
61
|
+
expanded: Set<string>,
|
|
62
|
+
events: FsEvent[],
|
|
63
|
+
): Set<string> {
|
|
64
|
+
const changedDirs = new Set<string>();
|
|
65
|
+
for (const ev of events) {
|
|
66
|
+
const parent = parentDir(ev.path);
|
|
67
|
+
if (ev.type === "add" || ev.type === "addDir") {
|
|
68
|
+
const entries = dirs.get(parent);
|
|
69
|
+
if (entries && !entries.some((e) => e.path === ev.path)) {
|
|
70
|
+
entries.push({
|
|
71
|
+
name: baseName(ev.path),
|
|
72
|
+
path: ev.path,
|
|
73
|
+
type: ev.isDir ? "directory" : "file",
|
|
74
|
+
});
|
|
75
|
+
changedDirs.add(parent);
|
|
76
|
+
}
|
|
77
|
+
if (ev.type === "addDir" && !dirs.has(ev.path)) {
|
|
78
|
+
dirs.set(ev.path, []);
|
|
79
|
+
}
|
|
80
|
+
} else if (ev.type === "unlink" || ev.type === "unlinkDir") {
|
|
81
|
+
const entries = dirs.get(parent);
|
|
82
|
+
if (entries) {
|
|
83
|
+
const idx = entries.findIndex((e) => e.path === ev.path);
|
|
84
|
+
if (idx !== -1) {
|
|
85
|
+
entries.splice(idx, 1);
|
|
86
|
+
changedDirs.add(parent);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (ev.type === "unlinkDir") {
|
|
90
|
+
dirs.delete(ev.path);
|
|
91
|
+
const nestedDirs: string[] = [];
|
|
92
|
+
for (const key of dirs.keys()) {
|
|
93
|
+
if (key.startsWith(`${ev.path}/`)) {
|
|
94
|
+
nestedDirs.push(key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const key of nestedDirs) {
|
|
98
|
+
dirs.delete(key);
|
|
99
|
+
}
|
|
100
|
+
const staleExpanded: string[] = [];
|
|
101
|
+
for (const key of expanded) {
|
|
102
|
+
if (key === ev.path || key.startsWith(`${ev.path}/`)) {
|
|
103
|
+
staleExpanded.push(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const key of staleExpanded) {
|
|
107
|
+
expanded.delete(key);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return changedDirs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface FsSyncContext {
|
|
116
|
+
renderLeftPanel: () => void;
|
|
117
|
+
/** Optional hook for an external content change to an open file (e.g. reload a clean tab). */
|
|
118
|
+
onContentChange?: (path: string) => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Subscribe the sidebar to backend filesystem events. Returns an unsubscribe function; a no-op when
|
|
123
|
+
* the platform has no watcher (desktop without one, or tests). Bursts are debounced into one
|
|
124
|
+
* render.
|
|
125
|
+
*/
|
|
126
|
+
export function startFsSync(ctx: FsSyncContext): () => void {
|
|
127
|
+
const platform = getPlatform();
|
|
128
|
+
if (!platform.subscribeFileEvents) {
|
|
129
|
+
return () => {};
|
|
130
|
+
}
|
|
131
|
+
let pending: FsEvent[] = [];
|
|
132
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
133
|
+
|
|
134
|
+
const flush = () => {
|
|
135
|
+
timer = null;
|
|
136
|
+
const state = projectState;
|
|
137
|
+
const batch = pending.filter((e) => !isRecentLocal(e.path));
|
|
138
|
+
pending = [];
|
|
139
|
+
if (!state || batch.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const changedDirs = applyFsEvents(state.dirs, state.expanded, batch);
|
|
143
|
+
if (ctx.onContentChange) {
|
|
144
|
+
for (const ev of batch) {
|
|
145
|
+
if (ev.type === "change") {
|
|
146
|
+
ctx.onContentChange(ev.path);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (changedDirs.size > 0) {
|
|
151
|
+
ctx.renderLeftPanel();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return platform.subscribeFileEvents((events) => {
|
|
156
|
+
pending.push(...events);
|
|
157
|
+
if (timer) {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
timer = setTimeout(flush, 50);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -12,6 +12,18 @@ import { showDialog } from "../ui/layers";
|
|
|
12
12
|
const CLIENT_ID = "Ov23liYVlMFpgjOEPXJH";
|
|
13
13
|
const STORAGE_KEY = "jx_github_token";
|
|
14
14
|
|
|
15
|
+
interface DeviceCodeResponse {
|
|
16
|
+
device_code: string;
|
|
17
|
+
user_code: string;
|
|
18
|
+
verification_uri: string;
|
|
19
|
+
interval?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TokenResponse {
|
|
23
|
+
access_token?: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export function getGithubToken() {
|
|
16
28
|
return localStorage.getItem(STORAGE_KEY);
|
|
17
29
|
}
|
|
@@ -44,7 +56,7 @@ export async function authenticateGithub() {
|
|
|
44
56
|
if (!codeRes.ok) {
|
|
45
57
|
throw new Error("Failed to initiate GitHub device flow");
|
|
46
58
|
}
|
|
47
|
-
const codeData = await codeRes.json();
|
|
59
|
+
const codeData = (await codeRes.json()) as DeviceCodeResponse;
|
|
48
60
|
|
|
49
61
|
const { device_code, user_code, verification_uri, interval = 5 } = codeData;
|
|
50
62
|
|
|
@@ -69,7 +81,7 @@ export async function authenticateGithub() {
|
|
|
69
81
|
},
|
|
70
82
|
method: "POST",
|
|
71
83
|
});
|
|
72
|
-
const tokenData = await tokenRes.json();
|
|
84
|
+
const tokenData = (await tokenRes.json()) as TokenResponse;
|
|
73
85
|
|
|
74
86
|
if (tokenData.access_token) {
|
|
75
87
|
localStorage.setItem(STORAGE_KEY, tokenData.access_token);
|
|
@@ -9,6 +9,16 @@ import { authenticateGithub } from "./github-auth";
|
|
|
9
9
|
import { getPlatform } from "../platform";
|
|
10
10
|
import { statusMessage } from "../panels/statusbar";
|
|
11
11
|
|
|
12
|
+
interface GithubErrorResponse {
|
|
13
|
+
errors?: { message?: string }[];
|
|
14
|
+
message?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GithubRepoResponse {
|
|
18
|
+
clone_url: string;
|
|
19
|
+
html_url: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/**
|
|
13
23
|
* Full "Publish to GitHub" flow: 1. Authenticate (or reuse stored token) 2. Prompt for repo name /
|
|
14
24
|
* visibility 3. Create the repo via GitHub API 4. Add remote + push
|
|
@@ -105,13 +115,13 @@ export async function publishToGithub({ projectName }: { projectName: string })
|
|
|
105
115
|
});
|
|
106
116
|
|
|
107
117
|
if (!createRes.ok) {
|
|
108
|
-
const err = await createRes.json();
|
|
118
|
+
const err = (await createRes.json()) as GithubErrorResponse;
|
|
109
119
|
const msg = err.errors?.[0]?.message || err.message || "Failed to create repository";
|
|
110
120
|
statusMessage(`Error: ${msg}`);
|
|
111
121
|
return false;
|
|
112
122
|
}
|
|
113
123
|
|
|
114
|
-
const repo = await createRes.json();
|
|
124
|
+
const repo = (await createRes.json()) as GithubRepoResponse;
|
|
115
125
|
const platform = getPlatform();
|
|
116
126
|
|
|
117
127
|
statusMessage("Setting remote and pushing…");
|