Add desktop recent-file disk linking and MSI build task
This commit is contained in:
+16
-1
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user