@jxsuite/studio 0.16.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,7 +6,7 @@
6
6
  * button with type-aware entity creation (including content types from project.json).
7
7
  */
8
8
 
9
- import { html, render as litRender } from "lit-html";
9
+ import { html, render as litRender, nothing } from "lit-html";
10
10
  import { getPlatform } from "../platform.js";
11
11
  import { projectState } from "../store.js";
12
12
  import { yamlDefault } from "../settings/schema-field-ui.js";
@@ -62,6 +62,22 @@ function extOf(name) {
62
62
  return dot > 0 ? name.slice(dot).toLowerCase() : "";
63
63
  }
64
64
 
65
+ const IMAGE_EXTENSIONS = new Set([
66
+ ".jpg",
67
+ ".jpeg",
68
+ ".png",
69
+ ".gif",
70
+ ".svg",
71
+ ".webp",
72
+ ".avif",
73
+ ".ico",
74
+ ]);
75
+
76
+ /** @param {string} ext */
77
+ function isImage(ext) {
78
+ return IMAGE_EXTENSIONS.has(ext);
79
+ }
80
+
65
81
  /** Map a file path to a display category. Media files override by extension. */
66
82
  function categoryFor(/** @type {string} */ dir, /** @type {string} */ ext) {
67
83
  if (ext && MEDIA_EXTENSIONS.has(ext)) return "Media";
@@ -396,9 +412,14 @@ export async function renderBrowse(container, ctx) {
396
412
  <sp-table-row
397
413
  value=${f.path}
398
414
  class="browse-row"
399
- @click=${() => ctx.openFile(f.path)}
415
+ style=${isImage(f.ext) ? "cursor:default" : ""}
416
+ @click=${isImage(f.ext) ? nothing : () => ctx.openFile(f.path)}
400
417
  >
401
- <sp-table-cell class="browse-name-cell">${f.name}</sp-table-cell>
418
+ <sp-table-cell class="browse-name-cell"
419
+ >${isImage(f.ext)
420
+ ? html`<img class="browse-thumb" src="/${f.path}" />`
421
+ : nothing}${f.name}</sp-table-cell
422
+ >
402
423
  <sp-table-cell>${f.category}</sp-table-cell>
403
424
  <sp-table-cell>${f.type}</sp-table-cell>
404
425
  <sp-table-cell class="browse-path-cell">${f.path}</sp-table-cell>
@@ -390,38 +390,12 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
390
390
  canvasEl.removeAttribute("data-content-mode");
391
391
  }
392
392
 
393
- // Resolve relative asset URLs through the PAL for environments that can't
394
- // serve project files natively (e.g. ElectroBun's views:// scheme).
395
- const platform = /** @type {any} */ (globalThis).__jxPlatform;
396
- if (platform?.resolveAssetUrl && docBase) {
397
- const mediaEls = el.querySelectorAll("img[src], video[src], source[src], video[poster]");
398
- for (const node of mediaEls) {
399
- for (const attr of ["src", "poster"]) {
400
- const val = node.getAttribute(attr);
401
- if (
402
- val &&
403
- !val.startsWith("data:") &&
404
- !val.startsWith("blob:") &&
405
- !val.startsWith("http")
406
- ) {
407
- try {
408
- const resolved = new URL(val, docBase).pathname.replace(/^\//, "");
409
- const dataUrl = await platform.resolveAssetUrl(resolved);
410
- if (dataUrl) node.setAttribute(attr, dataUrl);
411
- } catch {}
412
- }
413
- }
414
- }
415
- }
416
-
417
393
  canvasEl.appendChild(el);
394
+
418
395
  if (canvasMode === "design" || canvasMode === "edit") {
419
- // Custom element connectedCallbacks render children asynchronously —
420
- // sweep again after they've had a chance to run
421
396
  requestAnimationFrame(() => {
422
397
  const editingEl = getActiveElement();
423
398
  for (const child of canvasEl.querySelectorAll("*")) {
424
- // Preserve pointer-events on the actively-edited element
425
399
  if (view.componentInlineEdit && child === view.componentInlineEdit.el) continue;
426
400
  if (editingEl && child === editingEl) continue;
427
401
  /** @type {any} */ (child).style.pointerEvents = "none";
@@ -17,6 +17,7 @@ import {
17
17
  } from "../tabs/transact.js";
18
18
  import { isEditing } from "./inline-edit.js";
19
19
  import { copyNode, cutNode, pasteNode } from "./context-menu.js";
20
+ import { openQuickSearch } from "../panels/quick-search.js";
20
21
 
21
22
  /** @typedef {import("../state.js").JxPath} JxPath */
22
23
 
@@ -44,6 +45,8 @@ export function initShortcuts(getContext) {
44
45
  const { canvasMode, panX, panY, setPan, applyTransform } = getContext();
45
46
  // Edit (content) mode: let the scroll container handle scrolling natively
46
47
  if (canvasMode === "edit") return;
48
+ // Manage mode: browse table handles its own scrolling
49
+ if (canvasMode === "manage") return;
47
50
  e.preventDefault();
48
51
  if (e.ctrlKey || e.metaKey) {
49
52
  // Zoom towards cursor
@@ -164,6 +167,10 @@ export function initShortcuts(getContext) {
164
167
  e.preventDefault();
165
168
  openProject();
166
169
  break;
170
+ case "p":
171
+ e.preventDefault();
172
+ openQuickSearch();
173
+ break;
167
174
  case "s":
168
175
  e.preventDefault();
169
176
  saveFile();
@@ -15,9 +15,10 @@ 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";
18
+ import { workspace, openTab, activateTab, replaceAllTabs } from "../workspace/workspace.js";
19
19
  import { loadMarkdown } from "./file-ops.js";
20
20
  import { view } from "../view.js";
21
+ import { addRecentProject, trackRecentFile } from "../recent-projects.js";
21
22
 
22
23
  // ─── File icon map ────────────────────────────────────────────────────────────
23
24
 
@@ -32,7 +33,7 @@ const fileIconMap = /** @type {Record<string, import("lit-html").TemplateResult>
32
33
 
33
34
  // ─── File management ──────────────────────────────────────────────────────────
34
35
 
35
- async function loadDirectory(/** @type {string} */ dirPath) {
36
+ export async function loadDirectory(/** @type {string} */ dirPath) {
36
37
  if (!projectState) return;
37
38
  try {
38
39
  const platform = getPlatform();
@@ -92,6 +93,8 @@ export async function openProject({ renderActivityBar, renderLeftPanel }) {
92
93
 
93
94
  const { config, handle } = result;
94
95
 
96
+ replaceAllTabs({ id: "initial", document: { tagName: "div", children: [] } });
97
+
95
98
  setProjectState({
96
99
  ...projectState,
97
100
  projectRoot: handle.root,
@@ -129,6 +132,7 @@ export async function openProject({ renderActivityBar, renderLeftPanel }) {
129
132
  projectState.projectDirs = foundDirs;
130
133
 
131
134
  view.leftTab = "files";
135
+ addRecentProject(projectState.name, projectState.projectRoot);
132
136
  renderActivityBar();
133
137
  renderLeftPanel();
134
138
  statusMessage(`Opened project: ${projectState.name}`);
@@ -631,6 +635,7 @@ export async function openFileInTab(path) {
631
635
  sourceFormat: path.endsWith(".md") ? "md" : null,
632
636
  });
633
637
  projectState.selectedPath = path;
638
+ trackRecentFile({ path, name: path.split("/").pop() || path });
634
639
  statusMessage(`Opened ${path.split("/").pop()}`);
635
640
  } catch (/** @type {any} */ e) {
636
641
  statusMessage(`Error: ${e.message}`);
@@ -81,12 +81,14 @@ export function registerPanelEvents(panel) {
81
81
  document.elementsFromPoint(e.clientX, e.clientY),
82
82
  );
83
83
 
84
+ if (!tab) return;
85
+
84
86
  for (const el of elements) {
85
87
  if (canvas.contains(el) && el !== canvas) {
86
88
  // Layout element clicked — show layout info instead of selecting in page doc
87
89
  if (layoutElements.has(el)) {
88
90
  view.layoutSelection = { el, layoutPath: activeLayoutPath };
89
- activeTab.value.session.selection = null;
91
+ tab.session.selection = null;
90
92
  renderOnly("rightPanel");
91
93
  return;
92
94
  }
@@ -94,35 +96,35 @@ export function registerPanelEvents(panel) {
94
96
 
95
97
  const originalPath = elToPath.get(el);
96
98
  if (originalPath) {
97
- let path = bubbleInlinePath(tab?.doc.document, originalPath);
99
+ let path = bubbleInlinePath(tab.doc.document, originalPath);
98
100
  const newMedia = mediaName === "base" ? null : (mediaName ?? null);
99
101
 
100
102
  const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
101
103
 
102
104
  if (
103
- pathsEqual(path, tab?.session.selection) &&
105
+ pathsEqual(path, tab.session.selection) &&
104
106
  isEditableBlock(resolvedEl) &&
105
- (canvasMode === "edit" || tab?.doc.mode === "content")
107
+ (canvasMode === "edit" || tab.doc.mode === "content")
106
108
  ) {
107
- activeTab.value.session.ui.activeMedia = newMedia;
109
+ tab.session.ui.activeMedia = newMedia;
108
110
  _ctx.enterInlineEdit(resolvedEl, path);
109
111
  return;
110
112
  }
111
113
 
112
- if (canvasMode === "design" && tab?.doc.mode !== "content") {
114
+ if (canvasMode === "design" && tab.doc.mode !== "content") {
113
115
  updateUi("pendingInlineEdit", { path, mediaName });
114
- activeTab.value.session.ui.activeMedia = newMedia;
115
- activeTab.value.session.selection = path;
116
+ tab.session.ui.activeMedia = newMedia;
117
+ tab.session.selection = path;
116
118
  return;
117
119
  }
118
120
 
119
- activeTab.value.session.ui.activeMedia = newMedia;
120
- activeTab.value.session.selection = path;
121
+ tab.session.ui.activeMedia = newMedia;
122
+ tab.session.selection = path;
121
123
  return;
122
124
  }
123
125
  }
124
126
  }
125
- activeTab.value.session.selection = null;
127
+ tab.session.selection = null;
126
128
  },
127
129
  opts,
128
130
  );
@@ -149,16 +151,18 @@ export function registerPanelEvents(panel) {
149
151
  document.elementsFromPoint(e.clientX, e.clientY),
150
152
  );
151
153
 
154
+ if (!tab) return;
155
+
152
156
  for (const el of elements) {
153
157
  if (canvas.contains(el) && el !== canvas) {
154
158
  const originalPath = elToPath.get(el);
155
159
  if (originalPath) {
156
- const path = bubbleInlinePath(tab?.doc.document, originalPath);
160
+ const path = bubbleInlinePath(tab.doc.document, originalPath);
157
161
  const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
158
162
  if (isEditableBlock(resolvedEl)) {
159
163
  const newMedia = mediaName === "base" ? null : (mediaName ?? null);
160
- activeTab.value.session.ui.activeMedia = newMedia;
161
- activeTab.value.session.selection = path;
164
+ tab.session.ui.activeMedia = newMedia;
165
+ tab.session.selection = path;
162
166
  _ctx.enterInlineEdit(resolvedEl, path);
163
167
  return;
164
168
  }
@@ -217,18 +221,19 @@ export function registerPanelEvents(panel) {
217
221
  return;
218
222
  }
219
223
  const tab = activeTab.value;
224
+ if (!tab) return;
220
225
  const el = withPanelPointerEvents(() => document.elementFromPoint(e.clientX, e.clientY));
221
226
  if (el && canvas.contains(el) && el !== canvas) {
222
227
  let path = elToPath.get(el);
223
228
  if (path) {
224
- path = bubbleInlinePath(tab?.doc.document, path);
225
- if (!pathsEqual(path, tab?.session.hover)) {
226
- activeTab.value.session.hover = path;
229
+ path = bubbleInlinePath(tab.doc.document, path);
230
+ if (!pathsEqual(path, tab.session.hover)) {
231
+ tab.session.hover = path;
227
232
  renderOnly("overlays");
228
233
  }
229
234
  }
230
- } else if (tab?.session.hover) {
231
- activeTab.value.session.hover = null;
235
+ } else if (tab.session.hover) {
236
+ tab.session.hover = null;
232
237
  renderOnly("overlays");
233
238
  }
234
239
  },
@@ -222,16 +222,16 @@ function renderFrontmatterOnlyPanel() {
222
222
  fields.push({ field, entry: fieldSchema, value: fm[field] });
223
223
  }
224
224
  for (const [field, value] of Object.entries(fm)) {
225
- if (!schemaProps[field]) {
226
- fields.push({
227
- field,
228
- entry: { type: typeof value === "boolean" ? "boolean" : "string" },
229
- value,
230
- });
231
- }
225
+ if (schemaProps[field] || field.startsWith("$")) continue;
226
+ fields.push({
227
+ field,
228
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
229
+ value,
230
+ });
232
231
  }
233
232
  } else {
234
233
  for (const [field, value] of Object.entries(fm)) {
234
+ if (field.startsWith("$")) continue;
235
235
  fields.push({
236
236
  field,
237
237
  entry: { type: typeof value === "boolean" ? "boolean" : "string" },
@@ -1656,16 +1656,16 @@ export function renderPropertiesPanelTemplate(ctx) {
1656
1656
  fields.push({ field, entry: fieldSchema, value: fm[field] });
1657
1657
  }
1658
1658
  for (const [field, value] of Object.entries(fm)) {
1659
- if (!schemaProps[field]) {
1660
- fields.push({
1661
- field,
1662
- entry: { type: typeof value === "boolean" ? "boolean" : "string" },
1663
- value,
1664
- });
1665
- }
1659
+ if (schemaProps[field] || field.startsWith("$")) continue;
1660
+ fields.push({
1661
+ field,
1662
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
1663
+ value,
1664
+ });
1666
1665
  }
1667
1666
  } else {
1668
1667
  for (const [field, value] of Object.entries(fm)) {
1668
+ if (field.startsWith("$")) continue;
1669
1669
  fields.push({
1670
1670
  field,
1671
1671
  entry: { type: typeof value === "boolean" ? "boolean" : "string" },
@@ -0,0 +1,170 @@
1
+ import { html, render as litRender, nothing } from "lit-html";
2
+ import { getPlatform } from "../platform.js";
3
+ import { openFileInTab } from "../files/files.js";
4
+ import { getRecentFiles, trackRecentFile } from "../recent-projects.js";
5
+
6
+ let _open = false;
7
+ let _query = "";
8
+ /** @type {any[]} */
9
+ let _results = [];
10
+ let _selectedIndex = 0;
11
+ let _debounceTimer = 0;
12
+
13
+ /** @type {HTMLElement | null} */
14
+ let _container = null;
15
+
16
+ export function initQuickSearch() {
17
+ _container = document.createElement("div");
18
+ _container.style.display = "contents";
19
+ (document.querySelector("sp-theme") || document.body).appendChild(_container);
20
+ }
21
+
22
+ export function openQuickSearch() {
23
+ _open = true;
24
+ _query = "";
25
+ _results = [];
26
+ _selectedIndex = 0;
27
+ renderOverlay();
28
+ requestAnimationFrame(() => {
29
+ const input = _container?.querySelector(".quick-search-input");
30
+ if (input) /** @type {HTMLInputElement} */ (input).focus();
31
+ });
32
+ }
33
+
34
+ export function closeQuickSearch() {
35
+ _open = false;
36
+ renderOverlay();
37
+ }
38
+
39
+ async function doSearch(/** @type {string} */ query) {
40
+ if (!query.trim()) {
41
+ _results = [];
42
+ _selectedIndex = 0;
43
+ renderOverlay();
44
+ return;
45
+ }
46
+ try {
47
+ const platform = getPlatform();
48
+ _results = await platform.searchFiles(query.trim().toLowerCase());
49
+ _selectedIndex = 0;
50
+ renderOverlay();
51
+ } catch {
52
+ _results = [];
53
+ renderOverlay();
54
+ }
55
+ }
56
+
57
+ function onInput(/** @type {Event} */ e) {
58
+ _query = /** @type {HTMLInputElement} */ (e.target).value;
59
+ clearTimeout(_debounceTimer);
60
+ _debounceTimer = /** @type {any} */ (setTimeout(() => doSearch(_query), 150));
61
+ renderOverlay();
62
+ }
63
+
64
+ function onKeydown(/** @type {KeyboardEvent} */ e) {
65
+ const items = _query.trim() ? _results : getRecentFiles();
66
+ switch (e.key) {
67
+ case "ArrowDown":
68
+ e.preventDefault();
69
+ _selectedIndex = Math.min(_selectedIndex + 1, items.length - 1);
70
+ renderOverlay();
71
+ break;
72
+ case "ArrowUp":
73
+ e.preventDefault();
74
+ _selectedIndex = Math.max(_selectedIndex - 1, 0);
75
+ renderOverlay();
76
+ break;
77
+ case "Enter":
78
+ e.preventDefault();
79
+ if (items[_selectedIndex]) selectItem(items[_selectedIndex]);
80
+ break;
81
+ case "Escape":
82
+ e.preventDefault();
83
+ closeQuickSearch();
84
+ break;
85
+ }
86
+ }
87
+
88
+ function selectItem(/** @type {any} */ item) {
89
+ closeQuickSearch();
90
+ const path = item.path;
91
+ trackRecentFile({ path, name: path.split("/").pop() });
92
+ openFileInTab(path);
93
+ }
94
+
95
+ function fileIcon(/** @type {string} */ name) {
96
+ const ext = name.split(".").pop()?.toLowerCase();
97
+ switch (ext) {
98
+ case "json":
99
+ return html`<sp-icon-file-code size="s"></sp-icon-file-code>`;
100
+ case "md":
101
+ return html`<sp-icon-file-txt size="s"></sp-icon-file-txt>`;
102
+ default:
103
+ return html`<sp-icon-document size="s"></sp-icon-document>`;
104
+ }
105
+ }
106
+
107
+ function dirPart(/** @type {string} */ path) {
108
+ const parts = path.split("/");
109
+ parts.pop();
110
+ return parts.length ? parts.join("/") : "";
111
+ }
112
+
113
+ function renderOverlay() {
114
+ if (!_container) return;
115
+ if (!_open) {
116
+ litRender(nothing, _container);
117
+ return;
118
+ }
119
+
120
+ const recentFiles = getRecentFiles();
121
+ const showRecent = !_query.trim();
122
+ const items = showRecent ? recentFiles : _results;
123
+
124
+ const tpl = html`
125
+ <div class="quick-search-overlay" @click=${closeQuickSearch}>
126
+ <div class="quick-search-panel" @click=${(/** @type {Event} */ e) => e.stopPropagation()}>
127
+ <input
128
+ class="quick-search-input"
129
+ type="text"
130
+ placeholder="Search project files…"
131
+ .value=${_query}
132
+ @input=${onInput}
133
+ @keydown=${onKeydown}
134
+ />
135
+ <div class="quick-search-results">
136
+ ${items.length === 0 && _query.trim()
137
+ ? html`<div class="quick-search-empty">No results</div>`
138
+ : nothing}
139
+ ${items.length === 0 && !_query.trim() && recentFiles.length === 0
140
+ ? html`<div class="quick-search-empty">Type to search project files</div>`
141
+ : nothing}
142
+ ${showRecent && recentFiles.length
143
+ ? html`<div class="quick-search-section-label">Recently opened</div>`
144
+ : nothing}
145
+ ${items.map(
146
+ (item, i) => html`
147
+ <div
148
+ class="quick-search-item ${i === _selectedIndex ? "selected" : ""}"
149
+ @click=${() => selectItem(item)}
150
+ @mouseenter=${() => {
151
+ _selectedIndex = i;
152
+ renderOverlay();
153
+ }}
154
+ >
155
+ <span class="quick-search-icon"
156
+ >${fileIcon(item.name || item.path.split("/").pop())}</span
157
+ >
158
+ <span class="quick-search-name">${item.name || item.path.split("/").pop()}</span>
159
+ <span class="quick-search-path">${dirPart(item.path)}</span>
160
+ ${showRecent ? html`<span class="quick-search-badge">recent</span>` : nothing}
161
+ </div>
162
+ `,
163
+ )}
164
+ </div>
165
+ </div>
166
+ </div>
167
+ `;
168
+
169
+ litRender(tpl, _container);
170
+ }
@@ -11,6 +11,8 @@ import { activeTab } from "../workspace/workspace.js";
11
11
  import { getEffectiveMedia } from "../site-context.js";
12
12
  import { mediaDisplayName } from "./shared.js";
13
13
  import { view } from "../view.js";
14
+ import { getRecentProjects } from "../recent-projects.js";
15
+ import { openQuickSearch } from "./quick-search.js";
14
16
 
15
17
  /** @type {HTMLElement | null} */
16
18
  let _rootEl = null;
@@ -62,6 +64,9 @@ function tbBtnTpl(label, onClick, iconTag) {
62
64
  export function mount(rootEl, ctx) {
63
65
  _rootEl = rootEl;
64
66
  _ctx = ctx;
67
+ if (/** @type {any} */ (globalThis).__jxPlatform?.windowControls) {
68
+ rootEl.classList.add("electrobun-webkit-app-region-drag");
69
+ }
65
70
  _scope = effectScope();
66
71
  _scope.run(() => {
67
72
  effect(() => {
@@ -226,10 +231,60 @@ function toolbarTemplate() {
226
231
  </sp-action-group>
227
232
  `;
228
233
 
234
+ const windowControls = /** @type {any} */ (globalThis).__jxPlatform?.windowControls;
235
+ const csdTpl = windowControls
236
+ ? html`
237
+ <sp-action-group class="window-controls" size="s">
238
+ <sp-action-button
239
+ quiet
240
+ size="s"
241
+ title="Minimize"
242
+ @click=${() => windowControls.minimize()}
243
+ >
244
+ <sp-icon-remove slot="icon"></sp-icon-remove>
245
+ </sp-action-button>
246
+ <sp-action-button
247
+ quiet
248
+ size="s"
249
+ title="Maximize"
250
+ @click=${() => windowControls.maximize()}
251
+ >
252
+ <sp-icon-full-screen slot="icon"></sp-icon-full-screen>
253
+ </sp-action-button>
254
+ <sp-action-button
255
+ quiet
256
+ size="s"
257
+ title="Close"
258
+ class="csd-close"
259
+ @click=${() => windowControls.close()}
260
+ >
261
+ <sp-icon-close slot="icon"></sp-icon-close>
262
+ </sp-action-button>
263
+ </sp-action-group>
264
+ `
265
+ : nothing;
266
+
267
+ const recentProjects = getRecentProjects();
268
+ const recentProjectsTpl = recentProjects.length
269
+ ? html`
270
+ <overlay-trigger placement="bottom-start">
271
+ <sp-action-button size="s" slot="trigger" title="Recent projects">
272
+ <sp-icon-chevron-down slot="icon"></sp-icon-chevron-down>
273
+ </sp-action-button>
274
+ <sp-popover slot="click-content" tip>
275
+ <sp-menu @change=${(/** @type {any} */ e) => _ctx.openRecentProject(e.target.value)}>
276
+ ${recentProjects.map(
277
+ (p) => html`<sp-menu-item value=${p.root}>${p.name}</sp-menu-item>`,
278
+ )}
279
+ </sp-menu>
280
+ </sp-popover>
281
+ </overlay-trigger>
282
+ `
283
+ : nothing;
284
+
229
285
  return html`
230
286
  <sp-action-group compact size="s">
231
- ${tbBtnTpl("Open Project", _ctx.openProject, "sp-icon-folder-open")}
232
- ${tbBtnTpl("Open File", _ctx.openFile, "sp-icon-document")}
287
+ ${tbBtnTpl("Open Project", _ctx.openProject, "sp-icon-folder-open")} ${recentProjectsTpl}
233
288
  ${tbBtnTpl("Save", _ctx.saveFile, "sp-icon-save-floppy")}
234
289
  </sp-action-group>
235
290
  <sp-action-group compact size="s">
@@ -237,17 +292,11 @@ function toolbarTemplate() {
237
292
  ${tbBtnTpl("Redo", () => tabRedo(activeTab.value), "sp-icon-redo")}
238
293
  </sp-action-group>
239
294
  <div class="tb-spacer"></div>
240
- ${S.documentPath
241
- ? html`<span class="tb-file-title" title=${S.documentPath}
242
- >${S.documentPath}${S.dirty ? html`<span class="tb-dirty">●</span>` : nothing}</span
243
- >`
244
- : S.fileHandle
245
- ? html`<span class="tb-file-title"
246
- >${S.fileHandle.name}${S.dirty ? html`<span class="tb-dirty">●</span>` : nothing}</span
247
- >`
248
- : nothing}
249
- ${breadcrumbTpl}
295
+ <sp-action-button class="tb-search-trigger" size="s" quiet @click=${openQuickSearch}>
296
+ <sp-icon-search slot="icon"></sp-icon-search>
297
+ <span class="tb-search-label">Search files… <kbd>⌘P</kbd></span>
298
+ </sp-action-button>
250
299
  <div class="tb-spacer"></div>
251
- ${togglesTpl} ${modeSwitcherTpl}
300
+ ${breadcrumbTpl} ${togglesTpl} ${modeSwitcherTpl} ${csdTpl}
252
301
  `;
253
302
  }
@@ -315,6 +315,18 @@ export function createDevServerPlatform() {
315
315
  return null;
316
316
  },
317
317
 
318
+ /** @param {string} query */
319
+ async searchFiles(query) {
320
+ const glob = `**/*${query}*.{json,md}`;
321
+ const res = await fetch(
322
+ `/__studio/files?dir=${encodeURIComponent(serverPath("."))}&glob=${encodeURIComponent(glob)}`,
323
+ );
324
+ if (!res.ok) return [];
325
+ const entries = await res.json();
326
+ for (const e of entries) e.path = stripRoot(e.path);
327
+ return entries;
328
+ },
329
+
318
330
  // ─── Plugin schema ────────────────────────────────────────────────────
319
331
 
320
332
  /**