Add desktop recent-file disk linking and MSI build task

This commit is contained in:
Anthony Stirling
2026-07-14 10:04:45 +01:00
parent a1b15e0570
commit 8a1da04920
14 changed files with 728 additions and 10 deletions
+16 -1
View File
@@ -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
@@ -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"
@@ -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<Vec<String>> = Mutex::new(Vec::new());
@@ -42,3 +43,41 @@ pub async fn pop_opened_files() -> Result<Vec<String>, 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));
}
}
@@ -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,
+4
View File
@@ -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,
@@ -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<FileListItemProps> = ({
</Menu.Item>
)}
{desktopFileLinkingSupported && file.localFilePath && (
<Menu.Item
leftSection={<FolderOpenIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
void revealPathInFileManager(file.localFilePath!);
}}
>
{t("fileManager.showInFolder", "Show in folder")}
</Menu.Item>
)}
{canUpload && (
<Menu.Item
leftSection={<CloudUploadIcon style={{ fontSize: 16 }} />}
@@ -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),
);
@@ -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<boolean> {
return true;
}
// No OS file manager to reveal into outside the desktop app.
export async function revealPathInFileManager(_path: string): Promise<void> {}
@@ -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,
@@ -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<boolean>>(),
}));
const deleteMultipleStirlingFiles = vi.hoisted(() =>
vi.fn<(ids: FileId[]) => Promise<void>>(),
);
const updateFileMetadata = vi.hoisted(() =>
vi.fn<(id: FileId, updates: object) => Promise<boolean>>(),
);
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> = {}): 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();
});
});
@@ -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<StirlingFileStub[]> {
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<FileId>(toDelete);
const detachSet = new Set<FileId>(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<FileId[]> {
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;
}
@@ -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`,
@@ -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<boolean> {
if (!isTauri()) return true;
try {
return await invoke<boolean>("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<void> {
if (!isTauri()) return;
try {
await invoke("reveal_in_file_manager", { path });
} catch (error) {
console.error("[desktopFileLink] reveal_in_file_manager failed:", error);
}
}
@@ -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<string, boolean>(),
invoke: vi.fn(async (cmd: string, args?: Record<string, unknown>) => {
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<typeof import("@tauri-apps/api/core")>();
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<StirlingFileStub> = {},
): Promise<void> {
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 <input type=file> 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();
});
});