@jxsuite/studio 0.11.0 → 0.14.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 (55) hide show
  1. package/dist/studio.js +6517 -5870
  2. package/dist/studio.js.map +57 -51
  3. package/package.json +5 -3
  4. package/src/canvas/canvas-diff.js +184 -0
  5. package/src/canvas/canvas-helpers.js +10 -14
  6. package/src/canvas/canvas-live-render.js +28 -2
  7. package/src/canvas/canvas-render.js +170 -20
  8. package/src/canvas/canvas-utils.js +22 -25
  9. package/src/editor/component-inline-edit.js +55 -44
  10. package/src/editor/content-inline-edit.js +47 -50
  11. package/src/editor/context-menu.js +78 -53
  12. package/src/editor/convert-to-component.js +11 -14
  13. package/src/editor/insertion-helper.js +8 -11
  14. package/src/editor/shortcuts.js +88 -64
  15. package/src/files/components.js +15 -4
  16. package/src/files/file-ops.js +57 -108
  17. package/src/files/files.js +81 -28
  18. package/src/panels/activity-bar.js +31 -7
  19. package/src/panels/block-action-bar.js +104 -80
  20. package/src/panels/canvas-dnd.js +4 -10
  21. package/src/panels/dnd.js +77 -35
  22. package/src/panels/editors.js +17 -26
  23. package/src/panels/elements-panel.js +17 -11
  24. package/src/panels/events-panel.js +44 -39
  25. package/src/panels/git-panel.js +47 -4
  26. package/src/panels/layers-panel.js +25 -21
  27. package/src/panels/left-panel.js +109 -44
  28. package/src/panels/overlays.js +91 -41
  29. package/src/panels/panel-events.js +28 -37
  30. package/src/panels/preview-render.js +7 -3
  31. package/src/panels/properties-panel.js +179 -104
  32. package/src/panels/pseudo-preview.js +9 -8
  33. package/src/panels/right-panel.js +85 -37
  34. package/src/panels/shared.js +0 -22
  35. package/src/panels/signals-panel.js +125 -54
  36. package/src/panels/statusbar.js +26 -19
  37. package/src/panels/style-inputs.js +5 -4
  38. package/src/panels/style-panel.js +128 -105
  39. package/src/panels/style-utils.js +8 -6
  40. package/src/panels/stylebook-layers-panel.js +5 -5
  41. package/src/panels/stylebook-panel.js +27 -31
  42. package/src/panels/tab-strip.js +124 -0
  43. package/src/panels/toolbar.js +40 -10
  44. package/src/reactivity.js +28 -0
  45. package/src/settings/content-types-editor.js +56 -7
  46. package/src/settings/defs-editor.js +56 -7
  47. package/src/settings/schema-field-ui.js +60 -25
  48. package/src/state.js +2 -459
  49. package/src/store.js +61 -219
  50. package/src/studio.js +163 -300
  51. package/src/tabs/tab.js +168 -0
  52. package/src/tabs/transact.js +406 -0
  53. package/src/ui/color-selector.js +4 -5
  54. package/src/view.js +3 -0
  55. package/src/workspace/workspace.js +90 -0
@@ -3,8 +3,19 @@
3
3
  import { getPlatform } from "../platform.js";
4
4
  import { projectState } from "../store.js";
5
5
 
6
- /** @type {any[]} */
7
- export let componentRegistry = []; // cached list from /__studio/components
6
+ /**
7
+ * @typedef {{
8
+ * tagName: string;
9
+ * path: string;
10
+ * props?: { name: string; type?: string; [k: string]: any }[];
11
+ * source?: string;
12
+ * package?: string;
13
+ * modulePath?: string;
14
+ * }} ComponentEntry
15
+ */
16
+
17
+ /** @type {ComponentEntry[]} */
18
+ export let componentRegistry = [];
8
19
  export let _componentRegistryLoaded = false;
9
20
 
