@jxsuite/studio 0.16.1 → 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/dist/studio.js +395 -77
- package/dist/studio.js.map +15 -10
- package/package.json +1 -1
- package/src/browse/browse.js +24 -3
- package/src/editor/shortcuts.js +7 -0
- package/src/files/files.js +4 -1
- package/src/panels/quick-search.js +170 -0
- package/src/panels/toolbar.js +62 -13
- package/src/platforms/devserver.js +12 -0
- package/src/recent-projects.js +57 -0
- package/src/studio.js +58 -9
- package/src/ui/spectrum.js +2 -0
package/package.json
CHANGED
package/src/browse/browse.js
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
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>
|
package/src/editor/shortcuts.js
CHANGED
|
@@ -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();
|
package/src/files/files.js
CHANGED
|
@@ -18,6 +18,7 @@ import { loadComponentRegistry } from "./components.js";
|
|
|
18
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();
|
|
@@ -131,6 +132,7 @@ export async function openProject({ renderActivityBar, renderLeftPanel }) {
|
|
|
131
132
|
projectState.projectDirs = foundDirs;
|
|
132
133
|
|
|
133
134
|
view.leftTab = "files";
|
|
135
|
+
addRecentProject(projectState.name, projectState.projectRoot);
|
|
134
136
|
renderActivityBar();
|
|
135
137
|
renderLeftPanel();
|
|
136
138
|
statusMessage(`Opened project: ${projectState.name}`);
|
|
@@ -633,6 +635,7 @@ export async function openFileInTab(path) {
|
|
|
633
635
|
sourceFormat: path.endsWith(".md") ? "md" : null,
|
|
634
636
|
});
|
|
635
637
|
projectState.selectedPath = path;
|
|
638
|
+
trackRecentFile({ path, name: path.split("/").pop() || path });
|
|
636
639
|
statusMessage(`Opened ${path.split("/").pop()}`);
|
|
637
640
|
} catch (/** @type {any} */ e) {
|
|
638
641
|
statusMessage(`Error: ${e.message}`);
|
|
@@ -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
|
+
}
|
package/src/panels/toolbar.js
CHANGED
|
@@ -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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
/**
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const STORAGE_KEY = "jx-studio-recent-projects";
|
|
2
|
+
const FILES_STORAGE_KEY = "jx-studio-recent-files";
|
|
3
|
+
const MAX_RECENT = 8;
|
|
4
|
+
const MAX_RECENT_FILES = 10;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{ name: string; root: string; timestamp: number }} RecentProject
|
|
8
|
+
*
|
|
9
|
+
* @typedef {{ path: string; name: string; timestamp: number }} RecentFile
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @returns {RecentProject[]} */
|
|
13
|
+
export function getRecentProjects() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
16
|
+
if (!raw) return [];
|
|
17
|
+
return /** @type {RecentProject[]} */ (JSON.parse(raw)).sort(
|
|
18
|
+
(a, b) => b.timestamp - a.timestamp,
|
|
19
|
+
);
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} name
|
|
27
|
+
* @param {string} root
|
|
28
|
+
*/
|
|
29
|
+
export function addRecentProject(name, root) {
|
|
30
|
+
const projects = getRecentProjects().filter((p) => p.root !== root);
|
|
31
|
+
projects.unshift({ name, root, timestamp: Date.now() });
|
|
32
|
+
if (projects.length > MAX_RECENT) projects.length = MAX_RECENT;
|
|
33
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function clearRecentProjects() {
|
|
37
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns {RecentFile[]} */
|
|
41
|
+
export function getRecentFiles() {
|
|
42
|
+
try {
|
|
43
|
+
const raw = localStorage.getItem(FILES_STORAGE_KEY);
|
|
44
|
+
if (!raw) return [];
|
|
45
|
+
return /** @type {RecentFile[]} */ (JSON.parse(raw)).sort((a, b) => b.timestamp - a.timestamp);
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {{ path: string; name: string }} file */
|
|
52
|
+
export function trackRecentFile(file) {
|
|
53
|
+
const recent = getRecentFiles().filter((f) => f.path !== file.path);
|
|
54
|
+
recent.unshift({ path: file.path, name: file.name, timestamp: Date.now() });
|
|
55
|
+
if (recent.length > MAX_RECENT_FILES) recent.length = MAX_RECENT_FILES;
|
|
56
|
+
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(recent));
|
|
57
|
+
}
|
package/src/studio.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
updateUi,
|
|
17
17
|
} from "./store.js";
|
|
18
18
|
|
|
19
|
-
import { activeTab, openTab } from "./workspace/workspace.js";
|
|
19
|
+
import { activeTab, openTab, replaceAllTabs } from "./workspace/workspace.js";
|
|
20
20
|
import { transactDoc, mutateUpdateDef, mutateUpdateProperty } from "./tabs/transact.js";
|
|
21
21
|
import { effect } from "./reactivity.js";
|
|
22
22
|
|
|
@@ -35,19 +35,14 @@ import {
|
|
|
35
35
|
setStatusbarRenderer,
|
|
36
36
|
mountStatusbar,
|
|
37
37
|
} from "./panels/statusbar.js";
|
|
38
|
-
import {
|
|
39
|
-
openFile,
|
|
40
|
-
loadMarkdown,
|
|
41
|
-
saveFile,
|
|
42
|
-
exportFile,
|
|
43
|
-
serializeDocument,
|
|
44
|
-
} from "./files/file-ops.js";
|
|
38
|
+
import { loadMarkdown, saveFile, exportFile, serializeDocument } from "./files/file-ops.js";
|
|
45
39
|
import {
|
|
46
40
|
loadProject as _loadProject,
|
|
47
41
|
openProject as _openProject,
|
|
48
42
|
renderFilesTemplate as _renderFilesTemplate,
|
|
49
43
|
openFileInTab,
|
|
50
44
|
setupTreeKeyboard,
|
|
45
|
+
loadDirectory,
|
|
51
46
|
} from "./files/files.js";
|
|
52
47
|
import { renderImportsTemplate } from "./panels/imports-panel.js";
|
|
53
48
|
import { renderHeadTemplate } from "./panels/head-panel.js";
|
|
@@ -86,6 +81,8 @@ import { renderBlockActionBar, initBlockActionBar } from "./panels/block-action-
|
|
|
86
81
|
import { initCssData } from "./panels/style-utils.js";
|
|
87
82
|
import { updateForcedPseudoPreview } from "./panels/pseudo-preview.js";
|
|
88
83
|
import { initPanelEvents } from "./panels/panel-events.js";
|
|
84
|
+
import { initQuickSearch } from "./panels/quick-search.js";
|
|
85
|
+
import { addRecentProject } from "./recent-projects.js";
|
|
89
86
|
|
|
90
87
|
// ─── Globals ──────────────────────────────────────────────────────────────────
|
|
91
88
|
// These mutable variables are local to studio.js for now. As sections are extracted
|
|
@@ -260,7 +257,7 @@ toolbarPanel.mount(toolbarEl, {
|
|
|
260
257
|
navigateBack: () => navigateBack(),
|
|
261
258
|
closeFunctionEditor: () => closeFunctionEditor(),
|
|
262
259
|
openProject: () => openProject(),
|
|
263
|
-
|
|
260
|
+
openRecentProject: (/** @type {string} */ root) => openRecentProject(root),
|
|
264
261
|
saveFile: () => saveFile(),
|
|
265
262
|
parseMediaEntries,
|
|
266
263
|
getCanvasMode,
|
|
@@ -269,6 +266,8 @@ toolbarPanel.mount(toolbarEl, {
|
|
|
269
266
|
safeRenderRightPanel: () => safeRenderRightPanel(),
|
|
270
267
|
});
|
|
271
268
|
|
|
269
|
+
initQuickSearch();
|
|
270
|
+
|
|
272
271
|
tabStrip.mount(/** @type {HTMLElement} */ (document.querySelector("#tab-strip")));
|
|
273
272
|
|
|
274
273
|
overlaysPanel.mount({
|
|
@@ -543,6 +542,56 @@ function openProject() {
|
|
|
543
542
|
renderLeftPanel,
|
|
544
543
|
});
|
|
545
544
|
}
|
|
545
|
+
async function openRecentProject(/** @type {string} */ root) {
|
|
546
|
+
try {
|
|
547
|
+
const platform = getPlatform();
|
|
548
|
+
platform.projectRoot = root;
|
|
549
|
+
const content = await platform.readFile("project.json");
|
|
550
|
+
const config = JSON.parse(content);
|
|
551
|
+
|
|
552
|
+
replaceAllTabs({ id: "initial", document: { tagName: "div", children: [] } });
|
|
553
|
+
|
|
554
|
+
setProjectState({
|
|
555
|
+
...projectState,
|
|
556
|
+
projectRoot: root,
|
|
557
|
+
isSiteProject: true,
|
|
558
|
+
projectConfig: config,
|
|
559
|
+
name: config.name || root.split("/").pop(),
|
|
560
|
+
dirs: new Map(),
|
|
561
|
+
expanded: new Set(),
|
|
562
|
+
selectedPath: null,
|
|
563
|
+
searchQuery: "",
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
await loadDirectory(".");
|
|
567
|
+
await loadComponentRegistry();
|
|
568
|
+
|
|
569
|
+
const conventionalDirs = [
|
|
570
|
+
"pages",
|
|
571
|
+
"layouts",
|
|
572
|
+
"components",
|
|
573
|
+
"content",
|
|
574
|
+
"data",
|
|
575
|
+
"public",
|
|
576
|
+
"styles",
|
|
577
|
+
];
|
|
578
|
+
const entries = projectState.dirs.get(".") || [];
|
|
579
|
+
for (const e of entries) {
|
|
580
|
+
if (e.type === "directory" && conventionalDirs.includes(e.name)) {
|
|
581
|
+
projectState.expanded.add(e.path || e.name);
|
|
582
|
+
await loadDirectory(e.path || e.name);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
addRecentProject(projectState.name, root);
|
|
587
|
+
view.leftTab = "files";
|
|
588
|
+
renderActivityBar();
|
|
589
|
+
renderLeftPanel();
|
|
590
|
+
statusMessage(`Opened project: ${projectState.name}`);
|
|
591
|
+
} catch (/** @type {any} */ e) {
|
|
592
|
+
statusMessage(`Error: ${e.message}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
546
595
|
function renderFilesTemplate() {
|
|
547
596
|
return _renderFilesTemplate({ openProject, openFileFromTree, renderLeftPanel });
|
|
548
597
|
}
|
package/src/ui/spectrum.js
CHANGED
|
@@ -114,6 +114,7 @@ import { IconDistributeHorizontalCenter } from "@spectrum-web-components/icons-w
|
|
|
114
114
|
import { IconTextBaselineShift } from "@spectrum-web-components/icons-workflow/src/elements/IconTextBaselineShift.js";
|
|
115
115
|
import { IconFlipVertical } from "@spectrum-web-components/icons-workflow/src/elements/IconFlipVertical.js";
|
|
116
116
|
import { IconRemove } from "@spectrum-web-components/icons-workflow/src/elements/IconRemove.js";
|
|
117
|
+
import { IconFullScreen } from "@spectrum-web-components/icons-workflow/src/elements/IconFullScreen.js";
|
|
117
118
|
import { IconViewColumn } from "@spectrum-web-components/icons-workflow/src/elements/IconViewColumn.js";
|
|
118
119
|
import { IconBox } from "@spectrum-web-components/icons-workflow/src/elements/IconBox.js";
|
|
119
120
|
import { IconVisibility } from "@spectrum-web-components/icons-workflow/src/elements/IconVisibility.js";
|
|
@@ -244,6 +245,7 @@ const components = [
|
|
|
244
245
|
["sp-icon-text-baseline-shift", IconTextBaselineShift],
|
|
245
246
|
["sp-icon-flip-vertical", IconFlipVertical],
|
|
246
247
|
["sp-icon-remove", IconRemove],
|
|
248
|
+
["sp-icon-full-screen", IconFullScreen],
|
|
247
249
|
["sp-icon-download", IconDownload],
|
|
248
250
|
["sp-icon-checkmark", IconCheckmark],
|
|
249
251
|
["sp-icon-view-column", IconViewColumn],
|