diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index c80196305b..8ae6ddfa6e 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -78,6 +78,16 @@ tasks: cmds: - npx tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}' + build:msi: + desc: "Build Tauri desktop MSI installer with bundled JRE (Windows)" + deps: [prepare] + dir: editor + platforms: [windows] + cmds: + # prepare -> jlink (real bundled JRE) + jar + frontend + provisioner. + # No updater artifacts, so no signing key is required for a local MSI. + - npx tauri build --bundles msi --config '{"bundle":{"createUpdaterArtifacts":false}}' + build:dev:linux: desc: "Build Tauri desktop AppImage (Linux)" deps: [prepare] @@ -194,7 +204,12 @@ tasks: # `desktop:build` run `jlink:clean` first to force a fresh build. - cmd: chmod -R u+w runtime/jre platforms: [linux, darwin] - - cmd: powershell -NoProfile -Command "Get-ChildItem -Recurse runtime/jre | ForEach-Object { $_.IsReadOnly = $false }" + # Single-quote the PowerShell so go-task's POSIX shell passes $_ and $false + # through literally. With double quotes it expands $_ to the task path and + # $false to empty, so read-only never clears and the next incremental build + # dies with "Permission denied (os error 13)" staging the JRE. -File keeps + # it to FileInfo (only files carry a settable IsReadOnly). + - cmd: powershell -NoProfile -Command 'Get-ChildItem -Recurse -Force -File runtime/jre | ForEach-Object { $_.IsReadOnly = $false }' platforms: [windows] status: - test -f runtime/jre/release diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index c182ce2b89..abb0f7ad95 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3655,6 +3655,7 @@ shareSelected = "Share Files" sharing = "Sharing" showAll = "Show All" showHistory = "Show History" +showInFolder = "Show in folder" sortByDate = "Sort by Date" sortByName = "Sort by Name" sortBySize = "Sort by Size" diff --git a/frontend/editor/src-tauri/src/commands/files.rs b/frontend/editor/src-tauri/src/commands/files.rs index 1e3c9b61cb..71ae4abe33 100644 --- a/frontend/editor/src-tauri/src/commands/files.rs +++ b/frontend/editor/src-tauri/src/commands/files.rs @@ -1,5 +1,6 @@ use crate::utils::add_log; use std::sync::Mutex; +use tauri_plugin_opener::OpenerExt; // Store the opened file paths globally (supports multiple files) static OPENED_FILES: Mutex> = Mutex::new(Vec::new()); @@ -42,3 +43,41 @@ pub async fn pop_opened_files() -> Result, String> { add_log(format!("📂 Returning and clearing {} opened file(s)", all_files.len())); Ok(all_files) } + +// Report whether a path still exists on disk (used to prune stale recent files). +#[tauri::command] +pub fn path_exists(path: String) -> bool { + std::path::Path::new(&path).exists() +} + +// Reveal a file in the OS file manager (Explorer/Finder), highlighting it. +#[tauri::command] +pub async fn reveal_in_file_manager(app: tauri::AppHandle, path: String) -> Result<(), String> { + app.opener() + .reveal_item_in_dir(&path) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn path_exists_tracks_real_disk_state() { + let file = std::env::temp_dir() + .join(format!("stirling_path_exists_{}.tmp", std::process::id())); + let path = file.to_string_lossy().to_string(); + + // Absent before creation. + assert!(!path_exists(path.clone())); + + // Present once written. + writeln!(std::fs::File::create(&file).unwrap(), "x").unwrap(); + assert!(path_exists(path.clone())); + + // Gone again after deletion (the case that prunes a recent). + std::fs::remove_file(&file).unwrap(); + assert!(!path_exists(path)); + } +} diff --git a/frontend/editor/src-tauri/src/commands/mod.rs b/frontend/editor/src-tauri/src/commands/mod.rs index 1637c926c7..6a8b5b5cf7 100644 --- a/frontend/editor/src-tauri/src/commands/mod.rs +++ b/frontend/editor/src-tauri/src/commands/mod.rs @@ -10,7 +10,10 @@ pub mod updater; pub mod window; pub use backend::{cleanup_backend, get_backend_port, start_backend}; -pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files}; +pub use files::{ + add_opened_file, clear_opened_files, get_opened_files, path_exists, pop_opened_files, + reveal_in_file_manager, +}; pub use window::{ forward_files_to_window, open_files_in_new_window, diff --git a/frontend/editor/src-tauri/src/lib.rs b/frontend/editor/src-tauri/src/lib.rs index 8855604624..768f791b86 100644 --- a/frontend/editor/src-tauri/src/lib.rs +++ b/frontend/editor/src-tauri/src/lib.rs @@ -19,8 +19,10 @@ use commands::{ get_opened_files, open_files_in_new_window, open_in_new_window, + path_exists, pop_opened_files, pop_window_file_ids, + reveal_in_file_manager, get_refresh_token, get_user_info, is_first_launch, @@ -167,6 +169,8 @@ pub fn run() { get_opened_files, pop_opened_files, clear_opened_files, + path_exists, + reveal_in_file_manager, open_in_new_window, open_files_in_new_window, pop_window_file_ids, diff --git a/frontend/editor/src/core/components/fileManager/FileListItem.tsx b/frontend/editor/src/core/components/fileManager/FileListItem.tsx index ba1fe52bc9..6ab6b9415a 100644 --- a/frontend/editor/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/editor/src/core/components/fileManager/FileListItem.tsx @@ -19,6 +19,7 @@ import CloseIcon from "@mui/icons-material/Close"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudDoneIcon from "@mui/icons-material/CloudDone"; import LinkIcon from "@mui/icons-material/Link"; +import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import { useTranslation } from "react-i18next"; import { getFileSize, getFileDate } from "@app/utils/fileUtils"; import { FileId, StirlingFileStub } from "@app/types/fileContext"; @@ -35,6 +36,10 @@ import ShareManagementModal from "@app/components/shared/ShareManagementModal"; import apiClient from "@app/services/apiClient"; import { absoluteWithBasePath } from "@app/constants/app"; import { alert } from "@app/components/toast"; +import { + desktopFileLinkingSupported, + revealPathInFileManager, +} from "@app/services/desktopFileLink"; interface FileListItemProps { file: StirlingFileStub; @@ -368,6 +373,18 @@ const FileListItem: React.FC = ({ )} + {desktopFileLinkingSupported && file.localFilePath && ( + } + onClick={(e) => { + e.stopPropagation(); + void revealPathInFileManager(file.localFilePath!); + }} + > + {t("fileManager.showInFolder", "Show in folder")} + + )} + {canUpload && ( } diff --git a/frontend/editor/src/core/hooks/useFileManager.ts b/frontend/editor/src/core/hooks/useFileManager.ts index 442027364e..2ae2ec2f15 100644 --- a/frontend/editor/src/core/hooks/useFileManager.ts +++ b/frontend/editor/src/core/hooks/useFileManager.ts @@ -5,6 +5,12 @@ import { StirlingFileStub, StirlingFile } from "@app/types/fileContext"; import { FileId } from "@app/types/fileContext"; import apiClient from "@app/services/apiClient"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { + pruneMissingRecentFiles, + reconcileServerBackedRecents, + hasLocalRecord, +} from "@app/services/pruneMissingRecentFiles"; +import { desktopFileLinkingSupported } from "@app/services/desktopFileLink"; interface StoredFileResponse { id: number; @@ -105,7 +111,10 @@ export const useFileManager = () => { } // Load only leaf files metadata (processed files that haven't been used as input for other tools) - const stirlingFileStubs = await fileStorage.getLeafStirlingFileStubs(); + // On desktop, drop entries whose linked file no longer exists on disk. + const stirlingFileStubs = await pruneMissingRecentFiles( + await fileStorage.getLeafStirlingFileStubs(), + ); const remoteIdSet = new Set( stirlingFileStubs .map((stub) => stub.remoteStorageId) @@ -133,7 +142,21 @@ export const useFileManager = () => { } }); - const updatedLocalStubs = stirlingFileStubs.map((stub) => { + // Desktop: a server-uploaded recent whose local disk original was + // deleted drops its local copy, becoming a pure server file — it + // leaves recents but stays under My Files, downloadable. Gated on the + // server confirming it holds the file, so the last copy is never lost. + let localStubs = stirlingFileStubs; + const demotedIds = await reconcileServerBackedRecents( + stirlingFileStubs, + (id) => serverMap.has(id), + ); + if (demotedIds.length > 0) { + const removedSet = new Set(demotedIds); + localStubs = stirlingFileStubs.filter((s) => !removedSet.has(s.id)); + } + + const updatedLocalStubs = localStubs.map((stub) => { if (!stub.remoteStorageId) { return stub; } @@ -351,8 +374,15 @@ export const useFileManager = () => { } } + // Desktop recents show only files with a local IndexedDB record; files + // that live ONLY on the server (ephemeral server-/shared- stubs, no IDB + // row) belong in the My Files view, not here. + const recentStubs = desktopFileLinkingSupported + ? combinedStubs.filter(hasLocalRecord) + : combinedStubs; + // For now, only regular files - drafts will be handled separately in the future - const sortedFiles = combinedStubs.sort( + const sortedFiles = recentStubs.sort( (a, b) => (b.lastModified || 0) - (a.lastModified || 0), ); diff --git a/frontend/editor/src/core/services/desktopFileLink.ts b/frontend/editor/src/core/services/desktopFileLink.ts new file mode 100644 index 0000000000..5b95f165c6 --- /dev/null +++ b/frontend/editor/src/core/services/desktopFileLink.ts @@ -0,0 +1,14 @@ +// Seam for linking recent files to their real file on disk (desktop only). +// Non-desktop builds resolve to this no-op default via the @app alias order, +// so web/SaaS have no disk-linked recents to prune or reveal. + +// False on web: the recent-file pruner and "Show in folder" action are skipped. +export const desktopFileLinkingSupported = false; + +// Assume present so the pruner never drops a recent file off the desktop app. +export async function pathExistsOnDisk(_path: string): Promise { + return true; +} + +// No OS file manager to reveal into outside the desktop app. +export async function revealPathInFileManager(_path: string): Promise {} diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index 4cdecca9f1..2d872229f5 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -29,6 +29,7 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata { thumbnail?: string; thumbnailStoredAt?: number; // Epoch ms - sliding 30-day TTL url?: string; // For compatibility with existing components + localFilePath?: string; // Original disk path (desktop) - persisted so recents stay disk-linked across reloads // Cached classification labels — mirrors the stub field so the sidebar can // group by label without re-reading PDF bytes, and it survives versioning. // See StirlingFileStub.classificationLabels. @@ -132,6 +133,7 @@ class FileStorageService { data: arrayBuffer, thumbnail: stub.thumbnailUrl, thumbnailStoredAt: stub.thumbnailUrl ? Date.now() : undefined, + localFilePath: stub.localFilePath, isLeaf: stub.isLeaf ?? true, remoteStorageId: stub.remoteStorageId, remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt, @@ -262,6 +264,7 @@ class FileStorageService { lastModified: record.lastModified, quickKey: record.quickKey, thumbnailUrl: fresh ? record.thumbnail : undefined, + localFilePath: record.localFilePath, isLeaf: record.isLeaf, remoteStorageId: record.remoteStorageId, remoteStorageUpdatedAt: record.remoteStorageUpdatedAt, @@ -322,6 +325,7 @@ class FileStorageService { lastModified: record.lastModified, quickKey: record.quickKey, thumbnailUrl: fresh ? record.thumbnail : undefined, + localFilePath: record.localFilePath, isLeaf: record.isLeaf, remoteStorageId: record.remoteStorageId, remoteStorageUpdatedAt: record.remoteStorageUpdatedAt, @@ -414,6 +418,7 @@ class FileStorageService { lastModified: record.lastModified, quickKey: record.quickKey, thumbnailUrl: fresh ? record.thumbnail : undefined, + localFilePath: record.localFilePath, isLeaf: record.isLeaf, remoteStorageId: record.remoteStorageId, remoteStorageUpdatedAt: record.remoteStorageUpdatedAt, diff --git a/frontend/editor/src/core/services/pruneMissingRecentFiles.test.ts b/frontend/editor/src/core/services/pruneMissingRecentFiles.test.ts new file mode 100644 index 0000000000..544ac3dc83 --- /dev/null +++ b/frontend/editor/src/core/services/pruneMissingRecentFiles.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +// Controllable seam + storage doubles. +const desktopFileLink = vi.hoisted(() => ({ + desktopFileLinkingSupported: true, + pathExistsOnDisk: vi.fn<(path: string) => Promise>(), +})); +const deleteMultipleStirlingFiles = vi.hoisted(() => + vi.fn<(ids: FileId[]) => Promise>(), +); +const updateFileMetadata = vi.hoisted(() => + vi.fn<(id: FileId, updates: object) => Promise>(), +); + +vi.mock("@app/services/desktopFileLink", () => desktopFileLink); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { deleteMultipleStirlingFiles, updateFileMetadata }, +})); + +import { + pruneMissingRecentFiles, + reconcileServerBackedRecents, + hasLocalRecord, +} from "@app/services/pruneMissingRecentFiles"; + +const stub = (overrides: Partial = {}): StirlingFileStub => ({ + id: "file-1" as FileId, + name: "report.pdf", + type: "application/pdf", + size: 1024, + lastModified: 0, + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, + ...overrides, +}); + +beforeEach(() => { + desktopFileLink.desktopFileLinkingSupported = true; + desktopFileLink.pathExistsOnDisk.mockReset().mockResolvedValue(true); + deleteMultipleStirlingFiles.mockReset().mockResolvedValue(undefined); + updateFileMetadata.mockReset().mockResolvedValue(true); +}); + +describe("pruneMissingRecentFiles (local-only files)", () => { + test("deletes a pristine local-only file whose disk file is gone", async () => { + const gone = stub({ id: "gone" as FileId, localFilePath: "/tmp/gone.pdf" }); + const kept = stub({ id: "kept" as FileId, localFilePath: "/tmp/kept.pdf" }); + desktopFileLink.pathExistsOnDisk.mockImplementation( + async (p) => p === "/tmp/kept.pdf", + ); + + const result = await pruneMissingRecentFiles([gone, kept]); + + expect(result.map((s) => s.id)).toEqual(["kept"]); + expect(deleteMultipleStirlingFiles).toHaveBeenCalledWith(["gone"]); + expect(updateFileMetadata).not.toHaveBeenCalled(); + }); + + test("keeps in-app-edited local files but detaches the dead link", async () => { + const dirty = stub({ + id: "dirty" as FileId, + localFilePath: "/tmp/dirty.pdf", + isDirty: true, + }); + const edited = stub({ + id: "edited" as FileId, + localFilePath: "/tmp/edited.pdf", + versionNumber: 2, + toolHistory: [{ toolId: "compress" as never, timestamp: 1 }], + }); + desktopFileLink.pathExistsOnDisk.mockResolvedValue(false); + + const result = await pruneMissingRecentFiles([dirty, edited]); + + expect(result.map((s) => s.id).sort()).toEqual(["dirty", "edited"]); + expect(result.every((s) => s.localFilePath === undefined)).toBe(true); + expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled(); + expect(updateFileMetadata).toHaveBeenCalledTimes(2); + }); + + test("leaves server-backed files untouched (handled after the server fetch)", async () => { + const remote = stub({ + id: "remote" as FileId, + localFilePath: "/tmp/remote.pdf", + remoteStorageId: 7, + }); + desktopFileLink.pathExistsOnDisk.mockResolvedValue(false); + + const result = await pruneMissingRecentFiles([remote]); + + expect(result).toEqual([remote]); + expect(desktopFileLink.pathExistsOnDisk).not.toHaveBeenCalled(); + expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled(); + expect(updateFileMetadata).not.toHaveBeenCalled(); + }); + + test("ignores files with no disk link (e.g. web/browser uploads)", async () => { + const memoryOnly = stub({ id: "mem" as FileId }); + desktopFileLink.pathExistsOnDisk.mockResolvedValue(false); + + const result = await pruneMissingRecentFiles([memoryOnly]); + + expect(result).toEqual([memoryOnly]); + expect(desktopFileLink.pathExistsOnDisk).not.toHaveBeenCalled(); + }); + + test("is a no-op when desktop file linking is unsupported (web)", async () => { + desktopFileLink.desktopFileLinkingSupported = false; + const gone = stub({ id: "gone" as FileId, localFilePath: "/tmp/gone.pdf" }); + + const result = await pruneMissingRecentFiles([gone]); + + expect(result).toEqual([gone]); + expect(desktopFileLink.pathExistsOnDisk).not.toHaveBeenCalled(); + }); +}); + +describe("hasLocalRecord (desktop recents = local/IndexedDB only)", () => { + test("keeps files with a real local IndexedDB id", () => { + expect(hasLocalRecord(stub({ id: "uuid-abc" as FileId }))).toBe(true); + // A locally-stored file that is ALSO uploaded still has a local record. + expect( + hasLocalRecord(stub({ id: "uuid-abc" as FileId, remoteStorageId: 5 })), + ).toBe(true); + }); + + test("excludes ephemeral server-only and shared-only stubs", () => { + expect(hasLocalRecord(stub({ id: "server-42" as FileId }))).toBe(false); + expect(hasLocalRecord(stub({ id: "shared-tok" as FileId }))).toBe(false); + }); +}); + +describe("reconcileServerBackedRecents (server-uploaded files)", () => { + const serverHas = (ids: number[]) => (id: number) => ids.includes(id); + + test("demotes a server-confirmed file whose disk original is gone", async () => { + const s = stub({ + id: "s1" as FileId, + localFilePath: "/tmp/s1.pdf", + remoteStorageId: 42, + }); + desktopFileLink.pathExistsOnDisk.mockResolvedValue(false); + + const removed = await reconcileServerBackedRecents([s], serverHas([42])); + + expect(removed).toEqual(["s1"]); + // The local copy is removed; the file lives on as a pure server file. + expect(deleteMultipleStirlingFiles).toHaveBeenCalledWith(["s1"]); + }); + + test("does NOT demote when the server can't confirm the file (avoids data loss)", async () => { + const s = stub({ + id: "s1" as FileId, + localFilePath: "/tmp/s1.pdf", + remoteStorageId: 42, + }); + desktopFileLink.pathExistsOnDisk.mockResolvedValue(false); + + const removed = await reconcileServerBackedRecents([s], serverHas([])); + + expect(removed).toEqual([]); + expect(desktopFileLink.pathExistsOnDisk).not.toHaveBeenCalled(); + expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled(); + }); + + test("does NOT demote a file with unsaved local edits", async () => { + const s = stub({ + id: "s1" as FileId, + localFilePath: "/tmp/s1.pdf", + remoteStorageId: 42, + isDirty: true, + }); + desktopFileLink.pathExistsOnDisk.mockResolvedValue(false); + + const removed = await reconcileServerBackedRecents([s], serverHas([42])); + + expect(removed).toEqual([]); + expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled(); + }); + + test("does NOT demote when the disk file still exists", async () => { + const s = stub({ + id: "s1" as FileId, + localFilePath: "/tmp/s1.pdf", + remoteStorageId: 42, + }); + desktopFileLink.pathExistsOnDisk.mockResolvedValue(true); + + const removed = await reconcileServerBackedRecents([s], serverHas([42])); + + expect(removed).toEqual([]); + expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled(); + }); + + test("is a no-op off desktop", async () => { + desktopFileLink.desktopFileLinkingSupported = false; + const s = stub({ + id: "s1" as FileId, + localFilePath: "/tmp/s1.pdf", + remoteStorageId: 42, + }); + + const removed = await reconcileServerBackedRecents([s], serverHas([42])); + + expect(removed).toEqual([]); + expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/services/pruneMissingRecentFiles.ts b/frontend/editor/src/core/services/pruneMissingRecentFiles.ts new file mode 100644 index 0000000000..554fc063ea --- /dev/null +++ b/frontend/editor/src/core/services/pruneMissingRecentFiles.ts @@ -0,0 +1,138 @@ +import { StirlingFileStub, FileId } from "@app/types/fileContext"; +import { fileStorage } from "@app/services/fileStorage"; +import { + desktopFileLinkingSupported, + pathExistsOnDisk, +} from "@app/services/desktopFileLink"; + +/** + * True for a recents entry backed by a real local IndexedDB record, false for + * the ephemeral `server-`/`shared-` stubs synthesised from server storage (which + * have no IDB row). Desktop recents show only files with a local record; the + * server-only ones belong in the My Files view. + */ +export function hasLocalRecord(stub: StirlingFileStub): boolean { + return !stub.id.startsWith("server-") && !stub.id.startsWith("shared-"); +} + +/** + * A local-only recent is a pristine passthrough of its disk file — identical + * bytes on disk and in IndexedDB — only if it's a v1 with no in-app edits. When + * its disk file disappears there's nothing unique to keep, so it's deleted. + * Otherwise (unsaved edits, or a later/tool version) the IndexedDB copy may be + * the only one left, so it's KEPT with just the dead disk link detached. + */ +function isPristineLocalPassthrough(stub: StirlingFileStub): boolean { + return ( + !stub.isDirty && + (stub.versionNumber ?? 1) === 1 && + (stub.toolHistory?.length ?? 0) === 0 + ); +} + +/** + * Reconcile LOCAL-ONLY desktop recents against the disk files they link to. + * Server-backed recents are intentionally left untouched here — they're handled + * after the server fetch by {@link reconcileServerBackedRecents}, which can + * confirm the file still exists remotely before dropping the local copy. + * + * For each local-only stub whose `localFilePath` no longer exists on disk: + * - pristine passthrough → removed from recents + IndexedDB. + * - edited / dirty → KEPT, dead `localFilePath` cleared (no broken "Show + * in folder"; Ctrl+S falls back to Save As). + * No-op off the desktop app. + */ +export async function pruneMissingRecentFiles( + stubs: StirlingFileStub[], +): Promise { + if (!desktopFileLinkingSupported) return stubs; + + const linked = stubs.filter( + (stub) => stub.localFilePath && !stub.remoteStorageId, + ); + if (linked.length === 0) return stubs; + + const present = await Promise.all( + linked.map((stub) => pathExistsOnDisk(stub.localFilePath!)), + ); + + const toDelete: FileId[] = []; + const toDetach: FileId[] = []; + linked.forEach((stub, i) => { + if (present[i]) return; + if (isPristineLocalPassthrough(stub)) toDelete.push(stub.id); + else toDetach.push(stub.id); + }); + + if (toDelete.length === 0 && toDetach.length === 0) return stubs; + + if (toDelete.length > 0) { + try { + await fileStorage.deleteMultipleStirlingFiles(toDelete); + } catch (error) { + console.error("[pruneMissingRecentFiles] delete failed:", error); + } + } + if (toDetach.length > 0) { + await Promise.all( + toDetach.map((id) => + fileStorage + .updateFileMetadata(id, { localFilePath: undefined }) + .catch((error) => + console.error("[pruneMissingRecentFiles] detach failed:", error), + ), + ), + ); + } + + const deleteSet = new Set(toDelete); + const detachSet = new Set(toDetach); + return stubs + .filter((stub) => !deleteSet.has(stub.id)) + .map((stub) => + detachSet.has(stub.id) ? { ...stub, localFilePath: undefined } : stub, + ); +} + +/** + * When a server-uploaded recent's local disk original has been deleted, drop its + * LOCAL copy so the file becomes a pure server file: it leaves the (local) + * recents but remains under My Files and stays downloadable from the server. + * Only files the server CONFIRMS it still holds (via `serverHasFile`) and with + * no unsaved local edits are demoted, so the last copy is never lost. + * + * Returns the deleted local ids so the caller can drop them from the list this + * pass. No-op off the desktop app. + */ +export async function reconcileServerBackedRecents( + stubs: StirlingFileStub[], + serverHasFile: (remoteStorageId: number) => boolean, +): Promise { + if (!desktopFileLinkingSupported) return []; + + const candidates = stubs.filter( + (stub) => + typeof stub.remoteStorageId === "number" && + serverHasFile(stub.remoteStorageId) && + stub.localFilePath && + !stub.isDirty, + ); + if (candidates.length === 0) return []; + + const present = await Promise.all( + candidates.map((stub) => pathExistsOnDisk(stub.localFilePath!)), + ); + const gone = candidates.filter((_, i) => !present[i]); + if (gone.length === 0) return []; + + const removedIds = gone.map((stub) => stub.id); + try { + await fileStorage.deleteMultipleStirlingFiles(removedIds); + } catch (error) { + // If we couldn't remove the local copy, don't drop it from the list either. + console.error("[reconcileServerBackedRecents] delete failed:", error); + return []; + } + + return removedIds; +} diff --git a/frontend/editor/src/desktop/hooks/useAppInitialization.ts b/frontend/editor/src/desktop/hooks/useAppInitialization.ts index 176442cb06..4eae0b945f 100644 --- a/frontend/editor/src/desktop/hooks/useAppInitialization.ts +++ b/frontend/editor/src/desktop/hooks/useAppInitialization.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useOpenedFile } from "@app/hooks/useOpenedFile"; import { fileOpenService } from "@app/services/fileOpenService"; +import { fileStorage } from "@app/services/fileStorage"; import { useFileManagement } from "@app/contexts/file/fileHooks"; import { createQuickKey } from "@app/types/fileContext"; @@ -80,12 +81,18 @@ export function useAppInitialization(): void { ); const addedFiles = await addFiles(filesArray, { selectFiles: true }); - addedFiles.forEach((file) => { - const localFilePath = quickKeyToPath.get(file.quickKey); - if (localFilePath) { + await Promise.all( + addedFiles.map(async (file) => { + const localFilePath = quickKeyToPath.get(file.quickKey); + if (!localFilePath) return; updateStirlingFileStub(file.fileId, { localFilePath }); - } - }); + // Persist too: addFiles stored the record before we knew the path, + // so the disk link would otherwise be lost on reload. + await fileStorage.updateFileMetadata(file.fileId, { + localFilePath, + }); + }), + ); console.log( `[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`, diff --git a/frontend/editor/src/desktop/services/desktopFileLink.ts b/frontend/editor/src/desktop/services/desktopFileLink.ts new file mode 100644 index 0000000000..a0417e8aa7 --- /dev/null +++ b/frontend/editor/src/desktop/services/desktopFileLink.ts @@ -0,0 +1,28 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; + +// Desktop implementation of the file-link seam. Overrides the core no-op in +// desktop builds (see @app alias order) so recent files stay tied to disk. + +export const desktopFileLinkingSupported = true; + +// True if the path still exists on disk. On any error we assume it exists so a +// transient failure never prunes a valid recent file. +export async function pathExistsOnDisk(path: string): Promise { + if (!isTauri()) return true; + try { + return await invoke("path_exists", { path }); + } catch (error) { + console.error("[desktopFileLink] path_exists failed:", error); + return true; + } +} + +// Reveal the file in Explorer/Finder, highlighting it. +export async function revealPathInFileManager(path: string): Promise { + if (!isTauri()) return; + try { + await invoke("reveal_in_file_manager", { path }); + } catch (error) { + console.error("[desktopFileLink] reveal_in_file_manager failed:", error); + } +} diff --git a/frontend/editor/src/desktop/services/recentFileDiskLink.integration.test.ts b/frontend/editor/src/desktop/services/recentFileDiskLink.integration.test.ts new file mode 100644 index 0000000000..b773d9545f --- /dev/null +++ b/frontend/editor/src/desktop/services/recentFileDiskLink.integration.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import "fake-indexeddb/auto"; + +// Stub ONLY the Tauri OS boundary. Everything below (real fileStorage on a real +// IndexedDB, the real desktop desktopFileLink seam, the real pruner) runs for +// real, so this exercises the full disk-linked-recents chain end to end. +const { existsByPath, invoke } = vi.hoisted(() => ({ + existsByPath: new Map(), + invoke: vi.fn(async (cmd: string, args?: Record) => { + if (cmd === "path_exists") { + return existsByPath.get(args?.path as string) ?? false; + } + throw new Error(`unexpected command ${cmd}`); + }), +})); +vi.mock("@tauri-apps/api/core", async (importActual) => { + const actual = await importActual(); + return { ...actual, isTauri: () => true, invoke }; +}); + +import { fileStorage } from "@app/services/fileStorage"; +import { + pruneMissingRecentFiles, + reconcileServerBackedRecents, +} from "@app/services/pruneMissingRecentFiles"; +import { + createStirlingFile, + type StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +async function storeFile( + id: string, + overrides: Partial = {}, +): Promise { + const file = new File([`data-${id}`], `${id}.pdf`, { + type: "application/pdf", + }); + const stirlingFile = createStirlingFile(file, id as FileId); + const stub: StirlingFileStub = { + id: id as FileId, + name: `${id}.pdf`, + type: "application/pdf", + size: file.size, + lastModified: 0, + quickKey: stirlingFile.quickKey, + isLeaf: true, + originalFileId: id, + versionNumber: 1, + ...overrides, + }; + await fileStorage.storeStirlingFile(stirlingFile, stub); +} + +beforeEach(async () => { + existsByPath.clear(); + invoke.mockClear(); + // fake-indexeddb persists within a run; wipe the store for test isolation. + const all = await fileStorage.getAllStirlingFileStubs(); + if (all.length > 0) { + await fileStorage.deleteMultipleStirlingFiles(all.map((s) => s.id)); + } +}); + +describe("desktop recent files ↔ disk link (real storage + seam)", () => { + test("localFilePath round-trips through IndexedDB", async () => { + await storeFile("rt", { localFilePath: "/docs/rt.pdf" }); + + const stubs = await fileStorage.getLeafStirlingFileStubs(); + const loaded = stubs.find((s) => s.id === "rt"); + + // Regression: before persisting localFilePath in the record, this was + // undefined after a reload and the whole feature was inert. + expect(loaded?.localFilePath).toBe("/docs/rt.pdf"); + }); + + test("localFilePath applied post-store (OS open-with / drag-drop path) persists", async () => { + // useAppInitialization stores the opened file first, then applies the disk + // path via updateFileMetadata — this is the drag-drop / "open with" path. + await storeFile("dragged"); + await fileStorage.updateFileMetadata("dragged" as FileId, { + localFilePath: "/docs/dragged.pdf", + }); + + const stubs = await fileStorage.getLeafStirlingFileStubs(); + expect(stubs.find((s) => s.id === "dragged")?.localFilePath).toBe( + "/docs/dragged.pdf", + ); + }); + + test("prunes a recent whose disk file is gone and deletes it from storage", async () => { + await storeFile("gone", { localFilePath: "/docs/gone.pdf" }); + await storeFile("kept", { localFilePath: "/docs/kept.pdf" }); + existsByPath.set("/docs/gone.pdf", false); + existsByPath.set("/docs/kept.pdf", true); + + const stubs = await fileStorage.getLeafStirlingFileStubs(); + const survivors = await pruneMissingRecentFiles(stubs); + + expect(survivors.map((s) => s.id).sort()).toEqual(["kept"]); + // Really removed from IndexedDB, not just filtered from the array. + expect(await fileStorage.getStirlingFileStub("gone" as FileId)).toBeNull(); + expect( + await fileStorage.getStirlingFileStub("kept" as FileId), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith("path_exists", { + path: "/docs/gone.pdf", + }); + }); + + test("prune leaves server-backed files alone (server reconciliation owns them)", async () => { + await storeFile("onserver", { + localFilePath: "/docs/onserver.pdf", + remoteStorageId: 42, + }); + existsByPath.set("/docs/onserver.pdf", false); + + const survivors = await pruneMissingRecentFiles( + await fileStorage.getLeafStirlingFileStubs(), + ); + + // Untouched by the pre-fetch prune: still present, link intact. + expect(survivors.find((s) => s.id === "onserver")?.localFilePath).toBe( + "/docs/onserver.pdf", + ); + expect( + await fileStorage.getStirlingFileStub("onserver" as FileId), + ).not.toBeNull(); + }); + + test("demotes a server-confirmed file whose disk original is gone (removed locally, kept on server)", async () => { + await storeFile("onserver", { + localFilePath: "/docs/onserver.pdf", + remoteStorageId: 42, + }); + existsByPath.set("/docs/onserver.pdf", false); + + const removed = await reconcileServerBackedRecents( + await fileStorage.getLeafStirlingFileStubs(), + (id) => id === 42, // server confirms it holds file 42 + ); + + expect(removed).toEqual(["onserver"]); + // Local copy really removed from IndexedDB — it becomes a pure server file + // (shown under My Files, downloadable), so nothing is lost. + expect( + await fileStorage.getStirlingFileStub("onserver" as FileId), + ).toBeNull(); + }); + + test("does NOT demote a server-backed file the server can't confirm (no data loss)", async () => { + await storeFile("stale", { + localFilePath: "/docs/stale.pdf", + remoteStorageId: 99, + }); + existsByPath.set("/docs/stale.pdf", false); + + const removed = await reconcileServerBackedRecents( + await fileStorage.getLeafStirlingFileStubs(), + () => false, // server no longer has it + ); + + expect(removed).toEqual([]); + // Local copy preserved — it may be the only remaining copy. + expect( + await fileStorage.getStirlingFileStub("stale" as FileId), + ).not.toBeNull(); + }); + + test("keeps an in-app-edited file whose disk path is gone, detaching the dead link", async () => { + // A v2 with tool history: its edited bytes live only in IndexedDB once the + // disk file is gone, so it must be KEPT (not deleted) — just detached. + await storeFile("edited", { + localFilePath: "/docs/edited.pdf", + versionNumber: 2, + toolHistory: [{ toolId: "compress" as never, timestamp: 1 }], + }); + existsByPath.set("/docs/edited.pdf", false); + + const survivors = await pruneMissingRecentFiles( + await fileStorage.getLeafStirlingFileStubs(), + ); + + expect(survivors.some((s) => s.id === "edited")).toBe(true); + const stored = await fileStorage.getStirlingFileStub("edited" as FileId); + expect(stored).not.toBeNull(); + expect(stored?.localFilePath).toBeUndefined(); + }); + + test("leaves web/browser-uploaded files (no disk link) untouched", async () => { + // Files added via the fallback have no localFilePath, so + // they are never disk-checked, detached, or pruned. + await storeFile("weblink"); + existsByPath.set("/docs/anything.pdf", false); + + const survivors = await pruneMissingRecentFiles( + await fileStorage.getLeafStirlingFileStubs(), + ); + + expect(survivors.some((s) => s.id === "weblink")).toBe(true); + expect( + await fileStorage.getStirlingFileStub("weblink" as FileId), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); +});