10
21
  export async function loadComponentRegistry() {
@@ -18,8 +29,8 @@ export async function loadComponentRegistry() {
18
29
  }
19
30
 
20
31
  /**
21
- * @param {any} fromDocPath
22
- * @param {any} toCompPath
32
+ * @param {string | null} fromDocPath
33
+ * @param {string} toCompPath
23
34
  */
24
35
  export function computeRelativePath(fromDocPath, toCompPath) {
25
36
  if (!fromDocPath) return `./${toCompPath}`;
@@ -1,8 +1,7 @@
1
1
  /**
2
- * File Operations — open, load, save documents.
2
+ * File Operations — open, save, export documents.
3
3
  *
4
- * Each function that mutates state accepts a `commit(newState)` callback so the caller (studio.js)
5
- * can assign S and trigger render().
4
+ * All functions read/write directly from/to `activeTab.value` (the reactive tab).
6
5
  */
7
6
 
8
7
  import { unified } from "unified";
@@ -10,17 +9,13 @@ import remarkStringify from "remark-stringify";
10
9
  import remarkDirective from "remark-directive";
11
10
  import { stringify as stringifyYaml } from "yaml";
12
11
  import { jxToMd, jxDocToMd } from "../markdown/md-convert.js";
13
- import { createState } from "../store.js";
14
12
  import { locateDocument } from "../services/code-services.js";
15
13
  import { statusMessage } from "../panels/statusbar.js";
16
14
  import { getPlatform } from "../platform.js";
15
+ import { activeTab, openTab } from "../workspace/workspace.js";
17
16
 
18
- /**
19
- * Open a file via the File System Access API (or fallback input).
20
- *
21
- * @param {{ S: any; commit: (s: any) => void; renderToolbar: () => void }} ctx
22
- */
23
- export async function openFile({ S: _S, commit, renderToolbar: _renderToolbar }) {
17
+ /** Open a file via the File System Access API (or fallback input). */
18
+ export async function openFile() {
24
19
  try {
25
20
  if ("showOpenFilePicker" in window) {
26
21
  const [handle] = await /** @type {any} */ (window).showOpenFilePicker({
@@ -31,18 +26,21 @@ export async function openFile({ S: _S, commit, renderToolbar: _renderToolbar })
31
26
  });
32
27
  const file = await handle.getFile();
33
28
  const text = await file.text();
29
+ const documentPath = await locateDocument(handle.name);
34
30
 
35
31
  if (handle.name.endsWith(".md")) {
36
- const newState = await loadMarkdown(text, handle);
37
- commit(newState);
32
+ const { document, frontmatter } = await loadMarkdown(text);
33
+ openTab({
34
+ id: handle.name,
35
+ documentPath,
36
+ fileHandle: handle,
37
+ document,
38
+ frontmatter,
39
+ sourceFormat: "md",
40
+ });
38
41
  } else {
39
- const doc = JSON.parse(text);
40
- const newState = createState(doc);
41
- newState.fileHandle = handle;
42
- newState.dirty = false;
43
- newState.documentPath = await locateDocument(handle.name);
44
- await loadCompanionJS(handle, newState);
45
- commit(newState);
42
+ const document = JSON.parse(text);
43
+ openTab({ id: handle.name, documentPath, fileHandle: handle, document });
46
44
  }
47
45
 
48
46
  statusMessage(`Opened ${handle.name}`);
@@ -57,13 +55,11 @@ export async function openFile({ S: _S, commit, renderToolbar: _renderToolbar })
57
55
  const text = await file.text();
58
56
 
59
57
  if (file.name.endsWith(".md")) {
60
- const newState = await loadMarkdown(text, null);
61
- commit(newState);
58
+ const { document, frontmatter } = await loadMarkdown(text);
59
+ openTab({ id: file.name, document, frontmatter, sourceFormat: "md" });
62
60
  } else {
63
- const doc = JSON.parse(text);
64
- const newState = createState(doc);
65
- newState.dirty = false;
66
- commit(newState);
61
+ const document = JSON.parse(text);
62
+ openTab({ id: file.name, document });
67
63
  }
68
64
 
69
65
  statusMessage(`Opened ${file.name}`);
@@ -76,93 +72,50 @@ export async function openFile({ S: _S, commit, renderToolbar: _renderToolbar })
76
72
  }
77
73
 
78
74
  /**
79
- * Parse a markdown string into a Jx state object (pure — no side effects).
75
+ * Parse a markdown string into document + frontmatter (pure — no side effects).
80
76
  *
81
- * All markdown goes through `transpileJxMarkdown()`. Content documents (no hyphenated `tagName`)
82
- * are wrapped in a `{ tagName: "div", $id: "content" }` root to match the studio's content
83
- * contract.
84
- *
85
- * @param {any} source Markdown text
86
- * @param {any} fileHandle File handle (or null)
87
- * @returns {Promise<any>} A new state object ready for commit()
77
+ * @param {string} source Markdown text
78
+ * @returns {Promise<{ document: any; frontmatter: Record<string, any> }>}
88
79
  */
89
- export async function loadMarkdown(source, fileHandle) {
80
+ export async function loadMarkdown(source) {
90
81
  const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
91
82
  const doc = /** @type {any} */ (transpileJxMarkdown(source));
92
83
 
93
84
  const isComponent = doc.tagName && String(doc.tagName).includes("-");
94
85
 
95
86
  if (isComponent) {
96
- const newState = /** @type {any} */ (createState(doc));
97
- newState.sourceFormat = "md";
98
- newState.rawMarkdown = source;
99
- newState.fileHandle = fileHandle;
100
- newState.dirty = false;
101
- return newState;
87
+ return { document: doc, frontmatter: {} };
102
88
  }
103
89
 
104
90
  // Content markdown — children form the root-level document body
105
- const contentDoc = {
106
- children: doc.children ?? [],
107
- };
91
+ const contentDoc = { children: doc.children ?? [] };
108
92
 
109
- // Extract frontmatter keys (everything except children) as content metadata
110
93
  /** @type {Record<string, any>} */
111
94
  const frontmatter = {};
112
95
  for (const [key, value] of Object.entries(doc)) {
113
96
  if (key !== "children") frontmatter[key] = value;
114
97
  }
115
98
 
116
- const newState = /** @type {any} */ (createState(contentDoc));
117
- newState.sourceFormat = "md";
118
- newState.mode = "content";
119
- newState.content = { frontmatter };
120
- newState.rawMarkdown = source;
121
- newState.fileHandle = fileHandle;
122
- newState.dirty = false;
123
- return newState;
124
- }
125
-
126
- /**
127
- * Load companion JS file metadata into state.
128
- *
129
- * @param {any} handle
130
- * @param {any} state State object to mutate in-place
131
- */
132
- async function loadCompanionJS(handle, state) {
133
- try {
134
- if (handle.getParent) {
135
- // Not yet available in any browser; skip for now
136
- }
137
- if (state.document.$handlers) {
138
- state.handlersSource = `// Companion file: ${state.document.$handlers}\n// (Read-only in builder — edit the JS file directly)`;
139
- }
140
- } catch {}
99
+ return { document: contentDoc, frontmatter };
141
100
  }
142
101
 
143
- /**
144
- * Save the current document back to its source location.
145
- *
146
- * @param {{ S: any; commit: (s: any) => void; renderToolbar: () => void }} ctx
147
- */
148
- export async function saveFile({ S, commit, renderToolbar }) {
102
+ /** Save the current document back to its source location. */
103
+ export async function saveFile() {
104
+ const tab = activeTab.value;
105
+ if (!tab) return;
149
106
  try {
150
- const output = serializeDocument(S);
107
+ const output = serializeDocument(tab);
151
108
 
152
- if (S.documentPath) {
153
- // Project file — save via platform
109
+ if (tab.documentPath) {
154
110
  const platform = getPlatform();
155
- await platform.writeFile(S.documentPath, output);
156
- commit({ ...S, dirty: false });
157
- renderToolbar();
111
+ await platform.writeFile(tab.documentPath, output);
112
+ tab.doc.dirty = false;
158
113
  statusMessage("Saved");
159
- } else if (S.fileHandle && "createWritable" in S.fileHandle) {
160
- // Standalone file opened via FS Access API
161
- const writable = await S.fileHandle.createWritable();
114
+ } else if (tab.fileHandle && "createWritable" in tab.fileHandle) {
115
+ const writable = await /** @type {any} */ (tab.fileHandle).createWritable();
162
116
  await writable.write(output);
163
117
  await writable.close();
164
- commit({ ...S, dirty: false });
165
- renderToolbar();
118
+ tab.doc.dirty = false;
166
119
  statusMessage("Saved");
167
120
  } else {
168
121
  statusMessage("No save target — use Export");
@@ -172,22 +125,20 @@ export async function saveFile({ S, commit, renderToolbar }) {
172
125
  }
173
126
  }
174
127
 
175
- /**
176
- * Export the current document to a new location (Save As / download).
177
- *
178
- * @param {{ S: any; commit: (s: any) => void; renderToolbar: () => void }} ctx
179
- */
180
- export async function exportFile({ S, commit, renderToolbar }) {
128
+ /** Export the current document to a new location (Save As / download). */
129
+ export async function exportFile() {
130
+ const tab = activeTab.value;
131
+ if (!tab) return;
181
132
  try {
182
- const isContent = S.mode === "content";
183
- const output = serializeDocument(S);
133
+ const isContent = tab.doc.mode === "content";
134
+ const output = serializeDocument(tab);
184
135
  const mimeType = isContent ? "text/markdown" : "application/json";
185
136
  const ext = isContent ? ".md" : ".json";
186
137
  const description = isContent ? "Markdown Content" : "Jx Component";
187
138
 
188
139
  if ("showSaveFilePicker" in window) {
189
- const suggestedName = S.documentPath
190
- ? S.documentPath.split("/").pop()
140
+ const suggestedName = tab.documentPath
141
+ ? tab.documentPath.split("/").pop()
191
142
  : isContent
192
143
  ? "content.md"
193
144
  : "component.json";
@@ -198,8 +149,7 @@ export async function exportFile({ S, commit, renderToolbar }) {
198
149
  const writable = await handle.createWritable();
199
150
  await writable.write(output);
200
151
  await writable.close();
201
- commit({ ...S, dirty: false });
202
- renderToolbar();
152
+ tab.doc.dirty = false;
203
153
  statusMessage(`Exported as ${handle.name}`);
204
154
  } else {
205
155
  // Fallback: download
@@ -210,8 +160,7 @@ export async function exportFile({ S, commit, renderToolbar }) {
210
160
  a.download = isContent ? "content.md" : "component.json";
211
161
  a.click();
212
162
  URL.revokeObjectURL(url);
213
- commit({ ...S, dirty: false });
214
- renderToolbar();
163
+ tab.doc.dirty = false;
215
164
  statusMessage("Downloaded");
216
165
  }
217
166
  } catch (/** @type {any} */ e) {
@@ -222,22 +171,22 @@ export async function exportFile({ S, commit, renderToolbar }) {
222
171
  /**
223
172
  * Serialize the current document to its output format (JSON or Markdown).
224
173
  *
225
- * @param {any} S
174
+ * @param {import("../tabs/tab.js").Tab} tab
226
175
  * @returns {string}
227
176
  */
228
- function serializeDocument(S) {
229
- if (S.sourceFormat === "md") {
230
- return jxDocToMd(S.document);
177
+ function serializeDocument(tab) {
178
+ if (tab.doc.sourceFormat === "md") {
179
+ return jxDocToMd(tab.doc.document);
231
180
  }
232
- if (S.mode === "content") {
233
- const mdast = jxToMd(S.document);
181
+ if (tab.doc.mode === "content") {
182
+ const mdast = jxToMd(tab.doc.document);
234
183
  const md = unified()
235
184
  .use(remarkDirective)
236
185
  .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
237
186
  .stringify(mdast);
238
- const fm = S.content?.frontmatter;
187
+ const fm = tab.doc.content?.frontmatter;
239
188
  const hasFrontmatter = fm && Object.keys(fm).length > 0;
240
189
  return hasFrontmatter ? `---\n${stringifyYaml(fm).trim()}\n---\n\n${md}` : md;
241
190
  }
242
- return JSON.stringify(S.document, null, 2);
191
+ return JSON.stringify(tab.doc.document, null, 2);
243
192
  }
@@ -15,10 +15,13 @@ import { createState, projectState, setProjectState } from "../store.js";
15
15
  import { getPlatform } from "../platform.js";
16
16
  import { statusMessage } from "../panels/statusbar.js";
17
17
  import { loadComponentRegistry } from "./components.js";
18
+ import { workspace, openTab, activateTab } from "../workspace/workspace.js";
19
+ import { loadMarkdown } from "./file-ops.js";
20
+ import { view } from "../view.js";
18
21
 
19
22
  // ─── File icon map ────────────────────────────────────────────────────────────
20
23
 
21
- const fileIconMap = /** @type {Record<string, any>} */ ({
24
+ const fileIconMap = /** @type {Record<string, import("lit-html").TemplateResult>} */ ({
22
25
  "sp-icon-folder-open": html`<sp-icon-folder-open></sp-icon-folder-open>`,
23
26
  "sp-icon-folder": html`<sp-icon-folder></sp-icon-folder>`,
24
27
  "sp-icon-file-code": html`<sp-icon-file-code></sp-icon-file-code>`,
@@ -29,7 +32,7 @@ const fileIconMap = /** @type {Record<string, any>} */ ({
29
32
 
30
33
  // ─── File management ──────────────────────────────────────────────────────────
31
34
 
32
- async function loadDirectory(/** @type {any} */ dirPath) {
35
+ async function loadDirectory(/** @type {string} */ dirPath) {
33
36
  if (!projectState) return;
34
37
  try {
35
38
  const platform = getPlatform();
@@ -77,13 +80,11 @@ export async function loadProject() {
77
80
  * Open a project via the platform adapter.
78
81
  *
79
82
  * @param {{
80
- * S: any;
81
- * commit: (s: any) => void;
82
83
  * renderActivityBar: () => void;
83
84
  * renderLeftPanel: () => void;
84
85
  * }} ctx
85
86
  */
86
- export async function openProject({ S, commit, renderActivityBar, renderLeftPanel }) {
87
+ export async function openProject({ renderActivityBar, renderLeftPanel }) {
87
88
  try {
88
89
  const platform = getPlatform();
89
90
  const result = await platform.openProject();
@@ -127,7 +128,7 @@ export async function openProject({ S, commit, renderActivityBar, renderLeftPane
127
128
  }
128
129
  projectState.projectDirs = foundDirs;
129
130
 
130
- commit({ ...S, ui: { ...S.ui, leftTab: "files" } });
131
+ view.leftTab = "files";
131
132
  renderActivityBar();
132
133
  renderLeftPanel();
133
134
  statusMessage(`Opened project: ${projectState.name}`);
@@ -138,7 +139,7 @@ export async function openProject({ S, commit, renderActivityBar, renderLeftPane
138
139
 
139
140
  // ─── File tree templates ──────────────────────────────────────────────────────
140
141
 
141
- function fileTypeIconTpl(/** @type {any} */ name, /** @type {any} */ type) {
142
+ function fileTypeIconTpl(/** @type {string} */ name, /** @type {string} */ type) {
142
143
  let tag;
143
144
  if (type === "directory") {
144
145
  tag = projectState?.expanded?.has(name) ? "sp-icon-folder-open" : "sp-icon-folder";
@@ -237,11 +238,11 @@ export function renderFilesTemplate({
237
238
  quiet
238
239
  placeholder="Filter files…"
239
240
  value=${projectState.searchQuery}
240
- @input=${(/** @type {any} */ e) => {
241
- projectState.searchQuery = e.target.value;
241
+ @input=${(/** @type {Event} */ e) => {
242
+ projectState.searchQuery = /** @type {HTMLInputElement} */ (e.target).value;
242
243
  renderLeftPanel();
243
244
  }}
244
- @submit=${(/** @type {any} */ e) => e.preventDefault()}
245
+ @submit=${(/** @type {Event} */ e) => e.preventDefault()}
245
246
  ></sp-search>
246
247
  </div>
247
248
  <div class="file-tree" role="tree" aria-label="Project files">
@@ -250,10 +251,10 @@ export function renderFilesTemplate({
250
251
  `;
251
252
  }
252
253
 
253
- /** @returns {any} */
254
+ /** @returns {import("lit-html").TemplateResult | import("lit-html").TemplateResult[]} */
254
255
  function renderTreeLevelTemplate(
255
- /** @type {any} */ dirPath,
256
- /** @type {any} */ depth,
256
+ /** @type {string} */ dirPath,
257
+ /** @type {number} */ depth,
257
258
  /** @type {{ openFileFn: (path: string) => void; renderLeftPanel: () => void }} */ ctx,
258
259
  ) {
259
260
  const entries = projectState.dirs.get(dirPath);
@@ -293,7 +294,7 @@ function renderTreeLevelTemplate(
293
294
  data-path=${entry.path}
294
295
  data-type=${entry.type}
295
296
  aria-expanded=${isDir ? String(isExpanded) : nothing}
296
- @click=${async (/** @type {any} */ e) => {
297
+ @click=${async (/** @type {MouseEvent} */ e) => {
297
298
  e.stopPropagation();
298
299
  if (isDir) {
299
300
  if (isExpanded) projectState.expanded.delete(entry.path);
@@ -306,7 +307,7 @@ function renderTreeLevelTemplate(
306
307
  ctx.openFileFn(entry.path);
307
308
  }
308
309
  }}
309
- @contextmenu=${(/** @type {any} */ e) => {
310
+ @contextmenu=${(/** @type {MouseEvent} */ e) => {
310
311
  e.preventDefault();
311
312
  e.stopPropagation();
312
313
  showFileContextMenu(e, entry, ctx);
@@ -325,10 +326,10 @@ function renderTreeLevelTemplate(
325
326
  });
326
327
  }
327
328
 
328
- export function setupTreeKeyboard(/** @type {any} */ tree) {
329
- tree.addEventListener("keydown", (/** @type {any} */ e) => {
330
- const items = [...tree.querySelectorAll(".file-tree-item")];
331
- const focused = tree.querySelector(".file-tree-item:focus");
329
+ export function setupTreeKeyboard(/** @type {HTMLElement} */ tree) {
330
+ tree.addEventListener("keydown", (/** @type {KeyboardEvent} */ e) => {
331
+ const items = /** @type {HTMLElement[]} */ ([...tree.querySelectorAll(".file-tree-item")]);
332
+ const focused = /** @type {HTMLElement | null} */ (tree.querySelector(".file-tree-item:focus"));
332
333
  if (!focused || items.length === 0) return;
333
334
 
334
335
  const idx = items.indexOf(focused);
@@ -343,12 +344,15 @@ export function setupTreeKeyboard(/** @type {any} */ tree) {
343
344
  break;
344
345
  case "ArrowRight":
345
346
  if (focused.dataset.type === "directory") {
346
- const path = focused.dataset.path;
347
+ const path = /** @type {string} */ (focused.dataset.path);
347
348
  if (!projectState.expanded.has(path)) {
348
349
  projectState.expanded.add(path);
349
350
  loadDirectory(path).then(() => {
350
351
  const panel = tree.closest(".panel-body");
351
- if (panel) panel.querySelector(".file-tree-item:focus")?.click();
352
+ if (panel)
353
+ /** @type {HTMLElement | null} */ (
354
+ panel.querySelector(".file-tree-item:focus")
355
+ )?.click();
352
356
  });
353
357
  }
354
358
  }
@@ -378,12 +382,12 @@ export function setupTreeKeyboard(/** @type {any} */ tree) {
378
382
 
379
383
  // ─── Context menu ─────────────────────────────────────────────────────────────
380
384
 
381
- /** @type {any} */
385
+ /** @type {HTMLElement | null} */
382
386
  let fileContextPopover = null;
383
387
 
384
388
  function showFileContextMenu(
385
- /** @type {any} */ e,
386
- /** @type {any} */ entry,
389
+ /** @type {MouseEvent} */ e,
390
+ /** @type {{ name: string; path: string; type: string }} */ entry,
387
391
  /** @type {{ openFileFn: (path: string) => void; renderLeftPanel: () => void }} */ ctx,
388
392
  ) {
389
393
  if (fileContextPopover) {
@@ -442,8 +446,8 @@ function showFileContextMenu(
442
446
  litRender(tpl, fileContextPopover);
443
447
  document.body.appendChild(fileContextPopover);
444
448
 
445
- const closeHandler = (/** @type {any} */ ev) => {
446
- if (!fileContextPopover?.contains(ev.target)) {
449
+ const closeHandler = (/** @type {MouseEvent} */ ev) => {
450
+ if (!fileContextPopover?.contains(/** @type {Node} */ (ev.target))) {
447
451
  closeFileContextMenu();
448
452
  document.removeEventListener("mousedown", closeHandler, true);
449
453
  }
@@ -478,7 +482,10 @@ async function createNewFile(dirPath = ".", /** @type {() => void} */ renderLeft
478
482
  }
479
483
  }
480
484
 
481
- async function renameFile(/** @type {any} */ entry, /** @type {() => void} */ renderLeftPanel) {
485
+ async function renameFile(
486
+ /** @type {{ name: string; path: string; type: string }} */ entry,
487
+ /** @type {() => void} */ renderLeftPanel,
488
+ ) {
482
489
  const newName = prompt("New name:", entry.name);
483
490
  if (!newName || newName === entry.name) return;
484
491
  const entryPath = entry.path.replaceAll("\\", "/");
@@ -500,7 +507,10 @@ async function renameFile(/** @type {any} */ entry, /** @type {() => void} */ re
500
507
  }
501
508
  }
502
509
 
503
- async function deleteFile(/** @type {any} */ entry, /** @type {() => void} */ renderLeftPanel) {
510
+ async function deleteFile(
511
+ /** @type {{ name: string; path: string; type: string }} */ entry,
512
+ /** @type {() => void} */ renderLeftPanel,
513
+ ) {
504
514
  if (!confirm(`Delete "${entry.name}"?`)) return;
505
515
  try {
506
516
  const platform = getPlatform();
@@ -583,3 +593,46 @@ export async function openFileFromTree(ctx, path) {
583
593
  statusMessage(`Error: ${e.message}`);
584
594
  }
585
595
  }
596
+
597
+ /**
598
+ * Open a file from the tree into a tab. Activates existing tab if already open.
599
+ *
600
+ * @param {string} path
601
+ */
602
+ export async function openFileInTab(path) {
603
+ for (const [id, tab] of workspace.tabs.entries()) {
604
+ if (tab.documentPath === path) {
605
+ activateTab(id);
606
+ projectState.selectedPath = path;
607
+ return;
608
+ }
609
+ }
610
+
611
+ const platform = getPlatform();
612
+ try {
613
+ const content = await platform.readFile(path);
614
+ if (!content) return;
615
+
616
+ let document, frontmatter;
617
+ if (path.endsWith(".md")) {
618
+ const result = await loadMarkdown(content);
619
+ document = result.document;
620
+ frontmatter = result.frontmatter;
621
+ } else {
622
+ document = JSON.parse(content);
623
+ }
624
+
625
+ const id = path;
626
+ openTab({
627
+ id,
628
+ documentPath: path,
629
+ document,
630
+ frontmatter,
631
+ sourceFormat: path.endsWith(".md") ? "md" : null,
632
+ });
633
+ projectState.selectedPath = path;
634
+ statusMessage(`Opened ${path.split("/").pop()}`);
635
+ } catch (/** @type {any} */ e) {
636
+ statusMessage(`Error: ${e.message}`);
637
+ }
638
+ }
@@ -1,7 +1,29 @@
1
1
  /** Activity bar — tab icons for switching left panel views. */
2
2
 
3
3
  import { html, render as litRender, nothing } from "lit-html";
4
- import { activityBar, update, getState, renderOnly } from "../store.js";
4
+ import { activityBar, renderOnly } from "../store.js";
5
+ import { effect, effectScope } from "../reactivity.js";
6
+ import { activeTab } from "../workspace/workspace.js";
7
+ import { view } from "../view.js";
8
+
9
+ /** @type {import("@vue/reactivity").EffectScope | null} */
10
+ let _scope = null;
11
+
12
+ export function mount() {
13
+ _scope = effectScope();
14
+ _scope.run(() => {
15
+ effect(() => {
16
+ const tab = activeTab.value;
17
+ if (!tab) return;
18
+ renderActivityBar();
19
+ });
20
+ });
21
+ }
22
+
23
+ export function unmount() {
24
+ _scope?.stop();
25
+ _scope = null;
26
+ }
5
27
 
6
28
  const gitBranchIcon = (/** @type {any} */ s) => html`
7
29
  <svg
@@ -58,8 +80,10 @@ export function tabIcon(tag, size) {
58
80
  return fn ? fn(size || "s") : nothing;
59
81
  }
60
82
 
61
- /** @param {any} S — current studio state */
62
- export function renderActivityBar(S) {
83
+ export function renderActivityBar() {
84
+ const tab = activeTab.value;
85
+ if (!tab) return;
86
+ const leftTab = view.leftTab;
63
87
  const tabs = [
64
88
  { value: "files", icon: "sp-icon-folder", label: "Files" },
65
89
  { value: "layers", icon: "sp-icon-layers", label: "Layers" },
@@ -72,13 +96,13 @@ export function renderActivityBar(S) {
72
96
  ];
73
97
  const tpl = html`
74
98
  <sp-tabs
75
- selected=${S.ui.leftTab}
99
+ selected=${leftTab}
76
100
  direction="vertical"
77
101
  quiet
78
102
  @change=${(/** @type {any} */ e) => {
79
- const current = getState();
80
- update({ ...current, ui: { ...current.ui, leftTab: e.target.selected } });
81
- renderOnly("activityBar", "leftPanel");
103
+ view.leftTab = e.target.selected;
104
+ renderOnly("leftPanel");
105
+ renderActivityBar();
82
106
  }}
83
107
  >
84
108
  ${tabs.map(