mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Condense disk-linking comments to two lines
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user