feat(storage): replace File System Access API with browser-native OPFS#260
feat(storage): replace File System Access API with browser-native OPFS#260janiluuk wants to merge 1 commit into
Conversation
WorkspaceGate now auto-initializes via navigator.storage.getDirectory() instead of showing a folder-picker splash screen. Storage-dependent routes block for <1 frame while OPFS opens, then render immediately — no warning, no permission prompt, straight to the editor. Media file import switches from showOpenFilePicker to <input type="file">, returning File[] objects that are copied into OPFS (storageType: 'opfs'). importHandles/importHandlesForPlacement now accept File[] and route through the new public importMediaFile() service method. Drag-drop uses dataTransfer.files instead of getAsFileSystemHandle(), removing the dependency on the File System Access API drag-drop extension. ExtractedMediaFileEntry.handle removed; all call sites updated to use .file. showFileHandlePicker preserved as a @deprecated helper for relinking broken handle-based media in existing projects (media-grid, missing-media-dialog). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@janiluuk is attempting to deploy a commit to the walterlow's projects Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThis PR migrates the media library import pipeline from File System Access API ( ChangesMedia Library File API Migration
Workspace Gate OPFS Bootstrap Refactor
Sequence Diagram(s)sequenceDiagram
participant User as User/UI
participant Picker as File Picker
participant Actions as Import Actions
participant Service as MediaLibraryService
participant OPFS as OPFS Storage
User->>Picker: Click import or drag files
Picker->>Picker: Show file dialog or extract from drop
Picker-->>Actions: File[]
Actions->>Actions: Create optimistic metadata (storageType: opfs)
Actions->>Service: importMediaFile(file, projectId)
Service->>OPFS: importMediaFileToOpfs(file)
OPFS-->>Service: MediaMetadata
Service-->>Actions: metadata + metadata
Actions->>Actions: Resolve optimistic, emit success/error
Actions-->>User: Imported media list
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR replaces the File System Access API (folder-picker +
Confidence Score: 3/5The core OPFS migration is clean and the drag-drop/timeline call-site updates are correct, but two initialization paths leave users in a broken or hung state without any UI feedback. When navigator.storage.getDirectory() fails (private browsing in Firefox/Safari, quota restrictions), setReady(true) still fires and renders protected routes against an uninitialized workspace — every storage operation will silently fail. Separately, dismissing the file picker on Safari older than 17.4 leaves the showMediaFilePicker promise permanently unresolved, hanging the import UI with no way to recover. Both paths can be hit in normal use. workspace-gate.tsx and media-file-picker.ts each have a scenario where the app proceeds without a valid state; everything else in the PR looks correct. Important Files Changed
Sequence DiagramsequenceDiagram
participant App
participant WorkspaceGate
participant OPFS as navigator.storage (OPFS)
participant MediaLib as MediaLibrary
participant FilePicker as input[type=file]
participant DragDrop as file-drop.ts
App->>WorkspaceGate: mount
WorkspaceGate->>OPFS: getDirectory()
OPFS-->>WorkspaceGate: FileSystemDirectoryHandle
WorkspaceGate->>WorkspaceGate: setWorkspaceRoot(handle)
WorkspaceGate->>WorkspaceGate: bootstrapWorkspace(handle)
WorkspaceGate->>WorkspaceGate: setReady(true) [always, even on error]
WorkspaceGate-->>App: render children
App->>MediaLib: importMedia()
MediaLib->>FilePicker: createElement('input') + click()
FilePicker-->>MediaLib: change event → File[]
Note over FilePicker,MediaLib: cancel event may not fire on older Safari
MediaLib->>MediaLib: importMediaFile(file, projectId) → OPFS
App->>DragDrop: extractValidMediaFileEntriesFromDataTransfer(dataTransfer)
DragDrop->>DragDrop: Array.from(dataTransfer.files)
DragDrop-->>App: "ExtractedMediaFileEntry[] {file, mediaType}"
App->>MediaLib: importHandlesForPlacement(files) → OPFS
Prompt To Fix All With AIFix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
src/features/workspace-gate/workspace-gate.tsx:41-44
`setReady(true)` is called unconditionally even when the outer `catch` fires. If `navigator.storage.getDirectory()` rejects (private-browsing mode in some browsers, or permissions denied), `setWorkspaceRoot` is never called, yet the gate immediately unblocks protected routes — every subsequent OPFS read/write will fail without any error UI shown to the user.
```suggestion
} catch (error) {
logger.error('OPFS initialization failed', error)
return
}
setReady(true)
```
### Issue 2 of 3
src/features/media-library/utils/media-file-picker.ts:19-22
The `cancel` event on `<input type="file">` is only supported from Safari 17.4+. On older Safari (and any browser that never fires `cancel`), dismissing the file picker without selecting a file leaves the returned Promise permanently unresolved — the caller in `importMedia` hangs forever and the UI never recovers. A common fix is to register a one-shot `focus` listener on `window` as a fallback cancellation signal.
```suggestion
input.addEventListener('change', () => done(Array.from(input.files ?? [])))
input.addEventListener('cancel', () => done([]))
// Fallback for browsers that don't fire 'cancel' (Safari < 17.4):
// window regains focus after the dialog closes without a selection.
const onFocus = () => {
// Give the 'change' event a tick to arrive first.
setTimeout(() => done([]), 300)
}
window.addEventListener('focus', onFocus, { once: true })
input.click()
```
### Issue 3 of 3
src/features/media-library/stores/media-import-actions.ts:132
The second argument passed to `logger.error` is now `importResults[index]` — the entire `PromiseSettledResult` object (`{ status: 'rejected', reason: Error }`) — rather than the underlying error. The test reflects this by relaxing the assertion from `expect.any(Error)` to `expect.anything()`. Log consumers and error-tracking integrations that expect an `Error` instance here will receive a plain object instead.
```suggestion
logger.error(`Failed to import ${importTask.file.name}`, result.reason)
```
Reviews (1): Last reviewed commit: "feat(storage): replace File System Acces..." | Re-trigger Greptile |
| } catch (error) { | ||
| logger.error('OPFS initialization failed', error) | ||
| } | ||
| })().catch((error) => { | ||
| logger.error('Gate initialization failed', error) | ||
| if (!cancelled) setStatus({ kind: 'pick' }) | ||
| }) | ||
| return () => { | ||
| cancelled = true | ||
| } | ||
| }, [activate]) | ||
|
|
||
| // Permission-lost mid-session → flip to reconnect. | ||
| useEffect(() => { | ||
| const unsubscribe = onPermissionLost(() => { | ||
| void (async () => { | ||
| const record = await getWorkspaceHandleRecord() | ||
| setStatus({ kind: 'reconnect', handleName: record?.name ?? 'workspace' }) | ||
| })() | ||
| }) | ||
| return () => { | ||
| unsubscribe() | ||
| } | ||
| setReady(true) |
There was a problem hiding this comment.
setReady(true) is called unconditionally even when the outer catch fires. If navigator.storage.getDirectory() rejects (private-browsing mode in some browsers, or permissions denied), setWorkspaceRoot is never called, yet the gate immediately unblocks protected routes — every subsequent OPFS read/write will fail without any error UI shown to the user.
| } catch (error) { | |
| logger.error('OPFS initialization failed', error) | |
| } | |
| })().catch((error) => { | |
| logger.error('Gate initialization failed', error) | |
| if (!cancelled) setStatus({ kind: 'pick' }) | |
| }) | |
| return () => { | |
| cancelled = true | |
| } | |
| }, [activate]) | |
| // Permission-lost mid-session → flip to reconnect. | |
| useEffect(() => { | |
| const unsubscribe = onPermissionLost(() => { | |
| void (async () => { | |
| const record = await getWorkspaceHandleRecord() | |
| setStatus({ kind: 'reconnect', handleName: record?.name ?? 'workspace' }) | |
| })() | |
| }) | |
| return () => { | |
| unsubscribe() | |
| } | |
| setReady(true) | |
| } catch (error) { | |
| logger.error('OPFS initialization failed', error) | |
| return | |
| } | |
| setReady(true) |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/workspace-gate/workspace-gate.tsx
Line: 41-44
Comment:
`setReady(true)` is called unconditionally even when the outer `catch` fires. If `navigator.storage.getDirectory()` rejects (private-browsing mode in some browsers, or permissions denied), `setWorkspaceRoot` is never called, yet the gate immediately unblocks protected routes — every subsequent OPFS read/write will fail without any error UI shown to the user.
```suggestion
} catch (error) {
logger.error('OPFS initialization failed', error)
return
}
setReady(true)
```
How can I resolve this? If you propose a fix, please make it concise.| input.addEventListener('change', () => done(Array.from(input.files ?? []))) | ||
| input.addEventListener('cancel', () => done([])) | ||
|
|
||
| input.click() |
There was a problem hiding this comment.
The
cancel event on <input type="file"> is only supported from Safari 17.4+. On older Safari (and any browser that never fires cancel), dismissing the file picker without selecting a file leaves the returned Promise permanently unresolved — the caller in importMedia hangs forever and the UI never recovers. A common fix is to register a one-shot focus listener on window as a fallback cancellation signal.
| input.addEventListener('change', () => done(Array.from(input.files ?? []))) | |
| input.addEventListener('cancel', () => done([])) | |
| input.click() | |
| input.addEventListener('change', () => done(Array.from(input.files ?? []))) | |
| input.addEventListener('cancel', () => done([])) | |
| // Fallback for browsers that don't fire 'cancel' (Safari < 17.4): | |
| // window regains focus after the dialog closes without a selection. | |
| const onFocus = () => { | |
| // Give the 'change' event a tick to arrive first. | |
| setTimeout(() => done([]), 300) | |
| } | |
| window.addEventListener('focus', onFocus, { once: true }) | |
| input.click() |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/media-library/utils/media-file-picker.ts
Line: 19-22
Comment:
The `cancel` event on `<input type="file">` is only supported from Safari 17.4+. On older Safari (and any browser that never fires `cancel`), dismissing the file picker without selecting a file leaves the returned Promise permanently unresolved — the caller in `importMedia` hangs forever and the UI never recovers. A common fix is to register a one-shot `focus` listener on `window` as a fallback cancellation signal.
```suggestion
input.addEventListener('change', () => done(Array.from(input.files ?? [])))
input.addEventListener('cancel', () => done([]))
// Fallback for browsers that don't fire 'cancel' (Safari < 17.4):
// window regains focus after the dialog closes without a selection.
const onFocus = () => {
// Give the 'change' event a tick to arrive first.
setTimeout(() => done([]), 300)
}
window.addEventListener('focus', onFocus, { once: true })
input.click()
```
How can I resolve this? If you propose a fix, please make it concise.| failedCount++ | ||
| removeImportPlaceholder(set, importTask.tempId) | ||
| logger.error(`Failed to import ${importTask.file.name}`, result.reason) | ||
| logger.error(`Failed to import ${importTask.file.name}`, importResults[index]) |
There was a problem hiding this comment.
The second argument passed to
logger.error is now importResults[index] — the entire PromiseSettledResult object ({ status: 'rejected', reason: Error }) — rather than the underlying error. The test reflects this by relaxing the assertion from expect.any(Error) to expect.anything(). Log consumers and error-tracking integrations that expect an Error instance here will receive a plain object instead.
| logger.error(`Failed to import ${importTask.file.name}`, importResults[index]) | |
| logger.error(`Failed to import ${importTask.file.name}`, result.reason) |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/media-library/stores/media-import-actions.ts
Line: 132
Comment:
The second argument passed to `logger.error` is now `importResults[index]` — the entire `PromiseSettledResult` object (`{ status: 'rejected', reason: Error }`) — rather than the underlying error. The test reflects this by relaxing the assertion from `expect.any(Error)` to `expect.anything()`. Log consumers and error-tracking integrations that expect an `Error` instance here will receive a plain object instead.
```suggestion
logger.error(`Failed to import ${importTask.file.name}`, result.reason)
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/features/media-library/utils/media-file-picker.ts`:
- Around line 30-31: Wrap the unguarded call to window.showOpenFilePicker with a
feature-detection check and fallback to the existing controlled error/relink
flow: before calling window.showOpenFilePicker (the expression currently
returned), check if typeof window.showOpenFilePicker === "function"; if true,
call and return it as before, otherwise invoke the fallback path used by this
module (e.g., return a rejected Promise or call the module's relink/error
handler) so unsupported browsers (Safari/Firefox) don't throw a TypeError;
update the code around the existing return window.showOpenFilePicker(...)
expression to implement this guard.
In `@src/features/workspace-gate/workspace-gate.tsx`:
- Around line 28-46: The effect currently sets setReady(true) even when
navigator.storage.getDirectory() fails, allowing protected routes to render
without a workspace; modify the async IIFE so that setReady(true) is only called
on successful OPFS initialization (after setWorkspaceRoot and
bootstrapWorkspace), and on catch of getDirectory() set an error state (e.g.,
setOpfsError or reuse setReady(false)) and either render an error/redirect
instead of children; update references in this function
(navigator.storage.getDirectory, setWorkspaceRoot, bootstrapWorkspace,
autoPurgeExpiredTrash, setReady, logger) to ensure errors are logged via
logger.error and the component does not set ready when OPFS initialization
fails.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 98b3dcb0-8dd7-497a-b08e-1a08c79be17c
📒 Files selected for processing (17)
src/features/media-library/components/media-grid.tsxsrc/features/media-library/components/media-library.tsxsrc/features/media-library/components/missing-media-dialog.tsxsrc/features/media-library/contracts/timeline.tssrc/features/media-library/services/media-library-service.tssrc/features/media-library/stores/media-import-actions.test.tssrc/features/media-library/stores/media-import-actions.tssrc/features/media-library/types.tssrc/features/media-library/utils/file-drop.tssrc/features/media-library/utils/media-file-picker.test.tssrc/features/media-library/utils/media-file-picker.tssrc/features/preview/deps/media-library-contract.tssrc/features/preview/hooks/use-canvas-media-drop.tssrc/features/timeline/deps/media-library-resolver.tssrc/features/timeline/utils/drop-execution.tssrc/features/timeline/utils/external-file-project-match.test.tssrc/features/workspace-gate/workspace-gate.tsx
💤 Files with no reviewable changes (4)
- src/features/media-library/contracts/timeline.ts
- src/features/timeline/deps/media-library-resolver.ts
- src/features/timeline/utils/external-file-project-match.test.ts
- src/features/preview/deps/media-library-contract.ts
| return window.showOpenFilePicker({ | ||
| multiple: options?.multiple ?? true, | ||
| types: MEDIA_FILE_PICKER_TYPES, | ||
| multiple: options?.multiple ?? false, |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Which browsers currently support window.showOpenFilePicker (File System Access API), and what is the recommended fallback behavior when unsupported?
💡 Result:
Currently, window.showOpenFilePicker (File System Access API “picker” for opening local files) is supported in Chromium-based desktop browsers: Chrome and Microsoft Edge support it from version 86 onward, and Opera supports it from version 72 onward (per Can I use’s extracted MDN compatibility data) [1]. In contrast, Firefox and Safari do not support showOpenFilePicker (and the wider File System Access “picker” methods) [2]. Recommended fallback when unsupported: use a classic file-picking/upload fallback such as (and/or an download flow for saving). The browser-fs-access project describes this as a “transparent fallback to the and legacy methods,” and it feature-detects support for the File System Access API and otherwise uses the fallback implementation [3]. Practical guidance: detect support before calling, e.g. check whether 'showOpenFilePicker' exists in self, and if not, use the fallback file input approach [4][3].
Citations:
- 1: https://caniuse.com/mdn-api_window_showopenfilepicker
- 2: https://developer.chrome.google.cn/docs/capabilities/web-apis/file-system-access
- 3: https://github.com/GoogleChromeLabs/browser-fs-access/blob/main/README.md
- 4: https://developer.chrome.com/docs/capabilities/web-apis/file-system-access
Guard showOpenFilePicker behind feature detection to avoid TypeError in unsupported browsers.
window.showOpenFilePicker is supported only in Chromium-based desktop browsers (e.g., Chrome/Edge/Opera); Firefox and Safari don’t implement it, so the current unguarded call can throw a runtime TypeError instead of a controlled relink/error path.
Suggested fix
export async function showFileHandlePicker(options?: {
multiple?: boolean
}): Promise<FileSystemFileHandle[]> {
+ if (typeof window === 'undefined' || typeof window.showOpenFilePicker !== 'function') {
+ throw new Error('File relinking is not supported in this browser.')
+ }
return window.showOpenFilePicker({
multiple: options?.multiple ?? false,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return window.showOpenFilePicker({ | |
| multiple: options?.multiple ?? true, | |
| types: MEDIA_FILE_PICKER_TYPES, | |
| multiple: options?.multiple ?? false, | |
| export async function showFileHandlePicker(options?: { | |
| multiple?: boolean | |
| }): Promise<FileSystemFileHandle[]> { | |
| if (typeof window === 'undefined' || typeof window.showOpenFilePicker !== 'function') { | |
| throw new Error('File relinking is not supported in this browser.') | |
| } | |
| return window.showOpenFilePicker({ | |
| multiple: options?.multiple ?? false, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/features/media-library/utils/media-file-picker.ts` around lines 30 - 31,
Wrap the unguarded call to window.showOpenFilePicker with a feature-detection
check and fallback to the existing controlled error/relink flow: before calling
window.showOpenFilePicker (the expression currently returned), check if typeof
window.showOpenFilePicker === "function"; if true, call and return it as before,
otherwise invoke the fallback path used by this module (e.g., return a rejected
Promise or call the module's relink/error handler) so unsupported browsers
(Safari/Firefox) don't throw a TypeError; update the code around the existing
return window.showOpenFilePicker(...) expression to implement this guard.
| useEffect(() => { | ||
| let cancelled = false | ||
| ;(async () => { | ||
| if (!isFileSystemAccessSupported()) { | ||
| if (!cancelled) setStatus({ kind: 'unavailable' }) | ||
| return | ||
| } | ||
| // Promote any legacy `workspace:current` into a proper known-workspace | ||
| // record before we read it, so the indicator's "known workspaces" list | ||
| // includes the one the user is about to use. | ||
| await ensureKnownWorkspaceForCurrent() | ||
| const record = await getWorkspaceHandleRecord() | ||
| if (!record) { | ||
| if (!cancelled) setStatus({ kind: 'pick' }) | ||
| return | ||
| } | ||
| const handle = record.handle as FileSystemDirectoryHandle | ||
| const permission = await queryHandlePermission(handle) | ||
| if (cancelled) return | ||
| if (permission === 'granted') { | ||
| await activate(handle) | ||
| } else { | ||
| setStatus({ kind: 'reconnect', handleName: record.name }) | ||
| try { | ||
| const handle = await navigator.storage.getDirectory() | ||
| setWorkspaceRoot(handle) | ||
| try { | ||
| await bootstrapWorkspace(handle) | ||
| } catch (error) { | ||
| logger.warn('bootstrapWorkspace failed', error) | ||
| } | ||
| setTimeout(() => { | ||
| void autoPurgeExpiredTrash() | ||
| }, 0) | ||
| } catch (error) { | ||
| logger.error('OPFS initialization failed', error) | ||
| } | ||
| })().catch((error) => { | ||
| logger.error('Gate initialization failed', error) | ||
| if (!cancelled) setStatus({ kind: 'pick' }) | ||
| }) | ||
| return () => { | ||
| cancelled = true | ||
| } | ||
| }, [activate]) | ||
|
|
||
| // Permission-lost mid-session → flip to reconnect. | ||
| useEffect(() => { | ||
| const unsubscribe = onPermissionLost(() => { | ||
| void (async () => { | ||
| const record = await getWorkspaceHandleRecord() | ||
| setStatus({ kind: 'reconnect', handleName: record?.name ?? 'workspace' }) | ||
| })() | ||
| }) | ||
| return () => { | ||
| unsubscribe() | ||
| } | ||
| setReady(true) | ||
| })() | ||
| }, []) |
There was a problem hiding this comment.
OPFS failure still allows protected routes to render without a valid workspace.
If navigator.storage.getDirectory() throws (private browsing mode, unsupported browser, storage quota exceeded), the error is logged but setReady(true) still executes on line 44. Protected routes will render children without setWorkspaceRoot ever being called, causing cascading failures when storage-dependent code runs.
Consider keeping ready false on OPFS failure and rendering an error state, or redirecting to a safe route:
Proposed fix with error state
export function WorkspaceGate({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false)
+ const [initError, setInitError] = useState<Error | null>(null)
const pathname = usePathname()
const needsWorkspace = isStorageProtectedPath(pathname)
useEffect(() => {
;(async () => {
try {
const handle = await navigator.storage.getDirectory()
setWorkspaceRoot(handle)
try {
await bootstrapWorkspace(handle)
} catch (error) {
logger.warn('bootstrapWorkspace failed', error)
}
setTimeout(() => {
void autoPurgeExpiredTrash()
}, 0)
+ setReady(true)
} catch (error) {
logger.error('OPFS initialization failed', error)
+ setInitError(error instanceof Error ? error : new Error(String(error)))
}
- setReady(true)
})()
}, [])
if (!needsWorkspace) return <>{children}</>
+ if (initError) {
+ return (
+ <div className="min-h-screen bg-background flex items-center justify-center">
+ <p>Storage initialization failed. Please try refreshing or use a supported browser.</p>
+ </div>
+ )
+ }
if (!ready) return <div className="min-h-screen bg-background" aria-hidden="true" />
return <>{children}</>
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| let cancelled = false | |
| ;(async () => { | |
| if (!isFileSystemAccessSupported()) { | |
| if (!cancelled) setStatus({ kind: 'unavailable' }) | |
| return | |
| } | |
| // Promote any legacy `workspace:current` into a proper known-workspace | |
| // record before we read it, so the indicator's "known workspaces" list | |
| // includes the one the user is about to use. | |
| await ensureKnownWorkspaceForCurrent() | |
| const record = await getWorkspaceHandleRecord() | |
| if (!record) { | |
| if (!cancelled) setStatus({ kind: 'pick' }) | |
| return | |
| } | |
| const handle = record.handle as FileSystemDirectoryHandle | |
| const permission = await queryHandlePermission(handle) | |
| if (cancelled) return | |
| if (permission === 'granted') { | |
| await activate(handle) | |
| } else { | |
| setStatus({ kind: 'reconnect', handleName: record.name }) | |
| try { | |
| const handle = await navigator.storage.getDirectory() | |
| setWorkspaceRoot(handle) | |
| try { | |
| await bootstrapWorkspace(handle) | |
| } catch (error) { | |
| logger.warn('bootstrapWorkspace failed', error) | |
| } | |
| setTimeout(() => { | |
| void autoPurgeExpiredTrash() | |
| }, 0) | |
| } catch (error) { | |
| logger.error('OPFS initialization failed', error) | |
| } | |
| })().catch((error) => { | |
| logger.error('Gate initialization failed', error) | |
| if (!cancelled) setStatus({ kind: 'pick' }) | |
| }) | |
| return () => { | |
| cancelled = true | |
| } | |
| }, [activate]) | |
| // Permission-lost mid-session → flip to reconnect. | |
| useEffect(() => { | |
| const unsubscribe = onPermissionLost(() => { | |
| void (async () => { | |
| const record = await getWorkspaceHandleRecord() | |
| setStatus({ kind: 'reconnect', handleName: record?.name ?? 'workspace' }) | |
| })() | |
| }) | |
| return () => { | |
| unsubscribe() | |
| } | |
| setReady(true) | |
| })() | |
| }, []) | |
| export function WorkspaceGate({ children }: { children: React.ReactNode }) { | |
| const [ready, setReady] = useState(false) | |
| const [initError, setInitError] = useState<Error | null>(null) | |
| const pathname = usePathname() | |
| const needsWorkspace = isStorageProtectedPath(pathname) | |
| useEffect(() => { | |
| ;(async () => { | |
| try { | |
| const handle = await navigator.storage.getDirectory() | |
| setWorkspaceRoot(handle) | |
| try { | |
| await bootstrapWorkspace(handle) | |
| } catch (error) { | |
| logger.warn('bootstrapWorkspace failed', error) | |
| } | |
| setTimeout(() => { | |
| void autoPurgeExpiredTrash() | |
| }, 0) | |
| setReady(true) | |
| } catch (error) { | |
| logger.error('OPFS initialization failed', error) | |
| setInitError(error instanceof Error ? error : new Error(String(error))) | |
| } | |
| })() | |
| }, []) | |
| if (!needsWorkspace) return <>{children}</> | |
| if (initError) { | |
| return ( | |
| <div className="min-h-screen bg-background flex items-center justify-center"> | |
| <p>Storage initialization failed. Please try refreshing or use a supported browser.</p> | |
| </div> | |
| ) | |
| } | |
| if (!ready) return <div className="min-h-screen bg-background" aria-hidden="true" /> | |
| return <>{children}</> | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/features/workspace-gate/workspace-gate.tsx` around lines 28 - 46, The
effect currently sets setReady(true) even when navigator.storage.getDirectory()
fails, allowing protected routes to render without a workspace; modify the async
IIFE so that setReady(true) is only called on successful OPFS initialization
(after setWorkspaceRoot and bootstrapWorkspace), and on catch of getDirectory()
set an error state (e.g., setOpfsError or reuse setReady(false)) and either
render an error/redirect instead of children; update references in this function
(navigator.storage.getDirectory, setWorkspaceRoot, bootstrapWorkspace,
autoPurgeExpiredTrash, setReady, logger) to ensure errors are logged via
logger.error and the component does not set ready when OPFS initialization
fails.
Summary
WorkspaceGateauto-initializes vianavigator.storage.getDirectory(). Storage-dependent routes show a blank background for <1 frame, then render immediately. No warning, no permission prompt.<input type="file">for media import —showOpenFilePickerreplaced with a standard file input element. ReturnsFile[]copied into OPFS (storageType: 'opfs'). Works in all browsers.dataTransfer.filesfor drag-drop —getAsFileSystemHandle()removed;ExtractedMediaFileEntry.handledropped. All call sites updated to use.file.showFileHandlePickerkept as a@deprecatedhelper for relinking broken handle-based media from older projects.Changes
workspace-gate/workspace-gate.tsxmedia-library/utils/media-file-picker.tsshowOpenFilePicker→<input type="file">, returnsFile[]media-library/stores/media-import-actions.tsimportHandles/importHandlesForPlacementtakeFile[]media-library/services/media-library-service.tsimportMediaFile(file, projectId)methodmedia-library/utils/file-drop.tsgetAsFileSystemHandle→dataTransfer.filesmedia-library/types.tsimportHandles/importHandlesForPlacementsignaturesmedia-grid.tsx,missing-media-dialog.tsxshowFileHandlePickerfor legacy relink onlyTest plan
npx tsc --noEmit→ 0 errors🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes