Skip to content

Commit f33fc76

Browse files
committed
feat: Extract workspace folder management to a dedicated service and enable saving new/exported universe files directly to the configured workspace folder.
1 parent 309114e commit f33fc76

3 files changed

Lines changed: 175 additions & 66 deletions

File tree

src/GitNativeFederation.jsx

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,10 +1059,40 @@ const GitNativeFederation = ({ variant = 'panel', onRequestClose }) => {
10591059
}
10601060

10611061
try {
1062-
await universeBackendBridge.setupLocalFileHandle(createdSlug, {
1063-
mode: 'saveAs',
1064-
suggestedName: `${universeName}.redstring`
1062+
const suggestedName = `${universeName}.redstring`;
1063+
1064+
// Try creating in workspace folder first
1065+
const { createFileInWorkspace } = await import('./services/workspaceFolderService.js');
1066+
const defaultContent = JSON.stringify({
1067+
nodes: [],
1068+
edges: [],
1069+
viewport: { x: 0, y: 0, zoom: 1 }
1070+
}, null, 2);
1071+
1072+
let fileHandle = await createFileInWorkspace(suggestedName, defaultContent);
1073+
1074+
// Fallback to picker if no workspace folder or creation failed
1075+
if (!fileHandle) {
1076+
// This mimics saveAs behavior but with explicit control
1077+
fileHandle = await pickSaveLocation({ suggestedName });
1078+
await writeFile(fileHandle, defaultContent);
1079+
}
1080+
1081+
// Get filename for display
1082+
const fileName = isElectron() && typeof fileHandle === 'string'
1083+
? fileHandle.split(/[/\\]/).pop()
1084+
: (fileHandle?.name || suggestedName);
1085+
1086+
const displayPath = isElectron() && typeof fileHandle === 'string' ? fileHandle : fileName;
1087+
1088+
// Use renderer-side setFileHandle instead of bridge's setupLocalFileHandle
1089+
// This ensures we register the handle we just obtained/created
1090+
await universeBackend.setFileHandle(createdSlug, fileHandle, {
1091+
displayPath,
1092+
fileName,
1093+
suppressNotification: true
10651094
});
1095+
10661096
await universeBackendBridge.saveActiveUniverse();
10671097
setSyncStatus({
10681098
type: 'success',
@@ -2992,16 +3022,23 @@ const GitNativeFederation = ({ variant = 'panel', onRequestClose }) => {
29923022

29933023
// Prompt user to save file using adapter (works in both browser and Electron)
29943024
const suggestedName = `${universe.name || slug}.redstring`;
2995-
const fileHandle = await pickSaveLocation({ suggestedName });
3025+
3026+
// Try creating in workspace folder first
3027+
const { createFileInWorkspace } = await import('./services/workspaceFolderService.js');
3028+
let fileHandle = await createFileInWorkspace(suggestedName, jsonString);
3029+
3030+
// Fallback to picker if no workspace folder or creation failed
3031+
if (!fileHandle) {
3032+
fileHandle = await pickSaveLocation({ suggestedName });
3033+
// Write data to file using adapter (only needed for picker path)
3034+
await writeFile(fileHandle, jsonString);
3035+
}
29963036

29973037
// Get filename for display
29983038
const fileName = isElectron() && typeof fileHandle === 'string'
29993039
? fileHandle.split(/[/\\]/).pop()
30003040
: (fileHandle?.name || suggestedName);
30013041

3002-
// Write data to file using adapter
3003-
await writeFile(fileHandle, jsonString);
3004-
30053042
// Store the file handle and link to universe
30063043
const displayPath = isElectron() && typeof fileHandle === 'string' ? fileHandle : fileName;
30073044
await universeBackend.setFileHandle(slug, fileHandle, {

src/components/git-federation/UniversesList.jsx

Lines changed: 4 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -54,53 +54,7 @@ function formatWhen(timestamp) {
5454
}
5555
}
5656

57-
// IndexedDB helpers for persisting workspace folder handle
58-
const WORKSPACE_DB_NAME = 'redstring-workspace';
59-
const WORKSPACE_STORE_NAME = 'folder-handles';
60-
61-
function openWorkspaceDB() {
62-
return new Promise((resolve, reject) => {
63-
const request = indexedDB.open(WORKSPACE_DB_NAME, 1);
64-
request.onupgradeneeded = (event) => {
65-
const db = event.target.result;
66-
if (!db.objectStoreNames.contains(WORKSPACE_STORE_NAME)) {
67-
db.createObjectStore(WORKSPACE_STORE_NAME, { keyPath: 'id' });
68-
}
69-
};
70-
request.onsuccess = () => resolve(request.result);
71-
request.onerror = () => reject(request.error);
72-
});
73-
}
74-
75-
function saveWorkspaceHandleToDB(db, handle) {
76-
return new Promise((resolve, reject) => {
77-
const tx = db.transaction(WORKSPACE_STORE_NAME, 'readwrite');
78-
const store = tx.objectStore(WORKSPACE_STORE_NAME);
79-
store.put({ id: 'workspace', handle });
80-
tx.oncomplete = () => resolve();
81-
tx.onerror = () => reject(tx.error);
82-
});
83-
}
84-
85-
function getWorkspaceHandleFromDB(db) {
86-
return new Promise((resolve, reject) => {
87-
const tx = db.transaction(WORKSPACE_STORE_NAME, 'readonly');
88-
const store = tx.objectStore(WORKSPACE_STORE_NAME);
89-
const request = store.get('workspace');
90-
request.onsuccess = () => resolve(request.result?.handle || null);
91-
request.onerror = () => reject(request.error);
92-
});
93-
}
94-
95-
function clearWorkspaceHandleFromDB(db) {
96-
return new Promise((resolve, reject) => {
97-
const tx = db.transaction(WORKSPACE_STORE_NAME, 'readwrite');
98-
const store = tx.objectStore(WORKSPACE_STORE_NAME);
99-
store.delete('workspace');
100-
tx.oncomplete = () => resolve();
101-
tx.onerror = () => reject(tx.error);
102-
});
103-
}
57+
import { saveWorkspaceHandle, getWorkspaceHandle, clearWorkspaceHandle } from '../../services/workspaceFolderService.js';
10458

10559
const UniversesList = ({
10660
universes = [],
@@ -149,8 +103,7 @@ const UniversesList = ({
149103
useEffect(() => {
150104
(async () => {
151105
try {
152-
const db = await openWorkspaceDB();
153-
const handle = await getWorkspaceHandleFromDB(db);
106+
const handle = await getWorkspaceHandle();
154107
if (handle) {
155108
setWorkspaceFolderHandle(handle);
156109
setWorkspaceFolder(handle.name);
@@ -207,9 +160,7 @@ const UniversesList = ({
207160
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
208161
setWorkspaceFolderHandle(handle);
209162
setWorkspaceFolder(handle.name);
210-
localStorage.setItem('redstring_workspace_folder_name', handle.name);
211-
const db = await openWorkspaceDB();
212-
await saveWorkspaceHandleToDB(db, handle);
163+
await saveWorkspaceHandle(handle);
213164
} catch (e) {
214165
if (e.name !== 'AbortError') {
215166
console.error('[UniversesList] Failed to pick workspace folder:', e);
@@ -220,13 +171,7 @@ const UniversesList = ({
220171
const handleClearWorkspaceFolder = async () => {
221172
setWorkspaceFolderHandle(null);
222173
setWorkspaceFolder(null);
223-
localStorage.removeItem('redstring_workspace_folder_name');
224-
try {
225-
const db = await openWorkspaceDB();
226-
await clearWorkspaceHandleFromDB(db);
227-
} catch (e) {
228-
console.warn('[UniversesList] Failed to clear workspace folder from DB:', e);
229-
}
174+
await clearWorkspaceHandle();
230175
};
231176

232177
const triggerLocalFilePicker = () => {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
2+
const WORKSPACE_DB_NAME = 'redstring-workspace';
3+
const WORKSPACE_STORE_NAME = 'folder-handles';
4+
5+
let _cachedHandle = null;
6+
7+
// Opens the IndexedDB instance
8+
function openWorkspaceDB() {
9+
return new Promise((resolve, reject) => {
10+
const request = indexedDB.open(WORKSPACE_DB_NAME, 1);
11+
request.onupgradeneeded = (event) => {
12+
const db = event.target.result;
13+
if (!db.objectStoreNames.contains(WORKSPACE_STORE_NAME)) {
14+
db.createObjectStore(WORKSPACE_STORE_NAME, { keyPath: 'id' });
15+
}
16+
};
17+
request.onsuccess = () => resolve(request.result);
18+
request.onerror = () => reject(request.error);
19+
});
20+
}
21+
22+
/**
23+
* Saves a directory handle to IndexedDB
24+
* @param {FileSystemDirectoryHandle} handle
25+
*/
26+
export async function saveWorkspaceHandle(handle) {
27+
_cachedHandle = handle;
28+
try {
29+
const db = await openWorkspaceDB();
30+
await new Promise((resolve, reject) => {
31+
const tx = db.transaction(WORKSPACE_STORE_NAME, 'readwrite');
32+
const store = tx.objectStore(WORKSPACE_STORE_NAME);
33+
store.put({ id: 'workspace', handle });
34+
tx.oncomplete = () => resolve();
35+
tx.onerror = () => reject(tx.error);
36+
});
37+
// Also update localStorage for simple name checking
38+
localStorage.setItem('redstring_workspace_folder_name', handle.name);
39+
} catch (error) {
40+
console.warn('[WorkspaceFolderService] Failed to save handle:', error);
41+
throw error;
42+
}
43+
}
44+
45+
/**
46+
* Retrieves the stored directory handle
47+
* @returns {Promise<FileSystemDirectoryHandle|null>}
48+
*/
49+
export async function getWorkspaceHandle() {
50+
if (_cachedHandle) return _cachedHandle;
51+
52+
try {
53+
const db = await openWorkspaceDB();
54+
const handle = await new Promise((resolve, reject) => {
55+
const tx = db.transaction(WORKSPACE_STORE_NAME, 'readonly');
56+
const store = tx.objectStore(WORKSPACE_STORE_NAME);
57+
const request = store.get('workspace');
58+
request.onsuccess = () => resolve(request.result?.handle || null);
59+
request.onerror = () => reject(request.error);
60+
});
61+
62+
if (handle) {
63+
_cachedHandle = handle;
64+
// Verify permission is still granted if we can
65+
if (handle.queryPermission) {
66+
const state = await handle.queryPermission({ mode: 'readwrite' });
67+
if (state !== 'granted') {
68+
// If queryPermission returns prompt or denied, we might need to re-request
69+
// but we return the handle anyway so the UI can prompt
70+
}
71+
}
72+
}
73+
return handle;
74+
} catch (error) {
75+
console.warn('[WorkspaceFolderService] Failed to get handle:', error);
76+
return null;
77+
}
78+
}
79+
80+
/**
81+
* Clears the stored directory handle
82+
*/
83+
export async function clearWorkspaceHandle() {
84+
_cachedHandle = null;
85+
localStorage.removeItem('redstring_workspace_folder_name');
86+
try {
87+
const db = await openWorkspaceDB();
88+
await new Promise((resolve, reject) => {
89+
const tx = db.transaction(WORKSPACE_STORE_NAME, 'readwrite');
90+
const store = tx.objectStore(WORKSPACE_STORE_NAME);
91+
store.delete('workspace');
92+
tx.oncomplete = () => resolve();
93+
tx.onerror = () => reject(tx.error);
94+
});
95+
} catch (error) {
96+
console.warn('[WorkspaceFolderService] Failed to clear handle:', error);
97+
}
98+
}
99+
100+
/**
101+
* Creates a file in the workspace folder if available
102+
* @param {string} fileName
103+
* @param {string} content
104+
* @returns {Promise<FileSystemFileHandle|null>} The created file handle, or null if no workspace folder
105+
*/
106+
export async function createFileInWorkspace(fileName, content) {
107+
const dirHandle = await getWorkspaceHandle();
108+
if (!dirHandle) return null;
109+
110+
try {
111+
// Check permission first
112+
if (dirHandle.requestPermission) {
113+
const status = await dirHandle.requestPermission({ mode: 'readwrite' });
114+
if (status !== 'granted') throw new Error('Permission denied to workspace folder');
115+
}
116+
117+
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
118+
const writable = await fileHandle.createWritable();
119+
await writable.write(content);
120+
await writable.close();
121+
return fileHandle;
122+
} catch (error) {
123+
console.error('[WorkspaceFolderService] Failed to create file in workspace:', error);
124+
// Return null to allow fallback to system picker
125+
return null;
126+
}
127+
}

0 commit comments

Comments
 (0)