Skip to content

Commit 57517d8

Browse files
committed
feat: Implement workspace folder management and persistence using the File System Access API and IndexedDB.
1 parent 09af548 commit 57517d8

1 file changed

Lines changed: 159 additions & 1 deletion

File tree

src/components/git-federation/UniversesList.jsx

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState, useEffect, useRef } from 'react';
2-
import { Plus, Trash2, ChevronDown, Github, Upload, Download, X, Edit, Star, Save, Activity, Link, FileText, ArrowRightLeft } from 'lucide-react';
2+
import { Plus, Trash2, ChevronDown, Github, Upload, Download, X, Edit, Star, Save, Activity, Link, FileText, ArrowRightLeft, FolderOpen, Folder } from 'lucide-react';
33
import SectionCard from './shared/SectionCard.jsx';
44
import PanelIconButton from '../shared/PanelIconButton.jsx';
55

@@ -54,6 +54,54 @@ 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+
}
104+
57105
const UniversesList = ({
58106
universes = [],
59107
activeUniverseSlug,
@@ -84,11 +132,35 @@ const UniversesList = ({
84132
const [showNewMenu, setShowNewMenu] = useState(false);
85133
const [showLocalFileMenu, setShowLocalFileMenu] = useState(null); // Track which universe's menu is open
86134
const [isHeaderSlim, setIsHeaderSlim] = useState(false); // Track if header should stack at < 400px
135+
const [workspaceFolder, setWorkspaceFolder] = useState(() => {
136+
try {
137+
return localStorage.getItem('redstring_workspace_folder_name') || null;
138+
} catch {
139+
return null;
140+
}
141+
});
142+
const [workspaceFolderHandle, setWorkspaceFolderHandle] = useState(null);
87143
const loadMenuRef = useRef(null);
88144
const newMenuRef = useRef(null);
89145
const localFileMenuRef = useRef(null);
90146
const containerRef = useRef(null);
91147

148+
// Try to restore workspace folder handle from IndexedDB on mount
149+
useEffect(() => {
150+
(async () => {
151+
try {
152+
const db = await openWorkspaceDB();
153+
const handle = await getWorkspaceHandleFromDB(db);
154+
if (handle) {
155+
setWorkspaceFolderHandle(handle);
156+
setWorkspaceFolder(handle.name);
157+
}
158+
} catch (e) {
159+
console.warn('[UniversesList] Failed to restore workspace folder handle:', e);
160+
}
161+
})();
162+
}, []);
163+
92164
// Track container width for responsive header layout
93165
useEffect(() => {
94166
const el = containerRef.current;
@@ -125,6 +197,38 @@ const UniversesList = ({
125197
}
126198
}, [showLoadMenu, showNewMenu, showLocalFileMenu]);
127199

200+
// Workspace folder picker
201+
const handlePickWorkspaceFolder = async () => {
202+
if (!('showDirectoryPicker' in window)) {
203+
alert('Directory picker is not supported in this browser.');
204+
return;
205+
}
206+
try {
207+
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
208+
setWorkspaceFolderHandle(handle);
209+
setWorkspaceFolder(handle.name);
210+
localStorage.setItem('redstring_workspace_folder_name', handle.name);
211+
const db = await openWorkspaceDB();
212+
await saveWorkspaceHandleToDB(db, handle);
213+
} catch (e) {
214+
if (e.name !== 'AbortError') {
215+
console.error('[UniversesList] Failed to pick workspace folder:', e);
216+
}
217+
}
218+
};
219+
220+
const handleClearWorkspaceFolder = async () => {
221+
setWorkspaceFolderHandle(null);
222+
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+
}
230+
};
231+
128232
const triggerLocalFilePicker = () => {
129233
const input = document.createElement('input');
130234
input.type = 'file';
@@ -336,6 +440,60 @@ const UniversesList = ({
336440
}
337441
>
338442
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
443+
{/* Workspace Folder Section */}
444+
<div style={{
445+
padding: '10px 12px',
446+
backgroundColor: '#cfc6c6',
447+
borderRadius: 6,
448+
border: workspaceFolder ? '2px solid #7A0000' : '2px dashed #979090',
449+
display: 'flex',
450+
alignItems: 'center',
451+
justifyContent: 'space-between',
452+
gap: 10
453+
}}>
454+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
455+
{workspaceFolder ? (
456+
<FolderOpen size={18} color="#7A0000" />
457+
) : (
458+
<Folder size={18} color="#666" />
459+
)}
460+
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
461+
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: '#260000' }}>
462+
Workspace Folder
463+
</span>
464+
<span style={{
465+
fontSize: '0.65rem',
466+
color: workspaceFolder ? '#444' : '#888',
467+
overflow: 'hidden',
468+
textOverflow: 'ellipsis',
469+
whiteSpace: 'nowrap'
470+
}}>
471+
{workspaceFolder || 'Not linked — files may lose permissions on reload'}
472+
</span>
473+
</div>
474+
</div>
475+
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
476+
<button
477+
onClick={handlePickWorkspaceFolder}
478+
style={{
479+
...buttonStyle('outline'),
480+
fontSize: '0.65rem',
481+
padding: '4px 8px'
482+
}}
483+
>
484+
{workspaceFolder ? 'Change' : 'Choose'}
485+
</button>
486+
{workspaceFolder && (
487+
<PanelIconButton
488+
icon={X}
489+
size={16}
490+
onClick={handleClearWorkspaceFolder}
491+
title="Unlink workspace folder"
492+
/>
493+
)}
494+
</div>
495+
</div>
496+
339497
{isLoading ? (
340498
<div style={{
341499
display: 'flex',

0 commit comments

Comments
 (0)