@jxsuite/studio 0.31.1 → 0.33.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
  }
@@ -0,0 +1,107 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Ai-settings.js — local persistence for the AI assistant's provider settings.
4
+ *
5
+ * The Stack B proxy reads the OpenAI key from the `X-Api-Key` header (falling back to the server's
6
+ * `OPENAI_API_KEY` env var). Studio stores a user-supplied key in localStorage so the browser/dev
7
+ * build works without an env var. The key never leaves the machine except to the same-origin proxy.
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ const KEY_STORAGE = "jx.ai.openaiKey";
13
+ const BASE_URL_STORAGE = "jx.ai.baseUrl";
14
+ const MODEL_STORAGE = "jx.ai.model";
15
+ const DEFAULT_MODEL = "gpt-4o";
16
+
17
+ /** @returns {string} The stored OpenAI key, or "" if none/unavailable. */
18
+ export function getOpenAiKey() {
19
+ try {
20
+ return globalThis.localStorage?.getItem(KEY_STORAGE) ?? "";
21
+ } catch {
22
+ return "";
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Persist (or clear) the OpenAI key.
28
+ *
29
+ * @param {string} key - The key to store; an empty/blank value clears it.
30
+ */
31
+ export function setOpenAiKey(key: string) {
32
+ try {
33
+ const trimmed = (key || "").trim();
34
+ if (trimmed) {
35
+ globalThis.localStorage?.setItem(KEY_STORAGE, trimmed);
36
+ } else {
37
+ globalThis.localStorage?.removeItem(KEY_STORAGE);
38
+ }
39
+ } catch {
40
+ /* LocalStorage unavailable — settings are not persisted. */
41
+ }
42
+ }
43
+
44
+ /** @returns {boolean} Whether a non-empty key is stored. */
45
+ export function hasOpenAiKey() {
46
+ return getOpenAiKey().length > 0;
47
+ }
48
+
49
+ /**
50
+ * @returns {string} The OpenAI-compatible base URL override (e.g. a local LLM, OpenRouter, Azure),
51
+ * or "" to use the proxy's default (`https://api.openai.com/v1` / server `OPENAI_BASE_URL`).
52
+ */
53
+ export function getBaseUrl() {
54
+ try {
55
+ return globalThis.localStorage?.getItem(BASE_URL_STORAGE) ?? "";
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Persist (or clear) the base URL override.
63
+ *
64
+ * @param {string} url - The base URL; an empty/blank value clears the override.
65
+ */
66
+ export function setBaseUrl(url: string) {
67
+ try {
68
+ const trimmed = (url || "").trim().replace(/\/+$/, "");
69
+ if (trimmed) {
70
+ globalThis.localStorage?.setItem(BASE_URL_STORAGE, trimmed);
71
+ } else {
72
+ globalThis.localStorage?.removeItem(BASE_URL_STORAGE);
73
+ }
74
+ } catch {
75
+ /* LocalStorage unavailable — settings are not persisted. */
76
+ }
77
+ }
78
+
79
+ // ─── Model selection ────────────────────────────────────────────────────────
80
+
81
+ /** @returns {string} The stored model ID, or the default ("gpt-4o"). */
82
+ export function getModel() {
83
+ try {
84
+ return globalThis.localStorage?.getItem(MODEL_STORAGE) || DEFAULT_MODEL;
85
+ } catch {
86
+ return DEFAULT_MODEL;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Persist the selected model ID.
92
+ *
93
+ * @param {string} modelId - The model ID to store; an empty/blank value clears it, reverting to
94
+ * default.
95
+ */
96
+ export function setModel(modelId: string) {
97
+ try {
98
+ const trimmed = (modelId || "").trim();
99
+ if (trimmed) {
100
+ globalThis.localStorage?.setItem(MODEL_STORAGE, trimmed);
101
+ } else {
102
+ globalThis.localStorage?.removeItem(MODEL_STORAGE);
103
+ }
104
+ } catch {
105
+ /* LocalStorage unavailable — settings are not persisted. */
106
+ }
107
+ }