|
1 | 1 | 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'; |
3 | 3 | import SectionCard from './shared/SectionCard.jsx'; |
4 | 4 | import PanelIconButton from '../shared/PanelIconButton.jsx'; |
5 | 5 |
|
@@ -54,6 +54,54 @@ function formatWhen(timestamp) { |
54 | 54 | } |
55 | 55 | } |
56 | 56 |
|
| 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 | + |
57 | 105 | const UniversesList = ({ |
58 | 106 | universes = [], |
59 | 107 | activeUniverseSlug, |
@@ -84,11 +132,35 @@ const UniversesList = ({ |
84 | 132 | const [showNewMenu, setShowNewMenu] = useState(false); |
85 | 133 | const [showLocalFileMenu, setShowLocalFileMenu] = useState(null); // Track which universe's menu is open |
86 | 134 | 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); |
87 | 143 | const loadMenuRef = useRef(null); |
88 | 144 | const newMenuRef = useRef(null); |
89 | 145 | const localFileMenuRef = useRef(null); |
90 | 146 | const containerRef = useRef(null); |
91 | 147 |
|
| 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 | + |
92 | 164 | // Track container width for responsive header layout |
93 | 165 | useEffect(() => { |
94 | 166 | const el = containerRef.current; |
@@ -125,6 +197,38 @@ const UniversesList = ({ |
125 | 197 | } |
126 | 198 | }, [showLoadMenu, showNewMenu, showLocalFileMenu]); |
127 | 199 |
|
| 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 | + |
128 | 232 | const triggerLocalFilePicker = () => { |
129 | 233 | const input = document.createElement('input'); |
130 | 234 | input.type = 'file'; |
@@ -336,6 +440,60 @@ const UniversesList = ({ |
336 | 440 | } |
337 | 441 | > |
338 | 442 | <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 | + |
339 | 497 | {isLoading ? ( |
340 | 498 | <div style={{ |
341 | 499 | display: 'flex', |
|
0 commit comments