@jxsuite/studio 0.31.0 → 0.32.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.
@@ -1,13 +1,12 @@
1
1
  /// <reference lib="dom" />
2
- interface RecentProject {
3
- name: string;
4
- root: string;
5
- timestamp: number;
6
- }
2
+ import { getPlatform, hasPlatform } from "./platform";
3
+ import type { RecentProjectEntry } from "./types";
7
4
 
8
5
  interface RecentFile {
9
6
  path: string;
10
7
  name: string;
8
+ /** The project root the file belongs to, so recents can be scoped to the open project. */
9
+ root: string;
11
10
  timestamp: number;
12
11
  }
13
12
 
@@ -16,55 +15,154 @@ const FILES_STORAGE_KEY = "jx-studio-recent-files";
16
15
  const MAX_RECENT = 8;
17
16
  const MAX_RECENT_FILES = 10;
18
17
 
19
- /** @returns {RecentProject[]} */
20
- export function getRecentProjects() {
18
+ /**
19
+ * In-memory mirror of the backend recent-projects store, hydrated once at startup. Render is
20
+ * synchronous, so reads come from here (or directly from localStorage on the dev server) rather
21
+ * than awaiting the backend.
22
+ */
23
+ let cache: RecentProjectEntry[] = [];
24
+
25
+ /**
26
+ * The active backend store, or null when none is available (dev server). Desktop and chromium
27
+ * persist a user-level file shared across all projects/windows; the dev server falls back to
28
+ * per-origin localStorage, which already survives reloads.
29
+ */
30
+ function backend(): StudioRecentStore | null {
31
+ if (!hasPlatform()) {
32
+ return null;
33
+ }
34
+ const platform = getPlatform();
35
+ return platform.getRecentProjects && platform.saveRecentProjects
36
+ ? (platform as StudioRecentStore)
37
+ : null;
38
+ }
39
+
40
+ interface StudioRecentStore {
41
+ getRecentProjects: () => Promise<RecentProjectEntry[]>;
42
+ saveRecentProjects: (projects: RecentProjectEntry[]) => Promise<void>;
43
+ }
44
+
45
+ function loadFromLocalStorage(): RecentProjectEntry[] {
21
46
  try {
22
47
  const raw = localStorage.getItem(STORAGE_KEY);
23
48
  if (!raw) {
24
49
  return [];
25
50
  }
26
- return (JSON.parse(raw) as RecentProject[]).toSorted((a, b) => b.timestamp - a.timestamp);
51
+ const parsed = JSON.parse(raw) as RecentProjectEntry[];
52
+ return Array.isArray(parsed) ? parsed : [];
27
53
  } catch {
28
54
  return [];
29
55
  }
30
56
  }
31
57
 
58
+ /** The current list from whichever store is active (cache for backend, localStorage otherwise). */
59
+ function currentList(): RecentProjectEntry[] {
60
+ return backend() ? cache : loadFromLocalStorage();
61
+ }
62
+
63
+ /** Persist a new list to the active store and keep the in-memory cache in sync. */
64
+ function commit(list: RecentProjectEntry[]): void {
65
+ cache = list;
66
+ const store = backend();
67
+ if (store) {
68
+ void store.saveRecentProjects(list);
69
+ } else {
70
+ try {
71
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
72
+ } catch {
73
+ /* Storage unavailable — best effort */
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Load the recent-projects list into the in-memory cache. Call once after the platform is
80
+ * registered; a no-op (other than priming the cache) on the dev server, where reads hit
81
+ * localStorage directly.
82
+ */
83
+ export async function hydrateRecentProjects(): Promise<void> {
84
+ const store = backend();
85
+ if (!store) {
86
+ return;
87
+ }
88
+ try {
89
+ cache = await store.getRecentProjects();
90
+ } catch {
91
+ cache = [];
92
+ }
93
+ }
94
+
95
+ /** @returns {RecentProjectEntry[]} Newest-first */
96
+ export function getRecentProjects(): RecentProjectEntry[] {
97
+ return currentList().toSorted((a, b) => b.timestamp - a.timestamp);
98
+ }
99
+
32
100
  /**
33
101
  * @param {string} name
34
102
  * @param {string} root
35
103
  */
36
104
  export function addRecentProject(name: string, root: string) {
37
- const projects = getRecentProjects().filter((p) => p.root !== root);
105
+ const projects = currentList().filter((p) => p.root !== root);
38
106
  projects.unshift({ name, root, timestamp: Date.now() });
39
107
  if (projects.length > MAX_RECENT) {
40
108
  projects.length = MAX_RECENT;
41
109
  }
42
- localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
110
+ commit(projects);
111
+ }
112
+
113
+ /** Drop a single project from the recent list (used by the UI remove action + auto-prune). */
114
+ export function removeRecentProject(root: string) {
115
+ commit(currentList().filter((p) => p.root !== root));
43
116
  }
44
117
 
45
118
  export function clearRecentProjects() {
46
- localStorage.removeItem(STORAGE_KEY);
119
+ cache = [];
120
+ const store = backend();
121
+ if (store) {
122
+ void store.saveRecentProjects([]);
123
+ } else {
124
+ localStorage.removeItem(STORAGE_KEY);
125
+ }
47
126
  }
48
127
 
49
- /** @returns {RecentFile[]} */
50
- export function getRecentFiles() {
128
+ function loadRecentFiles(): RecentFile[] {
51
129
  try {
52
130
  const raw = localStorage.getItem(FILES_STORAGE_KEY);
53
131
  if (!raw) {
54
132
  return [];
55
133
  }
56
- return (JSON.parse(raw) as RecentFile[]).toSorted((a, b) => b.timestamp - a.timestamp);
134
+ const parsed = JSON.parse(raw) as RecentFile[];
135
+ return Array.isArray(parsed) ? parsed : [];
57
136
  } catch {
58
137
  return [];
59
138
  }
60
139
  }
61
140
 
62
- /** @param {{ path: string; name: string }} file */
63
- export function trackRecentFile(file: { path: string; name: string }) {
64
- const recent = getRecentFiles().filter((f) => f.path !== file.path);
65
- recent.unshift({ name: file.name, path: file.path, timestamp: Date.now() });
66
- if (recent.length > MAX_RECENT_FILES) {
67
- recent.length = MAX_RECENT_FILES;
141
+ /**
142
+ * Recently-opened files, newest-first. Pass a project `root` to scope the list to that project (the
143
+ * Quick Access modal does this so it only ever shows files from the open project).
144
+ *
145
+ * @param {string} [root]
146
+ * @returns {RecentFile[]}
147
+ */
148
+ export function getRecentFiles(root?: string) {
149
+ const all = loadRecentFiles().toSorted((a, b) => b.timestamp - a.timestamp);
150
+ return root == null ? all : all.filter((f) => f.root === root);
151
+ }
152
+
153
+ /** @param {{ path: string; name: string; root: string }} file */
154
+ export function trackRecentFile(file: { path: string; name: string; root: string }) {
155
+ const all = loadRecentFiles().filter((f) => !(f.root === file.root && f.path === file.path));
156
+ all.unshift({ name: file.name, path: file.path, root: file.root, timestamp: Date.now() });
157
+ // Cap per project so a busy project can't evict another project's history.
158
+ const perRoot = new Map<string, number>();
159
+ const kept: RecentFile[] = [];
160
+ for (const f of all.toSorted((a, b) => b.timestamp - a.timestamp)) {
161
+ const n = (perRoot.get(f.root) ?? 0) + 1;
162
+ perRoot.set(f.root, n);
163
+ if (n <= MAX_RECENT_FILES) {
164
+ kept.push(f);
165
+ }
68
166
  }
69
- localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(recent));
167
+ localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(kept));
70
168
  }
package/src/studio.ts CHANGED
@@ -106,7 +106,7 @@ import { initCssData } from "./panels/style-utils";
106
106
  import { updateForcedPseudoPreview } from "./panels/pseudo-preview";
107
107
  import { initPanelEvents } from "./panels/panel-events";
108
108
  import { initQuickSearch } from "./panels/quick-search";
109
- import { addRecentProject } from "./recent-projects";
109
+ import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
110
110
  import { initWelcome } from "./panels/welcome-screen";
111
111
  import { openNewProjectModal } from "./new-project/new-project-modal";
112
112
  import type { DocumentStackEntry, GitDiffState } from "./types";
@@ -338,7 +338,7 @@ toolbarPanel.mount(toolbarEl, {
338
338
  });
339
339
 
340
340
  initLayers();
341
- initQuickSearch();
341
+ initQuickSearch({ openRecentProject: (root: string) => openRecentProject(root) });
342
342
 
343
343
  tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
344
344
 
@@ -662,6 +662,14 @@ if (_projectParam) {
662
662
  ensureFsSync();
663
663
  }
664
664
 
665
+ // Hydrate the recent-projects list from the backend store (desktop/chromium), then refresh the
666
+ // Toolbar dropdown + welcome screen, both of which read it synchronously.
667
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
668
+ void hydrateRecentProjects().then(() => {
669
+ toolbarPanel.render();
670
+ render();
671
+ });
672
+
665
673
  // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
666
674
 
667
675
  function renderLeftPanel() {
@@ -748,6 +756,11 @@ async function openRecentProject(root: string) {
748
756
  ensureFsSync();
749
757
  void maybePromptJxsuiteUpdate(root);
750
758
  } catch (error) {
759
+ // The project likely moved or was deleted — drop the stale entry so it stops cluttering the
760
+ // List, and refresh the dropdown + welcome screen.
761
+ removeRecentProject(root);
762
+ toolbarPanel.render();
763
+ render();
751
764
  statusMessage(`Error: ${errorMessage(error)}`);
752
765
  }
753
766
  }
package/src/types.ts CHANGED
@@ -232,6 +232,22 @@ export interface StudioPlatform {
232
232
  setWindowProject?: (root: string) => Promise<{ deduped: boolean; config: ProjectConfig | null }>;
233
233
  /** The project root this window's backend is currently bound to. */
234
234
  getProjectRoot?: () => Promise<{ root: string | null }>;
235
+ // ─── Recent projects (backend-persisted; undefined on dev-server) ───────────
236
+ /**
237
+ * Read the user-level recent-projects list from a backend store shared across all
238
+ * projects/windows. Platforms without a native backend (dev server) omit it and the studio falls
239
+ * back to localStorage.
240
+ */
241
+ getRecentProjects?: () => Promise<RecentProjectEntry[]>;
242
+ /** Persist the full recent-projects list to the backend store. */
243
+ saveRecentProjects?: (projects: RecentProjectEntry[]) => Promise<void>;
244
+ }
245
+
246
+ /** A recently-opened project, keyed by its re-openable `root` (platform-specific). */
247
+ export interface RecentProjectEntry {
248
+ name: string;
249
+ root: string;
250
+ timestamp: number;
235
251
  }
236
252
 
237
253
  // ─── Studio Types ───────────────────────────────────────────────────────────
@@ -10,7 +10,7 @@ import { html, render as litRender, nothing } from "lit-html";
10
10
  import { live } from "lit-html/directives/live.js";
11
11
  import { ref } from "lit-html/directives/ref.js";
12
12
  import { getPlatform } from "../platform";
13
- import { debouncedStyleCommit } from "../store";
13
+ import { debouncedStyleCommit, renderOnly } from "../store";
14
14
  import { getLayerSlot } from "./layers";
15
15
 
16
16
  // ─── Media file cache ────────────────────────────────────────────────────────
@@ -88,6 +88,10 @@ async function loadMediaCache() {
88
88
  m.path = m.path.replace(/^\/public\//, "/");
89
89
  }
90
90
  mediaCacheLoaded = true;
91
+ // Re-render the host panels so the Browse button (gated on mediaCache.length) appears once the
92
+ // Async listing resolves — including when an image value is already set, so the current image can
93
+ // Be replaced. Mirrors loadLayoutEntries()'s renderOnly() in head-panel.
94
+ renderOnly("leftPanel", "rightPanel");
91
95
  }
92
96
 
93
97
  /** Force media cache reload (e.g. after upload). */
@@ -168,6 +168,9 @@ export function findContentTypeSchema(
168
168
  if (!documentPath || !projectConfig?.contentTypes) {
169
169
  return null;
170
170
  }
171
+ // Content-type `source` prefixes are always forward-slash. The desktop platform can hand us
172
+ // OS-native backslash paths on Windows, so normalize before prefix matching.
173
+ const docPath = documentPath.replaceAll("\\", "/");
171
174
  for (const [name, def] of Object.entries(
172
175
  projectConfig.contentTypes as Record<string, ContentTypeDef>,
173
176
  )) {
@@ -177,7 +180,7 @@ export function findContentTypeSchema(
177
180
  const src = def.source.replace(/^\.\//, "").replace(/\/$/, "");
178
181
  const hasExt = src.includes(".") && !src.endsWith("/");
179
182
  if (hasExt) {
180
- if (documentPath === src || documentPath.endsWith(`/${src}`)) {
183
+ if (docPath === src || docPath.endsWith(`/${src}`)) {
181
184
  return { name, schema: def.schema };
182
185
  }
183
186
  } else {
@@ -187,7 +190,7 @@ export function findContentTypeSchema(
187
190
  : (formatByName(def.format)?.extensions[0] ??
188
191
  defaultContentFormat()?.extensions[0] ??
189
192
  ".json");
190
- if (documentPath.startsWith(`${src}/`) && documentPath.endsWith(ext)) {
193
+ if (docPath.startsWith(`${src}/`) && docPath.endsWith(ext)) {
191
194
  return { name, schema: def.schema };
192
195
  }
193
196
  }