@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.
Files changed (86) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +9368 -6586
  3. package/dist/studio.js.map +109 -83
  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 +11 -10
  8. package/src/browse/browse-modal.ts +2 -2
  9. package/src/browse/browse.ts +20 -19
  10. package/src/canvas/canvas-diff.ts +3 -3
  11. package/src/canvas/canvas-helpers.ts +15 -2
  12. package/src/canvas/canvas-live-render.ts +168 -86
  13. package/src/canvas/canvas-patcher.ts +656 -0
  14. package/src/canvas/canvas-perf.ts +68 -0
  15. package/src/canvas/canvas-render.ts +135 -23
  16. package/src/canvas/canvas-subtree-render.ts +113 -0
  17. package/src/canvas/canvas-utils.ts +4 -0
  18. package/src/editor/component-inline-edit.ts +4 -35
  19. package/src/editor/context-menu.ts +96 -76
  20. package/src/editor/convert-to-component.ts +11 -2
  21. package/src/editor/convert-to-repeater.ts +4 -5
  22. package/src/editor/inline-edit.ts +9 -9
  23. package/src/editor/inline-format.ts +11 -11
  24. package/src/editor/merge-tags.ts +120 -0
  25. package/src/editor/shortcuts.ts +4 -4
  26. package/src/files/components.ts +37 -0
  27. package/src/files/file-ops.ts +28 -7
  28. package/src/files/files.ts +68 -24
  29. package/src/files/fs-events.ts +162 -0
  30. package/src/github/github-auth.ts +14 -2
  31. package/src/github/github-publish.ts +12 -2
  32. package/src/panels/activity-bar.ts +1 -1
  33. package/src/panels/ai-panel.ts +67 -39
  34. package/src/panels/block-action-bar.ts +72 -1
  35. package/src/panels/canvas-dnd.ts +55 -26
  36. package/src/panels/data-explorer.ts +5 -3
  37. package/src/panels/dnd.ts +14 -17
  38. package/src/panels/editors.ts +5 -23
  39. package/src/panels/elements-panel.ts +2 -12
  40. package/src/panels/events-panel.ts +1 -1
  41. package/src/panels/git-panel.ts +6 -6
  42. package/src/panels/head-panel.ts +1 -1
  43. package/src/panels/layers-panel.ts +177 -147
  44. package/src/panels/preview-render.ts +15 -0
  45. package/src/panels/properties-panel.ts +16 -27
  46. package/src/panels/quick-search.ts +2 -2
  47. package/src/panels/right-panel.ts +2 -6
  48. package/src/panels/shared.ts +3 -0
  49. package/src/panels/signals-panel.ts +43 -33
  50. package/src/panels/statusbar.ts +15 -6
  51. package/src/panels/style-panel.ts +3 -3
  52. package/src/panels/style-utils.ts +3 -3
  53. package/src/panels/stylebook-panel.ts +4 -4
  54. package/src/panels/tab-bar.ts +198 -0
  55. package/src/panels/tab-strip.ts +2 -2
  56. package/src/panels/toolbar.ts +6 -75
  57. package/src/platforms/devserver.ts +113 -35
  58. package/src/reactivity.ts +2 -0
  59. package/src/services/cem-export.ts +23 -2
  60. package/src/services/code-services.ts +16 -2
  61. package/src/services/monaco-setup.ts +2 -1
  62. package/src/settings/content-types-editor.ts +16 -16
  63. package/src/settings/css-vars-editor.ts +2 -2
  64. package/src/settings/defs-editor.ts +14 -14
  65. package/src/settings/general-settings.ts +6 -6
  66. package/src/settings/head-editor.ts +2 -2
  67. package/src/site-context.ts +1 -1
  68. package/src/state.ts +66 -12
  69. package/src/store.ts +15 -3
  70. package/src/studio.ts +88 -35
  71. package/src/tabs/patch-ops.ts +143 -0
  72. package/src/tabs/tab.ts +15 -1
  73. package/src/tabs/transact.ts +454 -27
  74. package/src/types.ts +71 -1
  75. package/src/ui/color-selector.ts +7 -7
  76. package/src/ui/icons.ts +0 -30
  77. package/src/ui/media-picker.ts +2 -2
  78. package/src/ui/panel-resize.ts +14 -8
  79. package/src/ui/unit-selector.ts +5 -2
  80. package/src/ui/value-selector.ts +3 -3
  81. package/src/utils/canvas-media.ts +6 -6
  82. package/src/utils/edit-display.ts +125 -83
  83. package/src/utils/google-fonts.ts +1 -1
  84. package/src/utils/inherited-style.ts +3 -2
  85. package/src/utils/studio-utils.ts +1 -1
  86. package/src/view.ts +4 -1
@@ -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.getFile();
112
- await handleSource(handle.name, await file.text(), 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
- statusMessage("Saved");
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
- statusMessage("Saved");
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
- statusMessage(`Exported as ${handle.name}`);
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
- statusMessage("Downloaded");
244
+ savedMessage(tab, "Downloaded");
224
245
  }
225
246
  } catch (error) {
226
247
  if (!(error instanceof Error && error.name === "AbortError")) {
@@ -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].focus();
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].focus();
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({ label: "\u2014" });
711
- items.push({
712
- action: () => renameFile(entry, ctx.renderLeftPanel),
713
- label: "Rename\u2026",
714
- });
715
- items.push({
716
- action: () => deleteFile(entry, ctx.renderLeftPanel),
717
- danger: true,
718
- label: "Delete",
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(`Renamed to ${newName}`);
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
- await ctx.loadMarkdown(content, null);
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, frontmatter;
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…");
@@ -21,7 +21,7 @@ export function mount() {
21
21
  if (tab) {
22
22
  const gs = tab.session.ui.gitStatus;
23
23
  if (!gs && !tab.session.ui.gitLoading) {
24
- refreshGitStatus();
24
+ void refreshGitStatus();
25
25
  }
26
26
  }
27
27
  renderActivityBar();