Condense disk-linking comments to two lines

This commit is contained in:
Anthony Stirling
2026-08-25 12:56:07 +01:00
parent 56e8498eae
commit 1af5ffe2b4
17 changed files with 115 additions and 347 deletions
@@ -6,13 +6,8 @@ use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
// Live watch over the files the workbench currently has linked to disk, so an
// external edit or delete is noticed as it happens rather than at the next list
// build. Without this the app only reconciles when something asks it to, which
// leaves a stale document on screen for as long as the user leaves it open.
//
// Directories are watched rather than files: most editors save by writing a
// temp file and renaming over the target, which drops a file-level watch.
// Watches linked files live so an external edit or delete does not sit stale on screen.
// Directories, not files: editors save by rename, which drops a file-level watch.
/// The watcher itself. Dropping it stops delivery, so it is parked here.
static WATCHER: Mutex<Option<RecommendedWatcher>> = Mutex::new(None);
@@ -3,11 +3,8 @@ import { DiskLinkBadge } from "@app/components/filesPage/DiskLinkBadge";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
/**
* The badge only renders for the two states that need attention, so the healthy
* and never-on-disk cases are here too - they must stay invisible, and a story
* is the cheapest way to keep that honest.
*/
/** The healthy and never-on-disk cases are here because they must render nothing;
* a story is the cheapest way to keep that honest. */
const meta: Meta<typeof DiskLinkBadge> = {
title: "FilesPage/DiskLinkBadge",
component: DiskLinkBadge,
@@ -6,20 +6,8 @@ import SyncProblemIcon from "@mui/icons-material/SyncProblem";
import { StirlingFileStub } from "@app/types/fileContext";
import { diskLinkState } from "@app/services/diskFileSync";
/**
* Says whether a file is still backed by its original on disk.
*
* Losing that link, or diverging from it, is a lasting state, but the only
* thing that ever announced it was a toast - so a few seconds after it happened
* there was no way to tell an orphaned file from a healthy one, and the file
* would still look saved while Ctrl+S was quietly about to ask for a new
* location. This is that state, kept on screen.
*
* Nothing is shown for the two ordinary cases - a healthy link, and a file that
* never came from disk - so the badge only ever appears when something needs
* attention. `FileOriginBadge` sits next to this and answers a different
* question (local vs server storage), not this one.
*/
/** Keeps a lost or diverged disk link on screen, where only a transient toast said so.
* Silent when healthy or never from disk, so it always means trouble. */
interface DiskLinkBadgeProps {
file: StirlingFileStub;
/** Icon-only, for dense rows. */
@@ -70,10 +58,8 @@ export function DiskLinkBadge({ file, compact = false }: DiskLinkBadgeProps) {
return (
<Tooltip label={config.tooltip} withinPortal multiline maw={300}>
{/* Compact is icon-only, so it needs a name of its own - and aria-label
is prohibited on a bare span, hence role="img". With the label
visible the text already names it, and a second aria-label would
only shadow it. */}
{/* Icon-only needs its own name, but aria-label is prohibited on a bare
span, hence role="img". With the label visible it would shadow the text. */}
<span
style={badgeStyle}
{...(compact ? { role: "img", "aria-label": config.label } : {})}
@@ -5,15 +5,8 @@ import type {
} from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
/**
* A conflict has to be recorded on the stub, not just toasted.
*
* `updateStirlingFileStub` silently drops updates for a file that is not in
* `filesRef` yet, so marking the conflict at the point it is detected - before
* the bytes are published - loses it. Nothing failed visibly: the toast still
* appeared, and only the badge (the one lasting sign of an unresolved fork)
* quietly never showed. Pin the ordering.
*/
// updateStirlingFileStub drops updates for a file not yet in filesRef, so marking
// the conflict before the bytes are published loses the badge with no visible failure.
const diskState = vi.hoisted(() => ({
state: { exists: true, size: 999, modifiedMs: 9_000 },
@@ -51,7 +44,8 @@ const dirtyLinkedStub = (): StirlingFileStub =>
beforeEach(() => {
getStirlingFile.mockImplementation(
async () => new File(["%PDF-1.7"], "report.pdf", { type: "application/pdf" }),
async () =>
new File(["%PDF-1.7"], "report.pdf", { type: "application/pdf" }),
);
});
@@ -98,9 +92,9 @@ describe("a disk conflict is recorded on the stub, not just toasted", () => {
test("diskConflictAt survives the filesRef ordering guard", async () => {
const updates = await hydrate();
await vi.waitFor(() =>
expect(
updates.some((u) => typeof u.diskConflictAt === "number"),
).toBe(true),
expect(updates.some((u) => typeof u.diskConflictAt === "number")).toBe(
true,
),
);
});
});
@@ -496,9 +496,8 @@ export async function addFiles(
`[FileActions] ✓ Found localFilePath: ${localFilePath}`,
);
fileStub.localFilePath = localFilePath;
// Record what the file looked like on disk as we read it. Without this
// baseline the next open has nothing to compare against and re-reads
// the file needlessly.
// Baseline what disk held at read time; without it the next open has
// nothing to compare against and re-reads the file needlessly.
const state = await getDiskFileState(localFilePath);
if (state.exists) {
fileStub.diskSyncedSize = state.size;
@@ -816,11 +815,8 @@ export async function undoConsumeFiles(
* Action factory functions
*/
/**
* Take the disk version of a file we are in conflict with, discarding the
* unsaved in-app edits that were shadowing it. Runs only from the "Use disk
* version" action on the conflict toast.
*/
/** Take the disk version of a conflicted file, discarding the unsaved in-app
* edits shadowing it. Only reachable from the conflict toast's action. */
async function useDiskVersion(
stub: StirlingFileStub,
stateRef: React.MutableRefObject<FileContextState>,
@@ -851,12 +847,8 @@ async function useDiskVersion(
);
}
/**
* Re-check specific open files against disk, in response to the watcher seeing
* their folder change. Same three outcomes as the open path - gone, moved on,
* diverged - but reached while the file is sitting on screen, which is the case
* the list-build and open-time checks cannot cover.
*/
/** Re-check open files against disk when the watcher sees their folder change:
* the on-screen case that list-build and open-time checks cannot cover. */
export async function resyncFilesFromDisk(
fileIds: FileId[],
stateRef: React.MutableRefObject<FileContextState>,
@@ -1046,14 +1038,12 @@ export async function addStirlingFileStubs(
),
STALLED_LOAD_MS,
);
// A desktop file is only a cache of the real file on disk, so reconcile
// against disk BEFORE serving it - otherwise an external edit is invisible
// and a deleted file still opens. Only linked files pay for this.
// A desktop file only caches disk, so reconcile BEFORE serving it, or
// external edits stay invisible and deleted files still open.
const diskSync = await syncLinkedFileFromDisk(stub);
if (diskSync.status === "missing") {
// The list-time prune missed it: the file was deleted between the list
// being drawn and this open. Say so and take it out rather than serving
// a copy of a file the user has deleted.
// Deleted between the list being drawn and this open; remove it rather
// than serving a copy of a file the user deleted.
console.warn(
`[Hydration] ${stub.name} (${fileId}) no longer exists at ${stub.localFilePath}; removing it`,
);
@@ -1063,11 +1053,8 @@ export async function addStirlingFileStubs(
clearTimeout(stall);
return;
}
// The divergence is recorded as state below, not here:
// updateStirlingFileStub drops updates for a file that is not in
// filesRef yet, so marking the conflict before the bytes are published
// silently loses it - and with it the badge that is the only lasting
// sign of an unresolved fork once the toast has gone.
// Stamped as state below, not here: updateStirlingFileStub drops updates
// for files not yet in filesRef, silently losing the conflict badge.
const conflictAt =
diskSync.status === "conflict" ? Date.now() : undefined;
if (conflictAt) {
@@ -1096,10 +1083,8 @@ export async function addStirlingFileStubs(
filesRef.current.set(fileId, stirlingFile);
// filesRef is a ref, so the selectors gating the workbench only see the
// file once something dispatches. Parsing it can't be a precondition.
// Must follow the filesRef write: updateStirlingFileStub drops updates
// for a file it cannot find there.
// Workbench selectors only see the file once something dispatches; must
// follow the filesRef write or the update is dropped.
if (diskSync.status === "updated") {
const { file, state } = diskSync;
const reloadedAt = Date.now();
@@ -12,14 +12,8 @@ import {
const DEBUG = process.env.NODE_ENV === "development";
/**
* Stub fields describing the link to a file on disk. Every other stub field is
* either already persisted at store time or is runtime-only display state, but
* these are edited long after the record was written - a save stamps
* `localFilePath`/`isDirty`, a disk re-read stamps the baseline - and if they
* stay in memory the link dies on reload and the app silently falls back to its
* stale copy. So updates touching them are mirrored into IndexedDB.
*/
// Disk-link fields are stamped long after the record was stored, so updates to
// them are mirrored into IndexedDB - in memory only, the link dies on reload.
const DISK_LINK_FIELDS = [
"localFilePath",
"isDirty",
@@ -8,22 +8,11 @@ import {
} from "@app/services/diskFileSync";
import type { DetachedOpenFile } from "@app/services/pruneMissingRecentFiles";
/**
* Shared wiring for the two places that reconcile the file list against disk
* (the files page and the recent-files list).
*
* Both used to hand `pruneMissingRecentFiles` a bare toast callback, which left
* the detach applied in IndexedDB and in the list they render, but NOT in the
* workbench. Every save path - Ctrl+S, the workbench save buttons, the exit
* warning - reads the workbench stub, so an open file whose original had been
* deleted went on quietly writing itself back to the deleted path instead of
* asking for a new location. That only came right after a reload.
*/
// Shared reconcile wiring for the files page and the recent-files list: the detach
// must reach the workbench stub too, or saves keep writing to the deleted path.
/**
* Both list builders can be in flight at once, and each would report the same
* loss. Ids are remembered briefly so the user is told once.
*/
// Both list builders can be in flight at once; ids are remembered briefly so the
// same loss is reported once.
const RECENTLY_REPORTED_MS = 5000;
const reportedAt = new Map<FileId, number>();
@@ -53,9 +42,8 @@ export function useDiskLinkReconcile() {
const onOpenFilesDetached = useCallback(
(files: DetachedOpenFile[]) => {
// Cut the link in the workbench too, so the save paths stop pointing at a
// file that is not there. This is the part that makes Ctrl+S become
// Save As in the session it happened, rather than after a restart.
// Cut the link in the workbench too, so Ctrl+S becomes Save As in this
// session rather than only after a restart.
files.forEach((file) =>
actions.updateStirlingFileStub(file.id, detachedFields(file.path)),
);
@@ -1,6 +1,5 @@
// Seam for keeping desktop files 1:1 with the real file on disk.
// Non-desktop builds resolve to this no-op default via the @app alias order,
// so web/SaaS keep their IndexedDB copy as the only source of truth.
// Seam for keeping desktop files 1:1 with disk. Non-desktop builds get this
// no-op via @app alias order, so web/SaaS keep IndexedDB as sole truth.
/** On-disk state of a linked file. Mirrors the Rust `DiskFileState`. */
export interface DiskFileState {
+30 -112
View File
@@ -7,18 +7,8 @@ import {
} from "@app/services/desktopFileLink";
import { fileStorage } from "@app/services/fileStorage";
/**
* Keeps a desktop file 1:1 with the real file on disk.
*
* A file opened from disk is copied into IndexedDB so the workbench has bytes to
* work with, but that copy is a cache, not the truth. Left alone it drifts: edit
* the PDF in another app and Stirling would keep serving the stale copy forever,
* and delete it and Stirling would keep offering a file that no longer exists.
* Every read of a linked file goes through here so disk stays authoritative.
*
* No-op off the desktop app, where there is no disk path and the stored copy IS
* the only copy.
*/
// Keeps desktop files 1:1 with disk: the stored copy is a cache, not truth, so
// every read re-checks disk. No-op off desktop, where the copy is the truth.
/** What a linked file's disk state means for the copy we are holding. */
export type DiskSyncOutcome =
@@ -33,12 +23,8 @@ export type DiskSyncOutcome =
/** Disk moved on and we had nothing unsaved, so these are the live bytes. */
| { status: "updated"; file: File; state: DiskFileState };
/**
* How a file stands relative to its disk original. Derived rather than stored so
* there is one answer to "is this backed by a real file" for every surface that
* asks - badge, save shortcut, exit warning - instead of each re-deriving it
* from a different subset of the fields.
*/
/** How a file stands relative to its disk original. Derived rather than stored
* so every surface gets the same answer instead of re-deriving it. */
export type DiskLinkState =
/** Never came from disk: a web upload, or a tool output. */
| "none"
@@ -61,14 +47,8 @@ export function diskLinkState(
return stub.orphanedFilePath ? "orphaned" : "none";
}
/**
* Did the disk file move on since we last read it?
*
* A record with no baseline predates disk sync (or was written by a build that
* did not stamp one), so we cannot prove its copy is current - re-read it once
* and let the read stamp a baseline. Size alone settles it when the platform
* gives us no mtime.
*/
/** Did disk change since our last read? No baseline means we cannot prove the
* copy is current, so re-read once; size settles it when mtime is missing. */
export function hasDiskChanged(
stub: Pick<StirlingFileStub, "diskSyncedSize" | "diskSyncedModifiedMs">,
state: DiskFileState,
@@ -91,12 +71,8 @@ export function diskBaseline(
};
}
/**
* The stub fields that mark a file as having lost its disk original. Applied
* both in storage and in the workbench so every surface agrees: the file list
* badge, and - the part that actually matters - the save paths, which read the
* in-memory stub and would otherwise keep writing to the deleted path.
*/
/** Fields marking a file as having lost its disk original. Applied in storage
* and the workbench so save paths stop writing to the deleted path. */
export function detachedFields(
path: string | undefined,
): Pick<
@@ -116,14 +92,8 @@ export function detachedFields(
};
}
/**
* Compare a linked stub against its file on disk and, when the disk copy has
* moved on, read the live bytes.
*
* Unsaved in-app edits always win: we report a conflict rather than overwriting
* work the user has not saved. An unreadable file (locked by another process,
* permissions) reports `unchanged` so the stored copy still opens.
*/
/** Compare a linked stub against disk and read live bytes when it moved on.
* Unsaved edits win (conflict); an unreadable file reports unchanged. */
export async function syncLinkedFileFromDisk(
stub: StirlingFileStub,
): Promise<DiskSyncOutcome> {
@@ -146,12 +116,8 @@ export async function syncLinkedFileFromDisk(
return { status: "updated", file, state };
}
/**
* Read the disk version of a file we are in conflict with, discarding the
* unsaved in-app edits that were shadowing it. Only ever called from the
* "Use disk version" action, never automatically - losing unsaved work has to
* be something the user asked for.
*/
/** Read the disk version, discarding unsaved in-app edits. Only from the "Use
* disk version" action - losing unsaved work must be the user's choice. */
export async function loadDiskVersion(
stub: StirlingFileStub,
): Promise<{ file: File; state: DiskFileState } | null> {
@@ -167,14 +133,8 @@ export async function loadDiskVersion(
return { file, state };
}
/**
* Replace a record's stored bytes with what we just read from disk and stamp the
* new baseline, so the next session starts from the current file.
*
* The cached page metadata and thumbnail describe the old bytes, so both are
* cleared and regenerate on demand - keeping them would show the previous
* document's pages under the new file.
*/
/** Replace stored bytes with the disk read and re-stamp the baseline. Cached
* metadata and thumbnail describe the old bytes, so both are cleared. */
export async function persistDiskUpdate(
fileId: FileId,
file: File,
@@ -197,14 +157,8 @@ export async function persistDiskUpdate(
});
}
/**
* After the app writes a file back to its disk path, the file on disk IS the
* copy we hold, so re-stamp the baseline. Skipping this leaves the next open
* believing the file changed externally and re-reading it for nothing.
*
* Returns the new baseline so the caller can also update the in-memory stub, or
* null when there is nothing to stamp.
*/
/** After a write-back, disk IS our copy, so re-stamp the baseline or the next
* open re-reads for nothing. Returns the new baseline, or null if none. */
export async function refreshDiskBaselineAfterSave(
fileId: FileId,
path: string,
@@ -231,14 +185,8 @@ export async function deleteVanishedFile(fileId: FileId): Promise<void> {
}
}
/**
* Write an orphaned file somewhere new, then re-link it there. This is what the
* "Save as…" action on the deleted-on-disk toast runs: the user is told the
* original is gone, and the toast can act on it rather than leaving them to
* find the save command and discover the file picker for themselves.
*
* Returns the new path, or null if the user cancelled or the save failed.
*/
/** Write an orphaned file somewhere new and re-link it; backs the toast's
* "Save as…" action. Returns the new path, or null if cancelled or failed. */
export async function saveOrphanAsCopy(
stub: StirlingFileStub,
): Promise<{ path: string; updates: Partial<StirlingFileStub> } | null> {
@@ -287,12 +235,8 @@ function isTranslator(value: unknown): value is Translator {
);
}
/**
* Best-effort translation without a hard dependency on i18n being initialised —
* this module runs from storage/hydration paths that have no React context. The
* English default is already interpolated, so an untranslated toast still reads
* correctly. Mirrors the approach in specialErrorToasts.
*/
/** Best-effort translation: runs from hydration paths with no i18n context,
* and the English default is already interpolated. */
function translate(
key: string,
defaultValue: string,
@@ -319,25 +263,16 @@ interface ToastSpec {
buttonCallback?: () => void;
}
/**
* Toast lazily. This module sits on the hydration path, and the toast barrel
* pulls in the whole icon set - importing it eagerly cost every file open ~3s of
* module load for a notification that almost never fires.
*/
/** Lazy: this sits on the hydration path and the toast barrel pulls in the
* whole icon set, costing ~3s of module load per file open. */
function toast({ alertType = "warning", ...options }: ToastSpec): void {
void import("@app/components/toast")
.then(({ alert }) => alert({ alertType, expandable: false, ...options }))
.catch((error) => console.error("[diskFileSync] toast failed:", error));
}
/**
* Tell the user a file they were opening no longer exists. This is the race the
* list-time prune cannot catch: the file was there when the list was drawn and
* was deleted before they clicked it.
*
* Nothing to offer here: the file is gone and its copy went with it, so this
* one stays a plain notice.
*/
/** Tell the user a file they were opening is gone - the race the list-time
* prune cannot catch. Nothing to offer, so it stays a plain notice. */
export function notifyFileVanished(name: string): void {
toast({
title: translate("desktopFileLink.missing.title", "File no longer exists"),
@@ -350,16 +285,8 @@ export function notifyFileVanished(name: string): void {
});
}
/**
* Tell the user a file they still have open has been deleted on disk. It stays
* open and keeps its contents, but it is no longer backed by anything, so the
* next save has to go somewhere new.
*
* This is an unresolved decision, not an event, so the toast stays until it is
* dealt with and carries the action that deals with it. Telling someone to save
* without saying they will be asked for a new location makes the file picker
* read as an error.
*/
/** An open file lost its disk original - an unresolved decision, not an event,
* so the toast persists and says saving will ask for a new location. */
export function notifyOpenFileDeleted(
names: string[],
onSaveAs?: () => void,
@@ -386,13 +313,8 @@ export function notifyOpenFileDeleted(
});
}
/**
* Tell the user their unsaved edits are shadowing a newer file on disk.
*
* Keeping theirs is the safe default and is what already happened, so the
* action offered is the reversal. Announcing a fork with no way to resolve it
* leaves the user to work out for themselves which version they are looking at.
*/
/** Unsaved edits are shadowing a newer disk file. Keeping theirs already
* happened, so the action offered is the reversal, not a fork with no exit. */
export function notifyDiskConflict(name: string, onUseDisk?: () => void): void {
toast({
title: translate("desktopFileLink.conflict.title", "File changed on disk"),
@@ -414,12 +336,8 @@ export function notifyDiskConflict(name: string, onUseDisk?: () => void): void {
});
}
/**
* Say that an external edit was picked up. Silently swapping the bytes is the
* right default - it is what "the file on disk is the truth" means - but doing
* it with no trace at all leaves someone who did not expect the change unable
* to tell whose version is on screen.
*/
/** Say an external edit was picked up: swapping bytes silently is right, but
* with no trace nobody can tell whose version is on screen. */
export function notifyDiskReloaded(name: string): void {
toast({
alertType: "neutral",
@@ -127,9 +127,8 @@ describe("pruneMissingRecentFiles", () => {
});
it("keeps a version-history root even though it looks like a passthrough", async () => {
// v1 opened from disk, then a tool ran on it: still v1 with no tool history
// of its own, so it reads as pristine - but later versions point at it and
// deleting it takes "revert to original" with them.
// A v1 root has no tool history of its own so it reads as pristine, but later
// versions point at it: deleting it takes "revert to original" with them.
const stubs = [
stub({
id: "root" as FileId,
@@ -6,33 +6,16 @@ import {
} from "@app/services/desktopFileLink";
import { detachedFields } from "@app/services/diskFileSync";
/**
* Reconciles the file list against disk before the user sees it, so a file
* deleted outside the app never shows up as available. The open path re-checks
* for the race where a file is deleted after the list is drawn.
*
* No-op off the desktop app, where there is no disk file to have lost.
*/
/** Reconciles the file list against disk so a file deleted outside the app never shows as available;
* the open path re-checks for the delete-after-draw race. No-op off desktop. */
/**
* True for an entry backed by a real local IndexedDB record, false for the
* ephemeral `server-`/`shared-` stubs synthesised from server storage. Only
* local records can have a disk link to reconcile.
*/
/** True for real local IndexedDB records; ephemeral `server-`/`shared-` stubs have no disk link. */
export function hasLocalRecord(stub: StirlingFileStub): boolean {
return !stub.id.startsWith("server-") && !stub.id.startsWith("shared-");
}
/**
* A local-only entry is a pristine passthrough of its disk file - same bytes on
* both sides - only if it is a v1 with no in-app edits. When its disk file
* disappears there is nothing unique to keep, so it goes.
*
* A non-leaf is excluded even though it fits that description: it is the root of
* a version history that later versions still point at, and deleting it takes
* "revert to original" with it. Its bytes are the only remaining copy of where
* the user's work started, which is exactly the thing not to throw away.
*/
/** Unedited v1 holds the same bytes as disk, so losing it loses nothing unique.
* Non-leaf is excluded: it is the version-history root "revert to original" needs. */
function isPristineLocalPassthrough(stub: StirlingFileStub): boolean {
return (
!stub.isDirty &&
@@ -51,38 +34,16 @@ export interface DetachedOpenFile {
}
export interface PruneOptions {
/**
* Files currently open in the workbench. Deleting one of these would leave a
* document on screen that has quietly stopped existing anywhere - still
* editable, but gone from the file list and gone for good at the next
* restart. Open files are therefore detached rather than deleted, whatever
* their edit state, so what is on screen is always still somewhere.
*/
/** Files open in the workbench. Deleting one would leave an on-screen document existing
* nowhere, so open files are detached rather than deleted whatever their edit state. */
openFileIds?: ReadonlySet<FileId>;
/**
* Open files whose disk original vanished. The caller MUST apply
* {@link detachedFields} to its own copy of these stubs: the save paths read
* the workbench stub, not this list, and would otherwise go on writing to the
* path the user just deleted instead of asking for a new one.
*/
/** Open files whose disk original vanished. The caller MUST apply {@link detachedFields} to its
* own stubs: save paths read the workbench stub and would keep writing to the deleted path. */
onOpenFilesDetached?: (files: DetachedOpenFile[]) => void;
}
/**
* Reconcile LOCAL-ONLY entries against the disk files they link to. Server-backed
* entries are skipped: the server may hold the only remaining copy, and nothing
* here can confirm that, so their dead disk link is left alone.
*
* For each local-only stub whose `localFilePath` no longer exists on disk:
* - open in the workbench → KEPT, link detached (see {@link PruneOptions}).
* - version-history root → KEPT, link detached (later versions still need it).
* - pristine passthrough → removed from the list AND from IndexedDB.
* - edited / dirty → KEPT, dead `localFilePath` cleared (so there is no
* broken save-in-place; Ctrl+S falls back to Save As).
*
* Detached files keep the path they used to point at, so the UI can go on saying
* "not on disk" once the toast announcing it has gone.
*/
/** Local-only stubs with a dead `localFilePath`: pristine passthroughs are deleted, the rest are
* detached (old path kept for the UI). Server-backed skipped - the server may hold the only copy. */
export async function pruneMissingRecentFiles(
stubs: StirlingFileStub[],
options: PruneOptions = {},
@@ -1,26 +1,12 @@
// oxlint-disable typescript/no-explicit-any -- this file impersonates Tauri's
// untyped `window.__TAURI_INTERNALS__` bridge; typing the shim would mean
// re-declaring Tauri's private IPC surface for no benefit.
// oxlint-disable typescript/no-explicit-any -- impersonates Tauri's untyped
// `__TAURI_INTERNALS__` bridge; typing it would re-declare Tauri's private IPC.
import { test } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
/**
* CAPTURE HARNESS - not a regression test.
*
* Drives the real desktop disk-linking code in a browser by standing up a fake
* Tauri IPC layer over an in-memory disk, then screenshots each state for the
* walkthrough. Needs the dev server in DESKTOP mode, because
* `desktopFileLinkingSupported` is only true when `@app/services/desktopFileLink`
* resolves to the desktop implementation:
*
* npx vite --mode desktop --port 5173 --strictPort
* SHOT_DIR=<dir> npx playwright test zz-disk-link-capture --project=stubbed
*
* Without SHOT_DIR it still runs (and so still proves the flows work) but
* writes nothing.
*/
/** Capture harness, not a regression test: fakes Tauri IPC over an in-memory disk.
* Needs `vite --mode desktop` (for desktopFileLinkingSupported) and SHOT_DIR set. */
const SHOT_DIR = process.env.SHOT_DIR;
// Playwright runs from the editor dir, so this is stable without __dirname
@@ -57,9 +43,8 @@ async function installTauri(page: Page) {
await page.addInitScript(() => {
const w = window as any;
w.isTauri = true;
// The init script re-runs on every navigation, so the virtual disk lives in
// sessionStorage - otherwise a reload would silently empty it and every
// scenario would look like "the file was deleted".
// Init script re-runs on every navigation, so the disk lives in sessionStorage -
// a reload would otherwise empty it and make every file look deleted.
w.__disk = JSON.parse(sessionStorage.getItem("__disk") || "{}");
w.__saveDisk = () =>
sessionStorage.setItem("__disk", JSON.stringify(w.__disk));
@@ -74,9 +59,8 @@ async function installTauri(page: Page) {
: { exists: false, size: 0, modifiedMs: 0 };
};
// The desktop build sends every API call through tauri-plugin-http, not
// window.fetch, so Playwright's page.route() stubs would never see them.
// Delegating to the browser's fetch puts them back on the intercepted path.
// Desktop API calls go through tauri-plugin-http, not window.fetch, so
// page.route() never sees them; delegating to fetch restores interception.
const httpReqs: Record<number, any> = {};
const httpRes: Record<number, { body: Uint8Array; read: boolean }> = {};
let nextRid = 1;
@@ -294,11 +278,8 @@ async function shoot(page: Page, name: string, theme: string) {
console.log(` shot ${name}_${theme}`);
}
/**
* Double-click the named card to load it into the workbench, then wait for the
* app to actually leave the file list - without this the capture can fire while
* the open is still in flight and show an empty workbench.
*/
/** Double-click a card into the workbench and wait for the open to finish -
* capturing mid-flight shows an empty workbench. */
async function openCard(page: Page, name: string) {
// Scope to the grid card: a bare text match also hits the library rail on the
// left, which navigates without loading the file into the workbench.
@@ -328,11 +309,8 @@ async function gotoFiles(page: Page) {
await page.waitForTimeout(800);
}
/**
* Put the world into a known state: disk contents, stored records, then a full
* navigation so the app re-reads IndexedDB from cold. Always ends on the file
* list with any onboarding dismissed.
*/
/** Stage disk contents and stored records, then navigate so the app re-reads
* IndexedDB cold. Always ends on the file list with onboarding dismissed. */
async function stage(
page: Page,
disk: Record<string, DiskEntry>,
@@ -54,9 +54,8 @@ export interface StirlingFileStub extends BaseFileMetadata {
// one of them, which is how a stale stored copy is spotted without hashing.
diskSyncedSize?: number;
diskSyncedModifiedMs?: number;
// The path this file used to be backed by, kept when the original is deleted.
// Losing the link is a lasting state, not a moment - the badge reads this so
// it can keep saying "not on disk" long after the toast about it has gone.
// Path this file used to be backed by, kept when the original is deleted so
// the badge keeps saying "not on disk" long after the toast has gone.
orphanedFilePath?: string;
// Epoch ms of an unresolved divergence: disk moved on while we held unsaved
// edits, so two real versions exist and the user has not picked one yet.
@@ -386,10 +385,7 @@ export interface FileContextActions {
id: FileId,
updates: Partial<StirlingFileStub>,
) => void;
/**
* Re-check open files against their disk originals (desktop file watcher).
* No-op for files with no disk link.
*/
/** Re-check open files against their disk originals (desktop file watcher); no-op without a disk link. */
resyncFilesFromDisk: (fileIds: FileId[]) => Promise<void>;
reorderFiles: (orderedFileIds: FileId[]) => void;
clearAllFiles: () => Promise<void>;
@@ -11,9 +11,8 @@ interface FileEditorStatusDotProps {
export function FileEditorStatusDot({ file }: FileEditorStatusDotProps) {
const { t } = useTranslation();
// An orphaned file used to read as plain "not saved to disk" - the same thing
// a brand-new file says - so a document whose original had been deleted
// underneath the user was indistinguishable from one never saved.
// Orphaned needs its own case: it used to read "not saved to disk", making a
// deleted original indistinguishable from a file never saved.
const { label, color } = (() => {
switch (diskLinkState(file)) {
case "orphaned":
@@ -81,10 +81,8 @@ export function useAppInitialization(): void {
);
const addedFiles = await addFiles(filesArray, { selectFiles: true });
// addFiles has already stored the records, so the path is stamped after
// the fact - updateStirlingFileStub mirrors it into IndexedDB so the
// link survives a restart. The baseline goes with it, otherwise the
// next open reads the file back as if it had changed externally.
// Path is stamped after addFiles stores records, so the link survives restart.
// The baseline goes too, or the next open sees a false external change.
await Promise.all(
addedFiles.map(async (file) => {
const localFilePath = quickKeyToPath.get(file.quickKey);
@@ -6,21 +6,11 @@ import {
watchDiskPaths,
} from "@app/services/desktopFileLink";
/**
* Watches the disk originals of every open file and reconciles as they change.
*
* Everything else in disk linking reconciles at a moment the user happens to
* trigger - building the file list, or opening a file. A file edited or deleted
* while it sits open in the workbench was therefore not noticed at all until
* something else prompted a rebuild, so the app could show a document that had
* stopped existing and let the user go on editing it.
*/
// Watches disk originals of open files: every other reconcile is user-triggered, so
// an edit or delete under an open file otherwise goes unnoticed until a rebuild.
/**
* Editors rarely write a file once: a save is often truncate-then-write, or a
* temp file renamed over the target, which arrives as a burst. Waiting for the
* burst to end avoids reading a half-written PDF.
*/
// Saves arrive as bursts (truncate-then-write, or temp file renamed over the
// target); settling avoids reading a half-written PDF.
const SETTLE_MS = 400;
export function useDiskWatcher(): void {
@@ -31,9 +21,8 @@ export function useDiskWatcher(): void {
.filter((stub) => stub.localFilePath)
.map((stub) => ({ id: stub.id, path: stub.localFilePath! }));
const paths = linked.map((entry) => entry.path).sort();
// Re-registered whenever the linked set changes. The key is JSON rather than
// a joined string: a separator would have to be a character that cannot occur
// in a path, and on Windows very few candidates qualify.
// Re-registered whenever the linked set changes. JSON rather than a joined
// string: no separator character is safe inside Windows paths.
const watchKey = JSON.stringify(paths);
// The handler and the watch effect read through refs, so neither is torn down
@@ -1,9 +1,8 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
import type { DiskFileState } from "@core/services/desktopFileLink";
// Desktop implementation of the file-link seam. Overrides the core no-op in
// desktop builds (see @app alias order) so a file opened from disk stays 1:1
// with the real file rather than drifting from its IndexedDB copy.
// Desktop side of the file-link seam; overrides the core no-op via @app alias
// order so disk-opened files stay 1:1 instead of drifting from IndexedDB.
export type { DiskFileState };
@@ -37,11 +36,8 @@ export async function pathExistsOnDisk(path: string): Promise<boolean> {
}
}
/**
* Watch the given linked files for external changes. Replaces any previous
* watch set; an empty list stops watching. Failure is non-fatal - detection
* falls back to the checks made at list-build and open time.
*/
/** Replaces the watch set; empty list stops watching. Failure is non-fatal -
* list-build and open-time checks still catch changes. */
export async function watchDiskPaths(paths: string[]): Promise<void> {
if (!isTauri()) return;
try {
@@ -69,11 +65,8 @@ export async function onDiskFilesChanged(
}
}
/**
* Read the live bytes of a linked file. Returns null when the file is gone or
* unreadable so callers can fall back to the stored copy rather than showing an
* empty document.
*/
/** Live bytes of a linked file, or null when gone/unreadable so callers fall
* back to the stored copy instead of showing an empty document. */
export async function readFileFromDisk(
path: string,
): Promise<ArrayBuffer | null> {
@@ -81,9 +74,8 @@ export async function readFileFromDisk(
try {
const { readFile } = await import("@tauri-apps/plugin-fs");
const bytes = await readFile(path);
// readFile usually hands back a tightly-packed buffer; use it directly
// instead of slicing, which would copy the whole file (a transient 2x
// memory spike on large PDFs). Only slice a view over a larger buffer.
// Use a tightly-packed buffer directly; slicing copies the whole file
// (2x memory spike on large PDFs). Only slice a view over a larger buffer.
return bytes.byteOffset === 0 &&
bytes.byteLength === bytes.buffer.byteLength
? bytes.buffer