diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a39fb85..4cb4571 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,7 @@ jobs: cli-tree-dump cli-views update-notice + file-change-detection ) # A win_subset entry that names no suite does not fail to match, it # silently fails in_subset and the Windows leg prints a skip line and diff --git a/CHANGELOG.md b/CHANGELOG.md index b47f065..e0a9961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to UniDoc PDF Debugger are recorded here. Format follows Keep a Changelog with an added Refactored section; versions use semantic versioning. +## [Unreleased] + +### Added + +- File > "Reload from Disk" (Cmd+R on macOS, Ctrl+R on Windows and Linux, and F5 everywhere) opens the active tab's file again in place. The tab keeps its position and its left-rail view; the tree, selection, detail panel, history and recent jumps reset as for a fresh open. A short "reloaded - view reset" note appears at the top of the window. A reload that finishes after you switched to another tab replaces that tab without taking focus. If the file is gone or no longer parses, the old tab stays as it was and the error banner says why; on Windows a file another app has locked shows "in use by another app - try again" instead +- Open files are watched for changes on disk, background tabs included. When a file is rewritten, replaced or deleted, its tab gets a small amber dot (2 to 4 seconds after the write while the window has focus, 10 to 20 seconds otherwise, also for a file a generator keeps rewriting) and a tooltip with the time it changed. The detail panel shows "Document updated" with Reload and Compare buttons, or "File not found". Nothing reloads by itself, and the marker never moves focus or shifts the layout. Detection compares size, modification time and file identity, so an in-place rewrite that keeps all three is not seen +- File > "Compare with File on Disk" diffs the open version against the file as it is now on disk, in the Diff panel, labelled "On disk (14:05)". Files over 500 MB ask first. Reload in the Diff header then makes the compared copy the tab's document without parsing it again +- Plain Text and the CLI's `dump bytes` (raw and `--json`) refuse to read a file that changed since it was parsed ("file changed on disk") instead of mixing the old structure with the new bytes. Signatures and `dump signatures` keep every signature's details and show the /Contents hole check as "not checked" for such a file; they used to report a hole mismatch. On Windows a file another app has locked shows "File is in use by another app" in Plain Text and in the hole check instead + ## [0.5.0] - 2026-10-14 Left-rail navigators for structure, pages and images, an image preview, update notices in the app and the CLI, and a CLI that says what it checked and rejects invocations it would have misread. Moves the app runtime to Wails v3 beta. diff --git a/cmd/cli/cli_open.go b/cmd/cli/cli_open.go index 1d1e791..ad04212 100644 --- a/cmd/cli/cli_open.go +++ b/cmd/cli/cli_open.go @@ -6,6 +6,11 @@ import ( "unidoc-pdf-debugger/internal/pdfcore" ) +// afterCLIOpen, when set, runs right after openForCLI succeeds in the dump +// bytes and dump signatures commands. Nil in production; tests use it to +// change the file between the parse and the view read. +var afterCLIOpen func(path string) + // openForCLI creates an Inspector, opens the file under the "cli" tab, and // emits the non-fatal structural warning. On failure it classifies the error // via handleOpenError and returns a non-zero exit code; the inspector is nil in diff --git a/cmd/cli/cmd_bytes.go b/cmd/cli/cmd_bytes.go index 81307a0..f8dc377 100644 --- a/cmd/cli/cmd_bytes.go +++ b/cmd/cli/cmd_bytes.go @@ -2,9 +2,11 @@ package main import ( "context" + "errors" "fmt" - "io" "os" + + "unidoc-pdf-debugger/internal/pdfcore" ) // runBytesDump parses flags and dispatches the document-level byte dump. @@ -33,6 +35,8 @@ func runBytesDump(args []string) int { // substitution is lossy and cannot be reversed, so re-encoding the decoded // string would corrupt every control byte. Reading the file directly keeps the // raw dump truly byte-for-byte without adding any PDF semantics (thin presenter). +// Both paths refuse a file that changed since the parse ("file changed on +// disk", exit 2). func execBytesDump(filePath string, f docViewFlags) (exitCode int) { defer func() { if r := recover(); r != nil { @@ -46,6 +50,9 @@ func execBytesDump(filePath string, f docViewFlags) (exitCode int) { return code } defer func() { _ = ins.Close("cli") }() + if afterCLIOpen != nil { + afterCLIOpen(filePath) + } if f.json { // Only the --json path needs the decoded payload. GetPlainText performs a @@ -70,15 +77,13 @@ func execBytesDump(filePath string, f docViewFlags) (exitCode int) { } // Verbatim source bytes: stream the file directly (the decoded Content is - // lossy for control bytes and cannot reproduce the source). io.Copy streams - // in fixed-size chunks rather than allocating the whole file like os.ReadFile. - src, err := os.Open(filePath) - if err != nil { - writeJSONError(os.Stderr, err.Error()) - return 2 - } - defer func() { _ = src.Close() }() - if _, err := io.Copy(os.Stdout, src); err != nil { + // lossy for control bytes and cannot reproduce the source), in fixed-size + // chunks rather than allocating the whole file like os.ReadFile. + if _, err := ins.CopyDocumentFile("cli", os.Stdout); err != nil { + if errors.Is(err, pdfcore.ErrFileChanged) || errors.Is(err, pdfcore.ErrFileBusy) { + writeJSONError(os.Stderr, err.Error()) + return 2 + } fmt.Fprintf(os.Stderr, "failed to write output: %v\n", err) return 2 } diff --git a/cmd/cli/cmd_signatures.go b/cmd/cli/cmd_signatures.go index 56b6950..a6e49b3 100644 --- a/cmd/cli/cmd_signatures.go +++ b/cmd/cli/cmd_signatures.go @@ -36,6 +36,9 @@ func execSignaturesDump(filePath string, flags docViewFlags) (exitCode int) { return code } defer func() { _ = ins.Close("cli") }() + if afterCLIOpen != nil { + afterCLIOpen(filePath) + } list, err := ins.GetSignatures("cli") if err != nil { @@ -151,9 +154,12 @@ func printSignaturesPlain(out io.Writer, sigs []pdfcore.SignatureField) error { w.Addf("Coverage note", "signed range does not start at byte 0 (starts at %d)", s.ByteRange[0]) } } - if s.HoleMatchesContents { + switch { + case s.HoleCheckError != "": + w.Add("Hole matches /Contents", "not checked: "+s.HoleCheckError) + case s.HoleMatchesContents: w.Add("Hole matches /Contents", "yes") - } else { + default: w.Add("Hole matches /Contents", "no (excluded span differs from the /Contents extent)") } } diff --git a/cmd/cli/file_change_test.go b/cmd/cli/file_change_test.go new file mode 100644 index 0000000..9460b68 --- /dev/null +++ b/cmd/cli/file_change_test.go @@ -0,0 +1,138 @@ +// A file rewritten after the CLI parsed it is reported instead of mixing the +// old parse with the new bytes: dump bytes (raw and --json) exits 2 with a +// stderr JSON error; dump signatures keeps the parsed details and prints the +// hole check as not checked. afterCLIOpen runs right after openForCLI +// succeeds and rewrites the file. +// +// Run: cd code && go test -run TestCLIFileChanged ./cmd/cli/ + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// signedCopy copies testdata/signed.pdf into a temp dir. +func signedCopy(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "signed.pdf")) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "signed.pdf") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// rewriteAfterOpen installs an afterCLIOpen hook that overwrites the opened +// file with minimal.pdf and moves its mtime ahead. +func rewriteAfterOpen(t *testing.T) *bool { + t.Helper() + called := new(bool) + afterCLIOpen = func(path string) { + *called = true + data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "minimal.pdf")) + if err != nil { + t.Errorf("read minimal.pdf: %v", err) + return + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Errorf("rewrite: %v", err) + } + later := time.Now().Add(time.Hour) + _ = os.Chtimes(path, later, later) + } + t.Cleanup(func() { afterCLIOpen = nil }) + return called +} + +func TestCLIFileChangedBytesJSON(t *testing.T) { + path := signedCopy(t) + called := rewriteAfterOpen(t) + var code int + var stderr string + captureStdout(t, func() { + stderr = captureStderr(t, func() { + code = execBytesDump(path, docViewFlags{json: true}) + }) + }) + if !*called { + t.Fatalf("afterCLIOpen was not called by dump bytes --json") + } + if code != 2 { + t.Errorf("exit %d, want 2", code) + } + if !strings.Contains(stderr, "file changed on disk") { + t.Errorf("stderr %q must contain %q", stderr, "file changed on disk") + } +} + +func TestCLIFileChangedBytesRaw(t *testing.T) { + path := signedCopy(t) + called := rewriteAfterOpen(t) + var code int + var stdout, stderr string + stdout = captureStdout(t, func() { + stderr = captureStderr(t, func() { + code = execBytesDump(path, docViewFlags{}) + }) + }) + if !*called { + t.Fatalf("afterCLIOpen was not called by dump bytes") + } + if code != 2 { + t.Errorf("exit %d, want 2", code) + } + if !strings.Contains(stderr, "file changed on disk") { + t.Errorf("stderr %q must contain %q", stderr, "file changed on disk") + } + if stdout != "" { + t.Errorf("no bytes of the rewritten file may reach stdout, got %d bytes", len(stdout)) + } +} + +func TestCLIFileChangedSignatures(t *testing.T) { + path := signedCopy(t) + called := rewriteAfterOpen(t) + var code int + stdout := captureStdout(t, func() { + captureStderr(t, func() { + code = execSignaturesDump(path, docViewFlags{}) + }) + }) + if !*called { + t.Fatalf("afterCLIOpen was not called by dump signatures") + } + if code != 0 { + t.Errorf("exit %d, want 0", code) + } + if !strings.Contains(stdout, "not checked: file changed on disk") { + t.Errorf("stdout must note the hole as not checked, got:\n%s", stdout) + } + if !strings.Contains(stdout, "ByteRange") { + t.Errorf("stdout must keep the parsed signature details, got:\n%s", stdout) + } +} + +func TestCLIFileChangedHookUnsetLeavesRunsUnaffected(t *testing.T) { + path := signedCopy(t) + if afterCLIOpen != nil { + t.Fatalf("afterCLIOpen must be nil outside tests") + } + var code int + var stderr string + captureStdout(t, func() { + stderr = captureStderr(t, func() { + code = execSignaturesDump(path, docViewFlags{json: true}) + }) + }) + if code != 0 || stderr != "" { + t.Errorf("unchanged file: exit %d stderr %q, want 0 and nothing", code, stderr) + } +} diff --git a/frontend/src/App.drain.test.tsx b/frontend/src/App.drain.test.tsx index 41be843..c487139 100644 --- a/frontend/src/App.drain.test.tsx +++ b/frontend/src/App.drain.test.tsx @@ -54,6 +54,7 @@ const mockCloseDocument = vi.fn().mockResolvedValue(undefined); vi.mock('../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ OpenFile: (...a: unknown[]) => mockOpenFile(...a), + SetWatchedTabs: vi.fn().mockResolvedValue(undefined), GetTreeRoot: (...a: unknown[]) => mockGetTreeRoot(...a), GetChildren: (...a: unknown[]) => mockGetChildren(...a), CloseDocument: (...a: unknown[]) => mockCloseDocument(...a), diff --git a/frontend/src/App.geometry.test.tsx b/frontend/src/App.geometry.test.tsx index 09de568..3a1d3ff 100644 --- a/frontend/src/App.geometry.test.tsx +++ b/frontend/src/App.geometry.test.tsx @@ -76,6 +76,7 @@ vi.mock( '../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ OpenFile: vi.fn(), + SetWatchedTabs: vi.fn().mockResolvedValue(undefined), GetTreeRoot: vi.fn(), GetChildren: vi.fn(), CloseDocument: vi.fn().mockResolvedValue(undefined), diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c2bf9d6..9fb3426 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,12 +2,13 @@ * @file Root application component. Wires Wails runtime events to app state * and renders either the empty state or the main three-panel layout. */ -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { Events, Screens, Window } from '@wailsio/runtime' -import { ConsumePendingOpenFiles } from '../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js' +import { ConsumePendingOpenFiles, SetWatchedTabs } from '../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js' import { AppProvider, useAppState, useAppDispatch } from './hooks/useDocumentState' import { mapErrorMessage, openPDFFile } from './hooks/usePDFService' import { useWindowPersistence } from './hooks/useWindowPersistence' +import { isInTextField } from './hooks/useFindBar' import { computeRestorePlan } from './lib/windowGeometryGuard' import { dispatchOpenedDocument } from './lib/openedDocuments' import { getPlatformModifier } from './lib/platform' @@ -19,6 +20,13 @@ import { BatchOpenDialog } from './components/BatchOpenDialog' import { CommandPalette } from './components/CommandPalette/CommandPalette' import { UpdateNotifier } from './components/UpdateNotifier' import { openPalette, useCommandPalette } from './hooks/useCommandPalette' +import { ReloadToast } from './components/ReloadToast' +import { DiskChangeAnnouncer, announceDiskChange } from './components/DiskChangeAnnouncer' +import { applyDiskStatusEvent, readDiskStatus, retainDiskStatus } from './lib/diskStatusStore' +import { reloadActiveTab } from './lib/reloadDocument' + +/** Window in which a Cmd/Ctrl+R keydown and the menu event count as one press. */ +const SHORTCUT_ECHO_MS = 1000 /** * Inner shell that subscribes to Wails backend events and delegates @@ -46,6 +54,90 @@ function AppContent() { // Ref mirrors tabs for use inside event handler closures without stale captures const tabsRef = useRef(tabs) useEffect(() => { tabsRef.current = tabs }, [tabs]) + const activeTabIdRef = useRef(activeTabId) + useEffect(() => { activeTabIdRef.current = activeTabId }, [activeTabId]) + + // On-disk status entries live only as long as their tab: a closed or + // replaced tab id drops its entry, which clears its marker. The backend + // watcher polls only the tab ids in the strip, never a backend-only Diff + // right side. Keyed on the ids so per-tab state changes (selection, + // history) do not call the backend. + const tabIdsKey = useMemo(() => tabs.map((t) => t.tabId).join('\n'), [tabs]) + useEffect(() => { + const ids = tabIdsKey === '' ? [] : tabIdsKey.split('\n') + retainDiskStatus(ids) + Promise.resolve(SetWatchedTabs(ids)).catch(() => {}) + }, [tabIdsKey]) + + // Reload from disk (File > Reload from Disk, its Cmd/Ctrl+R accelerator, and + // F5) always acts on the active tab, stale or not. Cmd/Ctrl+R is also + // handled here so it works before the native menu is live and never reaches + // the webview's own page reload. One press can arrive both as the keydown + // and as the menu event, in either order: a trigger from the other source + // within SHORTCUT_ECHO_MS of the last one is that echo and is ignored. + useEffect(() => { + const reloadActive = () => reloadActiveTab(dispatch, () => tabsRef.current, activeTabIdRef.current) + let lastShortcut = { source: '', at: -Infinity } + /** + * Records a press and reports whether it is new rather than the echo of + * one from the other source. + * @param {'key' | 'menu'} source + */ + const notePress = (source) => { + const now = performance.now() + if (lastShortcut.source !== source && now - lastShortcut.at < SHORTCUT_ECHO_MS) return false + lastShortcut = { source, at: now } + return true + } + const wantsMeta = getPlatformModifier() === 'Cmd' + /** @param {KeyboardEvent} e */ + function onKeyDown(e) { + const mod = wantsMeta ? e.metaKey : e.ctrlKey + const isModR = mod && !e.shiftKey && !e.altKey && (e.key === 'r' || e.key === 'R') + if (e.key !== 'F5' && !isModR) return + // Cmd/Ctrl+R never reaches the webview reload, even in a text field. + e.preventDefault() + const swallowed = e.repeat || isInTextField(e.target) + if (isModR) { + // A swallowed press is still recorded, so its menu echo is dropped too. + if (notePress('key') && !swallowed) reloadActive() + return + } + if (!swallowed) reloadActive() + } + const offReload = Events.On('document:reload-active', () => { + if (notePress('menu')) reloadActive() + }) + window.addEventListener('keydown', onKeyDown) + return () => { + offReload() + window.removeEventListener('keydown', onKeyDown) + } + }, [dispatch]) + + // Watcher events go to the disk-status store, never to AppState, so a status + // update re-renders only the subscribed tab item, note and announcer. + // Events for tab ids not in the tab list yet (a reload still fetching its + // tree) are ignored; reloadTab checks the new id once its tab exists. + useEffect(() => { + return Events.On('document:disk-status', (event) => { + const data = event?.data + if (!data || typeof data.tabId !== 'string') return + const tab = tabsRef.current.find((t) => t.tabId === data.tabId) + if (!tab) return + const wasChanged = readDiskStatus(data.tabId)?.state === 'changed' + const applied = applyDiskStatusEvent({ + tabId: data.tabId, + seq: Number(data.seq) || 0, + state: String(data.state ?? ''), + loadedModTime: Number(data.loadedModTime) || 0, + diskModTime: Number(data.diskModTime) || 0, + diskSize: Number(data.diskSize) || 0, + }) + // A refresh of an already changed tab (new times) is not announced again. + if (applied && data.state === 'changed' && !wasChanged) announceDiskChange(`${tab.fileName} changed on disk`) + }) + }, []) // Sync navigation menu enabled state with backend const prevNavState = useRef({ canGoBack: false, canGoForward: false }) @@ -309,14 +401,6 @@ function AppContent() { // native menu item in main.go also emits navigate:goToPage; this listener // exists so the shortcut works even before the menu is opened. useEffect(() => { - /** @param {EventTarget | null} target */ - function isInTextField(target) { - if (!(target instanceof HTMLElement)) return false - const tag = target.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA') return true - if (target.isContentEditable) return true - return false - } const wantsMeta = getPlatformModifier() === 'Cmd' /** @param {KeyboardEvent} e */ function handler(e) { @@ -355,6 +439,8 @@ function AppContent() { + + ) } diff --git a/frontend/src/App.menuNavigation.test.tsx b/frontend/src/App.menuNavigation.test.tsx index 745dfd6..40681d9 100644 --- a/frontend/src/App.menuNavigation.test.tsx +++ b/frontend/src/App.menuNavigation.test.tsx @@ -42,6 +42,7 @@ vi.mock('allotment/dist/style.css', () => ({})); vi.mock('../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ OpenFile: vi.fn(), + SetWatchedTabs: vi.fn().mockResolvedValue(undefined), GetTreeRoot: vi.fn(), GetChildren: vi.fn().mockResolvedValue([]), CloseDocument: vi.fn().mockResolvedValue(undefined), diff --git a/frontend/src/App.pagesNavigator.test.tsx b/frontend/src/App.pagesNavigator.test.tsx index 072aa8e..84b9914 100644 --- a/frontend/src/App.pagesNavigator.test.tsx +++ b/frontend/src/App.pagesNavigator.test.tsx @@ -55,6 +55,7 @@ const mockGetPageIndex = vi.fn(); vi.mock('../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ OpenFile: vi.fn(), + SetWatchedTabs: vi.fn().mockResolvedValue(undefined), GetTreeRoot: vi.fn(), GetChildren: vi.fn().mockResolvedValue([]), CloseDocument: vi.fn().mockResolvedValue(undefined), diff --git a/frontend/src/App.reload.test.tsx b/frontend/src/App.reload.test.tsx new file mode 100644 index 0000000..ca8a7ca --- /dev/null +++ b/frontend/src/App.reload.test.tsx @@ -0,0 +1,611 @@ +/** + * Reload from disk at the app level: the document:reload-active menu event and + * F5 reload the active tab under a new tab id in the same strip position, + * failures keep the old tab, a busy file only shows the pill, and on-disk + * status events mark tabs without disturbing the active view. + * + * The real TabBar and ReloadToast render; MainLayout is replaced by a + * useAppState consumer that counts its renders, standing in for TreePanel and + * DetailPanel. + * + * Run: cd frontend && npx vitest run src/App.reload.test.tsx + */ +import { render, screen, act, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { AppState } from './hooks/useDocumentState'; +import { resetDiskStatusStore } from './lib/diskStatusStore'; +import App from './App'; +import { getPlatformModifier } from './lib/platform'; +import { SetWatchedTabs } from '../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; + +type EventHandler = (event: { data: Record }) => void; +const eventHandlers: Record = {}; + +vi.mock('@wailsio/runtime', () => ({ + Events: { + On: (name: string, handler: EventHandler) => { + if (!eventHandlers[name]) eventHandlers[name] = []; + eventHandlers[name].push(handler); + return () => { + const idx = eventHandlers[name]?.indexOf(handler) ?? -1; + if (idx >= 0) eventHandlers[name].splice(idx, 1); + }; + }, + Emit: vi.fn(), + }, +})); + +const bindings = vi.hoisted(() => ({ + OpenFile: vi.fn(), + GetTreeRoot: vi.fn(), + GetChildren: vi.fn(), + CloseDocument: vi.fn(), + GetDiskStatus: vi.fn(), +})); + +vi.mock('../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ + OpenFile: (...a: unknown[]) => bindings.OpenFile(...a), + GetTreeRoot: (...a: unknown[]) => bindings.GetTreeRoot(...a), + GetChildren: (...a: unknown[]) => bindings.GetChildren(...a), + CloseDocument: (...a: unknown[]) => bindings.CloseDocument(...a), + GetDiskStatus: (...a: unknown[]) => bindings.GetDiskStatus(...a), + SetWatchedTabs: vi.fn().mockResolvedValue(undefined), + OpenFileDialog: vi.fn(), + ConsumePendingOpenFiles: vi.fn().mockResolvedValue([]), + GetObjectDetail: vi.fn(), + GetContentStream: vi.fn(), + GetAncestorPath: vi.fn(), +})); + +const probe = vi.hoisted(() => ({ renders: 0, state: null as AppState | null })); + +vi.mock('./components/MainLayout', async () => { + const { useAppState } = await import('./hooks/useDocumentState'); + return { + MainLayout: () => { + const s = useAppState(); + probe.renders += 1; + probe.state = s; + return
{s.activeTabId}
; + }, + }; +}); +vi.mock('./components/EmptyState', () => ({ + EmptyState: () =>
Empty
, +})); +vi.mock('./components/ErrorBanner', () => ({ + ErrorBanner: ({ message }: { message: string }) =>
{message}
, +})); +vi.mock('./components/UpdateNotifier', () => ({ + UpdateNotifier: () => null, +})); + +function emitEvent(name: string, data: Record = {}) { + for (const handler of [...(eventHandlers[name] ?? [])]) handler({ data }); +} + +const catalogNode = { + id: 'root', + label: 'Catalog', + rawKey: '', + nodeType: 'dict', + valueType: '', + hasChildren: true, + childCount: 1, + iconHint: 'catalog', + error: '', +}; + +// openedDocuments.ts keeps module-level maps of released ids and paths, so +// every test uses its own ids and paths. +let n = 0; +function ids() { + n += 1; + return { + a: `a${n}`, + b: `b${n}`, + fresh: `fresh${n}`, + pathA: `/docs/${n}/a.pdf`, + pathB: `/docs/${n}/b.pdf`, + }; +} + +function deliverOpen(tabId: string, filePath: string, pageCount = 1) { + act(() => { + emitEvent('document:opened', { + tabId, + fileName: filePath.split('/').pop(), + filePath, + pageCount, + rootNode: catalogNode, + rootChildren: [], + }); + }); +} + +function mockOpenResult(tabId: string, filePath: string, pageCount = 7, warning = '') { + bindings.OpenFile.mockResolvedValue({ tabId, fileName: filePath.split('/').pop(), filePath, pageCount, fileSize: 2048, error: warning }); +} + +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function backendError(message: string) { + return new Error(JSON.stringify({ message, cause: {}, kind: 'RuntimeError' })); +} + +function tabOrder(): string[] { + return Array.from(screen.getByTestId('tab-list').querySelectorAll('[data-testid^="tab-"][role="tab"]')).map( + (el) => (el.getAttribute('data-testid') ?? '').replace(/^tab-/, ''), + ); +} + +function diskStatus(tabId: string, seq: number, state: string) { + act(() => { + emitEvent('document:disk-status', { + tabId, + seq, + state, + loadedModTime: new Date(2026, 0, 12, 14, 0).getTime(), + diskModTime: state === 'missing' ? 0 : new Date(2026, 0, 12, 14, 5).getTime(), + diskSize: state === 'missing' ? 0 : 4096, + }); + }); +} + +beforeEach(() => { + for (const key of Object.keys(eventHandlers)) delete eventHandlers[key]; + for (const fn of Object.values(bindings)) fn.mockReset(); + bindings.CloseDocument.mockResolvedValue(undefined); + bindings.GetTreeRoot.mockResolvedValue(catalogNode); + bindings.GetChildren.mockResolvedValue([]); + resetDiskStatusStore(); + probe.renders = 0; + probe.state = null; +}); + +describe('reload triggers', () => { + test('the reload menu event replaces the active tab in place under a new tab id', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + deliverOpen(t.b, t.pathB); + deliverOpen(t.a, t.pathA); // repeated delivery activates the first tab + expect(probe.state?.activeTabId).toBe(t.a); + mockOpenResult(t.fresh, t.pathA, 7); + const freshChildren = [{ ...catalogNode, id: 'root/Pages', label: 'Pages', rawKey: 'Pages', iconHint: 'pages' }]; + bindings.GetChildren.mockResolvedValue(freshChildren); + + act(() => emitEvent('document:reload-active')); + + await waitFor(() => expect(tabOrder()).toEqual([t.fresh, t.b])); + expect(bindings.OpenFile).toHaveBeenCalledTimes(1); + expect(bindings.OpenFile).toHaveBeenCalledWith(t.pathA); + expect(bindings.GetChildren).toHaveBeenCalledWith(t.fresh, 'root'); + expect(bindings.CloseDocument).toHaveBeenCalledWith(t.a); + expect(bindings.CloseDocument).not.toHaveBeenCalledWith(t.fresh); + expect(probe.state?.activeTabId).toBe(t.fresh); + const fresh = probe.state?.tabs.find((x) => x.tabId === t.fresh); + expect(fresh?.pageCount).toBe(7); + expect(fresh?.rootChildren).toEqual(freshChildren); + }); + + test('F5 reloads the active tab and prevents the default action', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + + const notPrevented = fireEvent.keyDown(window, { key: 'F5' }); + + expect(notPrevented).toBe(false); + await waitFor(() => expect(tabOrder()).toEqual([t.fresh])); + expect(bindings.OpenFile).toHaveBeenCalledWith(t.pathA); + }); + + test('F5 typed in a text field does nothing', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + + fireEvent.keyDown(input, { key: 'F5' }); + + expect(bindings.OpenFile).not.toHaveBeenCalled(); + input.remove(); + }); + + test('a held F5 (repeat) does nothing', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + + fireEvent.keyDown(window, { key: 'F5', repeat: true }); + + expect(bindings.OpenFile).not.toHaveBeenCalled(); + }); + + test('a second trigger while the reload is parsing starts no second open', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + const pending = deferred(); + bindings.OpenFile.mockReturnValue(pending.promise); + + act(() => emitEvent('document:reload-active')); + act(() => emitEvent('document:reload-active')); + fireEvent.keyDown(window, { key: 'F5' }); + + expect(bindings.OpenFile).toHaveBeenCalledTimes(1); + await act(async () => { + pending.resolve({ tabId: t.fresh, fileName: 'a.pdf', filePath: t.pathA, pageCount: 1, fileSize: 1, error: '' }); + }); + await waitFor(() => expect(tabOrder()).toEqual([t.fresh])); + }); + + test('with no document open every trigger is a no-op', () => { + render(); + act(() => emitEvent('document:reload-active')); + fireEvent.keyDown(window, { key: 'F5' }); + expect(bindings.OpenFile).not.toHaveBeenCalled(); + }); + + test('a reload with no detected change still reloads', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + act(() => emitEvent('document:reload-active')); + await waitFor(() => expect(bindings.OpenFile).toHaveBeenCalledWith(t.pathA)); + expect(bindings.GetDiskStatus).not.toHaveBeenCalledWith(t.a); + }); + + test('Cmd/Ctrl+R reloads the active tab and prevents the webview reload', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + const mod = getPlatformModifier() === 'Cmd' ? { metaKey: true } : { ctrlKey: true }; + + const notPrevented = fireEvent.keyDown(window, { key: 'r', ...mod }); + + expect(notPrevented).toBe(false); + await waitFor(() => expect(tabOrder()).toEqual([t.fresh])); + }); + + test('Cmd/Ctrl+R in a text field is swallowed without reloading', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + const mod = getPlatformModifier() === 'Cmd' ? { metaKey: true } : { ctrlKey: true }; + + const notPrevented = fireEvent.keyDown(input, { key: 'r', ...mod }); + + expect(notPrevented).toBe(false); + expect(bindings.OpenFile).not.toHaveBeenCalled(); + input.remove(); + }); + + test('the menu event and the keydown for one Cmd/Ctrl+R press open the file once', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + const mod = getPlatformModifier() === 'Cmd' ? { metaKey: true } : { ctrlKey: true }; + + fireEvent.keyDown(window, { key: 'r', ...mod }); + act(() => emitEvent('document:reload-active')); + + await waitFor(() => expect(tabOrder()).toEqual([t.fresh])); + expect(bindings.OpenFile).toHaveBeenCalledTimes(1); + }); + + test('a menu event for the same press after a fast reload finished does not reload again', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + const mod = getPlatformModifier() === 'Cmd' ? { metaKey: true } : { ctrlKey: true }; + + fireEvent.keyDown(window, { key: 'r', ...mod }); + await waitFor(() => expect(tabOrder()).toEqual([t.fresh])); + act(() => emitEvent('document:reload-active')); + await act(async () => { + await Promise.resolve(); + }); + + expect(bindings.OpenFile).toHaveBeenCalledTimes(1); + }); + + test('Cmd/Ctrl+R in a text field swallows the menu echo too', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + const mod = getPlatformModifier() === 'Cmd' ? { metaKey: true } : { ctrlKey: true }; + + fireEvent.keyDown(input, { key: 'r', ...mod }); + act(() => emitEvent('document:reload-active')); + await act(async () => { + await Promise.resolve(); + }); + + expect(bindings.OpenFile).not.toHaveBeenCalled(); + input.remove(); + }); + + test('a held Cmd/Ctrl+R reloads once and swallows the menu echoes of its repeats', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + const mod = getPlatformModifier() === 'Cmd' ? { metaKey: true } : { ctrlKey: true }; + + fireEvent.keyDown(window, { key: 'r', ...mod }); + await waitFor(() => expect(tabOrder()).toEqual([t.fresh])); + fireEvent.keyDown(window, { key: 'r', repeat: true, ...mod }); + act(() => emitEvent('document:reload-active')); + fireEvent.keyDown(window, { key: 'r', repeat: true, ...mod }); + act(() => emitEvent('document:reload-active')); + await act(async () => { + await Promise.resolve(); + }); + + expect(bindings.OpenFile).toHaveBeenCalledTimes(1); + }); + + test('two menu reloads in a row both reload', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + + act(() => emitEvent('document:reload-active')); + await waitFor(() => expect(tabOrder()).toEqual([t.fresh])); + mockOpenResult('tab-fresh-2', t.pathA); + act(() => emitEvent('document:reload-active')); + + await waitFor(() => expect(tabOrder()).toEqual(['tab-fresh-2'])); + expect(bindings.OpenFile).toHaveBeenCalledTimes(2); + }); +}); + +describe('reload outcome', () => { + test('success shows the reload pill', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + + act(() => emitEvent('document:reload-active')); + + const pill = await screen.findByText('a.pdf reloaded - view reset'); + expect(pill.closest('[role="status"]')).not.toBeNull(); + }); + + test('a warning carried by the reloaded document is shown', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA, 1, 'This PDF has structural errors. Some objects may display incorrectly.'); + + act(() => emitEvent('document:reload-active')); + + await waitFor(() => + expect(screen.getByTestId('error-banner')).toHaveTextContent('This PDF has structural errors. Some objects may display incorrectly.'), + ); + }); + + test.each([ + ['document not found: stat /docs/a.pdf: no such file or directory', 'file not found'], + ['malformed PDF: xref table not found', 'file is damaged or not a valid PDF'], + ['encrypted PDF: password required', 'file is encrypted'], + ['something else went wrong', 'unexpected error'], + ])('failure "%s" shows "Could not reload a.pdf: %s" and keeps the old tab and its marker', async (raw, reason) => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + diskStatus(t.a, 1, 'changed'); + bindings.OpenFile.mockRejectedValue(backendError(raw)); + + act(() => emitEvent('document:reload-active')); + + await waitFor(() => expect(screen.getByTestId('error-banner')).toHaveTextContent(`Could not reload a.pdf: ${reason}`)); + expect(tabOrder()).toEqual([t.a]); + expect(probe.state?.activeTabId).toBe(t.a); + expect(screen.getByTestId(`tab-disk-dot-${t.a}`)).toBeInTheDocument(); + expect(bindings.CloseDocument).not.toHaveBeenCalledWith(t.a); + }); + + test('a busy file shows only the pill and keeps the tab and its marker', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + diskStatus(t.a, 1, 'changed'); + bindings.OpenFile.mockRejectedValue(backendError('file is busy: open /docs/a.pdf: The process cannot access the file')); + + act(() => emitEvent('document:reload-active')); + + const pill = await screen.findByText('a.pdf is in use by another app - try again'); + expect(pill.closest('[role="status"]')).not.toBeNull(); + expect(screen.queryByTestId('error-banner')).not.toBeInTheDocument(); + expect(tabOrder()).toEqual([t.a]); + expect(screen.getByTestId(`tab-disk-dot-${t.a}`)).toBeInTheDocument(); + }); + + test('closing the tab while its reload parses closes the new document and adds no tab', async () => { + const t = ids(); + const user = userEvent.setup(); + render(); + deliverOpen(t.a, t.pathA); + deliverOpen(t.b, t.pathB); + deliverOpen(t.a, t.pathA); + const pending = deferred(); + bindings.OpenFile.mockReturnValue(pending.promise); + + act(() => emitEvent('document:reload-active')); + await user.click(screen.getByTestId(`tab-close-${t.a}`)); + await act(async () => { + pending.resolve({ tabId: t.fresh, fileName: 'a.pdf', filePath: t.pathA, pageCount: 1, fileSize: 1, error: '' }); + }); + + await waitFor(() => expect(bindings.CloseDocument).toHaveBeenCalledWith(t.fresh)); + expect(tabOrder()).toEqual([t.b]); + expect(screen.queryByText(/reloaded - view reset/)).not.toBeInTheDocument(); + }); + + test('the marker clears once the reload lands', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + diskStatus(t.a, 1, 'changed'); + mockOpenResult(t.fresh, t.pathA); + + act(() => emitEvent('document:reload-active')); + + await waitFor(() => expect(tabOrder()).toEqual([t.fresh])); + expect(screen.queryByTestId(`tab-disk-dot-${t.fresh}`)).not.toBeInTheDocument(); + }); + + test('a change that landed while the reload parsed marks the new tab at once', async () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + mockOpenResult(t.fresh, t.pathA); + bindings.GetDiskStatus.mockImplementation((id: string) => Promise.resolve( + id === t.fresh + ? { state: 'changed', loadedModTime: 1, diskModTime: 2, diskSize: 10 } + : { state: 'current', loadedModTime: 1, diskModTime: 1, diskSize: 10 }, + )); + + act(() => emitEvent('document:reload-active')); + + await waitFor(() => expect(screen.getByTestId(`tab-disk-dot-${t.fresh}`)).toBeInTheDocument()); + expect(bindings.GetDiskStatus).toHaveBeenCalledWith(t.fresh); + }); +}); + +describe('on-disk status events', () => { + test('the backend watches exactly the tab ids in the strip', async () => { + const t = ids(); + const watched = vi.mocked(SetWatchedTabs); + watched.mockClear(); + render(); + deliverOpen(t.a, t.pathA); + + await waitFor(() => expect(watched).toHaveBeenLastCalledWith([t.a])); + }); + + test('a per-tab state change with the same tab ids does not call the backend', async () => { + const t = ids(); + const watched = vi.mocked(SetWatchedTabs); + render(); + deliverOpen(t.a, t.pathA); + await waitFor(() => expect(watched).toHaveBeenLastCalledWith([t.a])); + watched.mockClear(); + + act(() => emitEvent('navigate:goToPage')); + + expect(watched).not.toHaveBeenCalled(); + }); + + test('a changed event marks the tab and announces it once', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + + diskStatus(t.a, 1, 'changed'); + + expect(screen.getByTestId(`tab-disk-dot-${t.a}`)).toBeInTheDocument(); + const announcer = screen.getByTestId('disk-change-announcer'); + expect(announcer).toHaveAttribute('aria-live', 'polite'); + expect(announcer).toHaveTextContent('a.pdf changed on disk'); + }); + + test('a refresh of an already changed tab is not announced again', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + diskStatus(t.a, 1, 'changed'); + const announced = screen.getByTestId('disk-change-announcer').firstChild; + + diskStatus(t.a, 2, 'changed'); + + expect(screen.getByTestId('disk-change-announcer').firstChild).toBe(announced); + }); + + test('an event does not disturb the active view', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + const focusTarget = document.createElement('button'); + document.body.appendChild(focusTarget); + focusTarget.focus(); + const rendersBefore = probe.renders; + const stateBefore = probe.state; + + diskStatus(t.a, 1, 'changed'); + diskStatus(t.a, 2, 'missing'); + + expect(document.activeElement).toBe(focusTarget); + expect(probe.renders).toBe(rendersBefore); + expect(probe.state).toBe(stateBefore); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByTestId('error-banner')).not.toBeInTheDocument(); + for (const fn of Object.values(bindings)) expect(fn).not.toHaveBeenCalled(); + focusTarget.remove(); + }); + + test('an event whose seq is not above the last applied one changes nothing', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + diskStatus(t.a, 5, 'changed'); + diskStatus(t.a, 4, 'current'); + expect(screen.getByTestId(`tab-disk-dot-${t.a}`)).toBeInTheDocument(); + }); + + test('a current event clears the marker', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + diskStatus(t.a, 1, 'changed'); + diskStatus(t.a, 2, 'current'); + expect(screen.queryByTestId(`tab-disk-dot-${t.a}`)).not.toBeInTheDocument(); + }); + + test('events for tab ids not in the tab list are ignored', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + diskStatus('backend-only-diff-copy', 1, 'changed'); + expect(screen.queryByTestId(/^tab-disk-dot-/)).not.toBeInTheDocument(); + expect(screen.getByTestId('disk-change-announcer')).not.toHaveTextContent('changed on disk'); + }); + + test('a background tab is marked too', () => { + const t = ids(); + render(); + deliverOpen(t.a, t.pathA); + deliverOpen(t.b, t.pathB); + diskStatus(t.a, 1, 'changed'); + expect(screen.getByTestId(`tab-disk-dot-${t.a}`)).toBeInTheDocument(); + expect(screen.queryByTestId(`tab-disk-dot-${t.b}`)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e52294c..593eff4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -43,6 +43,7 @@ vi.mock( '../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ OpenFile: vi.fn(), + SetWatchedTabs: vi.fn().mockResolvedValue(undefined), GetTreeRoot: vi.fn(), GetChildren: vi.fn(), CloseDocument: (...args: unknown[]) => mockCloseDocument(...args), diff --git a/frontend/src/components/DetailPanel.compareDisk.test.tsx b/frontend/src/components/DetailPanel.compareDisk.test.tsx new file mode 100644 index 0000000..17c416b --- /dev/null +++ b/frontend/src/components/DetailPanel.compareDisk.test.tsx @@ -0,0 +1,488 @@ +/** + * Compare with File on Disk: the active tab's file is parsed again backend-only + * and shown as the right side of the Diff inner tab, labelled with its disk + * time. Over 500 MB asks first; a missing file parses nothing. Reload in the + * Diff header promotes the compared copy to the tab without parsing again. + * + * Entry points: the document:compare-active menu event and the stale note's + * Compare button. Test ids: stale-note, diff-reload (Diff header Reload, only + * for a disk copy), existing detail-tab-diff, diff-pick-error, diff-close. + * + * Run: cd frontend && npx vitest run src/components/DetailPanel.compareDisk.test.tsx + */ +import { render, screen, act, waitFor, within, fireEvent } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Dispatch } from 'react'; +import { AppProvider, useAppState, useAppDispatch, type AppAction, type AppState } from '../hooks/useDocumentState'; +import { applyDiskStatusEvent, readDiskStatus, resetDiskStatusStore } from '../lib/diskStatusStore'; +import { DetailPanel } from './DetailPanel'; + +vi.mock('allotment', () => { + function Pane({ children }: { children: React.ReactNode }) { + return
{children}
; + } + function Allotment({ children }: { children: React.ReactNode }) { + return
{children}
; + } + Allotment.Pane = Pane; + return { Allotment }; +}); +vi.mock('allotment/dist/style.css', () => ({})); + +type EventHandler = (event: { data: Record }) => void; +const handlers = vi.hoisted(() => ({} as Record }) => void)[]>)); + +vi.mock('@wailsio/runtime', async (importOriginal) => ({ + ...(await importOriginal()), + Events: { + On: (name: string, handler: EventHandler) => { + (handlers[name] ??= []).push(handler); + return () => { + const i = handlers[name]?.indexOf(handler) ?? -1; + if (i >= 0) handlers[name].splice(i, 1); + }; + }, + Emit: vi.fn(), + }, +})); + +const b = vi.hoisted(() => ({ + OpenFile: vi.fn(), + GetTreeRoot: vi.fn(), + GetChildren: vi.fn(), + CloseDocument: vi.fn(), + GetSignatures: vi.fn(), + GetDiskStatus: vi.fn(), + DiffDocuments: vi.fn(), + OpenFileDialog: vi.fn(), +})); + +vi.mock('../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ + OpenFile: (...a: unknown[]) => b.OpenFile(...a), + GetTreeRoot: (...a: unknown[]) => b.GetTreeRoot(...a), + GetChildren: (...a: unknown[]) => b.GetChildren(...a), + CloseDocument: (...a: unknown[]) => b.CloseDocument(...a), + GetSignatures: (...a: unknown[]) => b.GetSignatures(...a), + GetDiskStatus: (...a: unknown[]) => b.GetDiskStatus(...a), + DiffDocuments: (...a: unknown[]) => b.DiffDocuments(...a), + OpenFileDialog: (...a: unknown[]) => b.OpenFileDialog(...a), + GetObjectDetail: vi.fn().mockResolvedValue(null), + GetContentStream: vi.fn(), + GetImageData: vi.fn(), + DescribeImage: vi.fn().mockResolvedValue({ width: 0, height: 0, colorSpace: '', estimatedBytes: 0 }), + SaveImageToFile: vi.fn().mockResolvedValue(''), + GetReverseRefs: vi.fn().mockResolvedValue([]), + GetFontView: vi.fn(), + GetXRefTable: vi.fn().mockResolvedValue({ tabId: 'x', entries: [] }), + GetPlainText: vi.fn().mockResolvedValue({ tabId: 'x', content: '', totalBytes: 0 }), + GetPlainTextSize: vi.fn().mockResolvedValue(0), + GetEmbeddedFiles: vi.fn().mockResolvedValue({ files: [] }), + GetEmbeddedFileBytes: vi.fn(), + GetDocumentMetadata: vi.fn().mockResolvedValue({ info: {}, xmp: '' }), + SaveBytesToFile: vi.fn(), + Validate: vi.fn().mockResolvedValue({ problems: [], rules: [], scope: '' }), +})); + +const catalogNode = { + id: 'root', + label: 'Catalog', + rawKey: '', + nodeType: 'dict', + valueType: '', + hasChildren: true, + childCount: 1, + iconHint: 'catalog', + error: '', +}; + +const identicalDiff = { + summary: { + added: 0, + removed: 0, + changed: 0, + pageCountLeft: 1, + pageCountRight: 1, + versionChanged: false, + encryptionChanged: false, + infoChanged: false, + xmpChanged: false, + }, + root: { path: '/Root', status: 'unchanged', kind: 'dict', changedKeys: [], leftSummary: '', rightSummary: '', children: [] }, +}; + +let state: AppState; +let dispatch: Dispatch; +function Probe() { + state = useAppState(); + dispatch = useAppDispatch(); + return null; +} + +function mount() { + render( + + + + , + ); +} + +let n = 0; +function ids() { + n += 1; + return { a: `a${n}`, b: `b${n}`, disk: `disk${n}`, pathA: `/docs/${n}/a.pdf`, pathB: `/docs/${n}/b.pdf` }; +} + +function open(tabId: string, filePath: string) { + act(() => + dispatch({ + type: 'OPEN_DOCUMENT', + payload: { tabId, fileName: filePath.split('/').pop() ?? '', filePath, pageCount: 1, rootNode: catalogNode, rootChildren: [] }, + }), + ); +} + +const at = (h: number, m: number) => new Date(2026, 0, 12, h, m).getTime(); +const MB = 1024 * 1024; + +function diskStatusOf(state: string, diskSize = 4096) { + return { state, loadedModTime: at(14, 0), diskModTime: state === 'missing' ? 0 : at(14, 5), diskSize: state === 'missing' ? 0 : diskSize }; +} + +function markChanged(tabId: string) { + act(() => applyDiskStatusEvent({ tabId, seq: 1, ...diskStatusOf('changed') })); +} + +function emit(name: string) { + act(() => { + for (const h of [...(handlers[name] ?? [])]) h({ data: {} }); + }); +} + +function clickCompare() { + fireEvent.click(within(screen.getByTestId('stale-note')).getByRole('button', { name: 'Compare' })); +} + +function diskDoc(tabId: string, filePath: string) { + return { tabId, fileName: filePath.split('/').pop(), filePath, pageCount: 2, fileSize: 4096, error: '' }; +} + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +beforeEach(() => { + for (const key of Object.keys(handlers)) delete handlers[key]; + for (const fn of Object.values(b)) fn.mockReset(); + b.CloseDocument.mockResolvedValue(undefined); + b.GetSignatures.mockResolvedValue([]); + b.GetTreeRoot.mockResolvedValue(catalogNode); + b.GetChildren.mockResolvedValue([]); + b.DiffDocuments.mockResolvedValue(identicalDiff); + resetDiskStatusStore(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 0, 12, 18, 0)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('compare with the file on disk', () => { + test('the stale note Compare opens Diff with the disk copy on the right, labelled with its disk time', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + markChanged(t.a); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed')); + b.OpenFile.mockResolvedValue(diskDoc(t.disk, t.pathA)); + + clickCompare(); + + await waitFor(() => expect(b.DiffDocuments).toHaveBeenCalledWith(t.a, t.disk)); + expect(b.GetDiskStatus).toHaveBeenCalledWith(t.a); + expect(b.OpenFile).toHaveBeenCalledWith(t.pathA); + expect(screen.getByTestId('detail-tab-diff')).toHaveAttribute('aria-selected', 'true'); + expect(await screen.findByText(/On disk \(14:05\)/)).toBeInTheDocument(); + expect(state.tabs.map((x) => x.tabId)).toEqual([t.a]); + }); + + test('the compare menu event does the same for a tab that is not stale', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('current')); + b.OpenFile.mockResolvedValue(diskDoc(t.disk, t.pathA)); + + emit('document:compare-active'); + + await waitFor(() => expect(b.DiffDocuments).toHaveBeenCalledWith(t.a, t.disk)); + expect(screen.getByTestId('detail-tab-diff')).toHaveAttribute('aria-selected', 'true'); + }); + + test('the compare menu event with no document open does nothing', () => { + mount(); + emit('document:compare-active'); + expect(b.GetDiskStatus).not.toHaveBeenCalled(); + expect(b.OpenFile).not.toHaveBeenCalled(); + }); + + test('over 500 MB asks first; Cancel parses nothing', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + markChanged(t.a); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed', 600 * MB)); + + clickCompare(); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent(/a\.pdf is .+ on disk\. Comparing loads a second copy into memory\. Continue\?/); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(b.OpenFile).not.toHaveBeenCalled(); + }); + + test('over 500 MB: Continue parses the disk copy', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + markChanged(t.a); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed', 600 * MB)); + b.OpenFile.mockResolvedValue(diskDoc(t.disk, t.pathA)); + + clickCompare(); + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(b.OpenFile).toHaveBeenCalledWith(t.pathA)); + }); + + test('exactly 500 MB does not ask', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + markChanged(t.a); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed', 500 * MB)); + b.OpenFile.mockResolvedValue(diskDoc(t.disk, t.pathA)); + + clickCompare(); + + await waitFor(() => expect(b.OpenFile).toHaveBeenCalledWith(t.pathA)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + test('a missing file shows the missing message in Diff and parses nothing', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('missing')); + + emit('document:compare-active'); + + expect(await screen.findByTestId('diff-pick-error')).toHaveTextContent('File no longer found at this path'); + expect(screen.getByTestId('detail-tab-diff')).toHaveAttribute('aria-selected', 'true'); + expect(b.OpenFile).not.toHaveBeenCalled(); + }); + + test('a parse error, busy included, goes to the Diff pick error slot', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed')); + b.OpenFile.mockRejectedValue(new Error(JSON.stringify({ message: 'file is busy: sharing violation', cause: {}, kind: 'RuntimeError' }))); + + emit('document:compare-active'); + + expect(await screen.findByTestId('diff-pick-error')).toHaveTextContent('file is busy'); + expect(b.DiffDocuments).not.toHaveBeenCalled(); + }); + + test('switching tabs while the disk copy parses closes it', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + open(t.b, t.pathB); + act(() => dispatch({ type: 'ACTIVATE_TAB', payload: { tabId: t.a } })); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed')); + const pending = deferred(); + b.OpenFile.mockReturnValue(pending.promise); + + emit('document:compare-active'); + await waitFor(() => expect(b.OpenFile).toHaveBeenCalled()); + act(() => dispatch({ type: 'ACTIVATE_TAB', payload: { tabId: t.b } })); + await act(async () => { + pending.resolve(diskDoc(t.disk, t.pathA)); + }); + + await waitFor(() => expect(b.CloseDocument).toHaveBeenCalledWith(t.disk)); + expect(b.DiffDocuments).not.toHaveBeenCalled(); + }); + + test('a disk check that lands after a tab switch still marks the tab and parses nothing', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + open(t.b, t.pathB); + act(() => dispatch({ type: 'ACTIVATE_TAB', payload: { tabId: t.a } })); + const check = deferred(); + b.GetDiskStatus.mockReturnValue(check.promise); + + emit('document:compare-active'); + await waitFor(() => expect(b.GetDiskStatus).toHaveBeenCalledWith(t.a)); + act(() => dispatch({ type: 'ACTIVATE_TAB', payload: { tabId: t.b } })); + await act(async () => { + check.resolve(diskStatusOf('changed')); + }); + + expect(readDiskStatus(t.a)?.state).toBe('changed'); + expect(b.OpenFile).not.toHaveBeenCalled(); + }); + + test('a second Compare while the disk copy parses starts no second parse', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed')); + const pending = deferred(); + b.OpenFile.mockReturnValue(pending.promise); + + emit('document:compare-active'); + await waitFor(() => expect(b.OpenFile).toHaveBeenCalledTimes(1)); + emit('document:compare-active'); + await waitFor(() => expect(b.GetDiskStatus).toHaveBeenCalledTimes(2)); + await act(async () => { + await Promise.resolve(); + }); + expect(b.OpenFile).toHaveBeenCalledTimes(1); + + await act(async () => { + pending.resolve(diskDoc(t.disk, t.pathA)); + }); + await waitFor(() => expect(b.DiffDocuments).toHaveBeenCalledWith(t.a, t.disk)); + }); + + test('Close diff frees the disk copy', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed')); + b.OpenFile.mockResolvedValue(diskDoc(t.disk, t.pathA)); + emit('document:compare-active'); + await waitFor(() => expect(b.DiffDocuments).toHaveBeenCalled()); + + fireEvent.click(await screen.findByTestId('diff-close')); + + await waitFor(() => expect(b.CloseDocument).toHaveBeenCalledWith(t.disk)); + }); +}); + +describe('reload from the Diff header', () => { + test('promotes the compared copy into the tab without parsing again', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + open(t.b, t.pathB); + act(() => dispatch({ type: 'ACTIVATE_TAB', payload: { tabId: t.a } })); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed')); + b.OpenFile.mockResolvedValue(diskDoc(t.disk, t.pathA)); + emit('document:compare-active'); + await waitFor(() => expect(b.DiffDocuments).toHaveBeenCalledWith(t.a, t.disk)); + b.GetDiskStatus.mockClear(); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('current')); + + fireEvent.click(await screen.findByTestId('diff-reload')); + + await waitFor(() => expect(state.activeTabId).toBe(t.disk)); + expect(state.tabs.map((x) => x.tabId)).toEqual([t.disk, t.b]); + expect(state.tabs[0].pageCount).toBe(2); + expect(b.OpenFile).toHaveBeenCalledTimes(1); + expect(b.GetTreeRoot).toHaveBeenCalledWith(t.disk); + expect(b.GetChildren).toHaveBeenCalledWith(t.disk, 'root'); + expect(b.CloseDocument).toHaveBeenCalledWith(t.a); + await waitFor(() => expect(b.GetDiskStatus).toHaveBeenCalledWith(t.disk)); + expect(b.GetDiskStatus).toHaveBeenCalledTimes(1); + // Let any cleanup effects run before asserting the promoted id stays open. + await act(async () => { + await Promise.resolve(); + }); + expect(b.CloseDocument).not.toHaveBeenCalledWith(t.disk); + }); + + test('Close diff while the promotion fetches the tree leaves the tab alone', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed')); + b.OpenFile.mockResolvedValue(diskDoc(t.disk, t.pathA)); + emit('document:compare-active'); + await waitFor(() => expect(b.DiffDocuments).toHaveBeenCalledWith(t.a, t.disk)); + const tree = deferred(); + b.GetTreeRoot.mockReturnValue(tree.promise); + + fireEvent.click(await screen.findByTestId('diff-reload')); + fireEvent.click(await screen.findByTestId('diff-close')); + await waitFor(() => expect(b.CloseDocument).toHaveBeenCalledWith(t.disk)); + await act(async () => { + tree.resolve(catalogNode); + }); + + expect(state.tabs.map((x) => x.tabId)).toEqual([t.a]); + expect(state.activeTabId).toBe(t.a); + expect(state.documentError).toBeNull(); + expect(b.CloseDocument).not.toHaveBeenCalledWith(t.a); + }); + + test('a failed promotion sets only the error banner', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed')); + b.OpenFile.mockResolvedValue(diskDoc(t.disk, t.pathA)); + emit('document:compare-active'); + await waitFor(() => expect(b.DiffDocuments).toHaveBeenCalledWith(t.a, t.disk)); + act(() => dispatch({ type: 'SET_DOCUMENT_WARNING', payload: { message: 'earlier warning' } })); + act(() => dispatch({ type: 'OPENING_START', payload: { fileName: 'x.pdf' } })); + b.GetTreeRoot.mockRejectedValue(new Error('malformed PDF: bad xref')); + + fireEvent.click(await screen.findByTestId('diff-reload')); + + await waitFor(() => expect(state.documentError).toBe('Could not reload a.pdf: file is damaged or not a valid PDF')); + expect(state.documentWarning).toBe('earlier warning'); + expect(state.isOpening).toBe(true); + expect(state.tabs.map((x) => x.tabId)).toEqual([t.a]); + }); + + test('the promoted copy\'s parse warning replaces the banner in one dispatch', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + b.GetDiskStatus.mockResolvedValue(diskStatusOf('changed')); + b.OpenFile.mockResolvedValue({ ...diskDoc(t.disk, t.pathA), error: 'This PDF has structural errors. Some objects may display incorrectly.' }); + emit('document:compare-active'); + await waitFor(() => expect(b.DiffDocuments).toHaveBeenCalledWith(t.a, t.disk)); + + fireEvent.click(await screen.findByTestId('diff-reload')); + + await waitFor(() => expect(state.activeTabId).toBe(t.disk)); + expect(state.documentWarning).toBe('This PDF has structural errors. Some objects may display incorrectly.'); + }); + + test('a file picked with Compare with another PDF gets no header Reload', async () => { + const t = ids(); + mount(); + open(t.a, t.pathA); + b.OpenFileDialog.mockResolvedValue(['/elsewhere/other.pdf']); + b.OpenFile.mockResolvedValue(diskDoc(t.disk, '/elsewhere/other.pdf')); + + fireEvent.click(screen.getByTestId('detail-tab-diff')); + fireEvent.click(await screen.findByTestId('diff-pick-file')); + + await waitFor(() => expect(b.DiffDocuments).toHaveBeenCalledWith(t.a, t.disk)); + expect(screen.queryByTestId('diff-reload')).not.toBeInTheDocument(); + expect(screen.queryByText(/On disk \(/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/DetailPanel.staleNote.test.tsx b/frontend/src/components/DetailPanel.staleNote.test.tsx new file mode 100644 index 0000000..ba8cfce --- /dev/null +++ b/frontend/src/components/DetailPanel.staleNote.test.tsx @@ -0,0 +1,327 @@ +/** + * Stale note in the detail panel's inner-tab row, and signatures refused + * because the file changed on disk. + * + * The note shows only for the active tab while it is changed or missing. It + * sits next to the inner tab list, never inside it, so the tab triggers do not + * move. Changed: "Document updated" with Reload and Compare buttons. Missing: + * "File not found" with no buttons. + * + * Test ids: stale-note (the note), detail-tab-list (existing tab list). + * + * Run: cd frontend && npx vitest run src/components/DetailPanel.staleNote.test.tsx + */ +import { render, screen, act, waitFor, within, fireEvent } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Dispatch } from 'react'; +import { AppProvider, useAppState, useAppDispatch, type AppAction, type AppState } from '../hooks/useDocumentState'; +import { applyDiskStatusEvent, readDiskStatus, resetDiskStatusStore } from '../lib/diskStatusStore'; +import { DetailPanel } from './DetailPanel'; + +vi.mock('allotment', () => { + function Pane({ children }: { children: React.ReactNode }) { + return
{children}
; + } + function Allotment({ children }: { children: React.ReactNode }) { + return
{children}
; + } + Allotment.Pane = Pane; + return { Allotment }; +}); +vi.mock('allotment/dist/style.css', () => ({})); + +vi.mock('@wailsio/runtime', async (importOriginal) => ({ + ...(await importOriginal()), + Events: { + On: vi.fn(() => vi.fn()), + Emit: vi.fn(), + }, +})); + +const b = vi.hoisted(() => ({ + OpenFile: vi.fn(), + GetTreeRoot: vi.fn(), + GetChildren: vi.fn(), + CloseDocument: vi.fn(), + GetSignatures: vi.fn(), + GetDiskStatus: vi.fn(), + DiffDocuments: vi.fn(), + OpenFileDialog: vi.fn(), +})); + +vi.mock('../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ + OpenFile: (...a: unknown[]) => b.OpenFile(...a), + GetTreeRoot: (...a: unknown[]) => b.GetTreeRoot(...a), + GetChildren: (...a: unknown[]) => b.GetChildren(...a), + CloseDocument: (...a: unknown[]) => b.CloseDocument(...a), + GetSignatures: (...a: unknown[]) => b.GetSignatures(...a), + GetDiskStatus: (...a: unknown[]) => b.GetDiskStatus(...a), + DiffDocuments: (...a: unknown[]) => b.DiffDocuments(...a), + OpenFileDialog: (...a: unknown[]) => b.OpenFileDialog(...a), + GetObjectDetail: vi.fn().mockResolvedValue(null), + GetContentStream: vi.fn(), + GetImageData: vi.fn(), + DescribeImage: vi.fn().mockResolvedValue({ width: 0, height: 0, colorSpace: '', estimatedBytes: 0 }), + SaveImageToFile: vi.fn().mockResolvedValue(''), + GetReverseRefs: vi.fn().mockResolvedValue([]), + GetFontView: vi.fn(), + GetXRefTable: vi.fn().mockResolvedValue({ tabId: 'x', entries: [] }), + GetPlainText: vi.fn().mockResolvedValue({ tabId: 'x', content: '', totalBytes: 0 }), + GetPlainTextSize: vi.fn().mockResolvedValue(0), + GetEmbeddedFiles: vi.fn().mockResolvedValue({ files: [] }), + GetEmbeddedFileBytes: vi.fn(), + GetDocumentMetadata: vi.fn().mockResolvedValue({ info: {}, xmp: '' }), + SaveBytesToFile: vi.fn(), + Validate: vi.fn().mockResolvedValue({ problems: [], rules: [], scope: '' }), +})); + +const catalogNode = { + id: 'root', + label: 'Catalog', + rawKey: '', + nodeType: 'dict', + valueType: '', + hasChildren: true, + childCount: 1, + iconHint: 'catalog', + error: '', +}; + +let state: AppState; +let dispatch: Dispatch; +function Probe() { + state = useAppState(); + dispatch = useAppDispatch(); + return null; +} + +function mount() { + render( + + + + , + ); +} + +let n = 0; +function paths() { + n += 1; + return { a: `a${n}`, b: `b${n}`, fresh: `fresh${n}`, pathA: `/docs/${n}/a.pdf`, pathB: `/docs/${n}/b.pdf` }; +} + +function open(tabId: string, filePath: string) { + act(() => + dispatch({ + type: 'OPEN_DOCUMENT', + payload: { tabId, fileName: filePath.split('/').pop() ?? '', filePath, pageCount: 1, rootNode: catalogNode, rootChildren: [] }, + }), + ); +} + +const at = (h: number, m: number) => new Date(2026, 0, 12, h, m).getTime(); + +function mark(tabId: string, seq: number, state: 'changed' | 'missing' | 'current') { + act(() => + applyDiskStatusEvent({ + tabId, + seq, + state, + loadedModTime: at(14, 0), + diskModTime: state === 'missing' ? 0 : at(14, 5), + diskSize: state === 'missing' ? 0 : 4096, + }), + ); +} + +function envelope(message: string) { + return new Error(JSON.stringify({ message, cause: {}, kind: 'RuntimeError' })); +} + +function triggerNames(): string[] { + return within(screen.getByTestId('detail-tab-list')) + .getAllByRole('tab') + .map((el) => el.textContent ?? ''); +} + +beforeEach(() => { + for (const fn of Object.values(b)) fn.mockReset(); + b.CloseDocument.mockResolvedValue(undefined); + b.GetSignatures.mockResolvedValue([]); + b.GetTreeRoot.mockResolvedValue(catalogNode); + b.GetChildren.mockResolvedValue([]); + resetDiskStatusStore(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 0, 12, 18, 0)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('stale note', () => { + test('changed: shows Document updated with Reload and Compare buttons and the version tooltip', () => { + const p = paths(); + mount(); + open(p.a, p.pathA); + mark(p.a, 1, 'changed'); + + const note = screen.getByTestId('stale-note'); + expect(note).toHaveTextContent('Document updated'); + const buttons = within(note).getAllByRole('button'); + expect(buttons.map((x) => x.textContent)).toEqual(['Reload', 'Compare']); + for (const btn of buttons) expect(btn.tagName).toBe('BUTTON'); + const label = within(note).getByText('Document updated'); + expect(label.closest('[title]')).toHaveAttribute('title', 'Showing version from 14:00. Changed on disk 14:05.'); + }); + + test('missing: shows File not found with no buttons', () => { + const p = paths(); + mount(); + open(p.a, p.pathA); + mark(p.a, 1, 'missing'); + + const note = screen.getByTestId('stale-note'); + expect(note).toHaveTextContent('File not found'); + expect(within(note).queryAllByRole('button')).toHaveLength(0); + expect(within(note).getByText('File not found').closest('[title]')).toHaveAttribute('title', 'File no longer found at this path'); + }); + + test('absent for a current active tab, and for a stale background tab', () => { + const p = paths(); + mount(); + open(p.a, p.pathA); + open(p.b, p.pathB); + expect(screen.queryByTestId('stale-note')).not.toBeInTheDocument(); + mark(p.a, 1, 'changed'); + expect(screen.queryByTestId('stale-note')).not.toBeInTheDocument(); + }); + + test('sits beside the tab list, outside it, and leaves the tab triggers unchanged', () => { + const p = paths(); + mount(); + open(p.a, p.pathA); + const before = triggerNames(); + + mark(p.a, 1, 'changed'); + + const note = screen.getByTestId('stale-note'); + const list = screen.getByTestId('detail-tab-list'); + expect(list.contains(note)).toBe(false); + expect(note.closest('[role="tablist"]')).toBeNull(); + expect(note.parentElement).toBe(list.parentElement); + expect(triggerNames()).toEqual(before); + }); + + test('buttons keep a 24px minimum hit area and the row keeps its classes', () => { + const p = paths(); + mount(); + open(p.a, p.pathA); + const list = screen.getByTestId('detail-tab-list'); + const row = list.parentElement as HTMLElement; + const rowBefore = row.className; + const listBefore = list.className; + + mark(p.a, 1, 'changed'); + + const note = screen.getByTestId('stale-note'); + expect(row.className).toBe(rowBefore); + expect(list.className).toBe(listBefore); + // jsdom has no layout: the note adds no vertical size of its own and the + // buttons carry the 24px minimums. + for (const el of [note, ...Array.from(note.querySelectorAll('*'))]) { + expect(el.className).not.toMatch(/(^|\s)(py-|pt-|pb-|my-|mt-|mb-|h-|min-h-(?!\[24px\]))/); + } + for (const btn of within(note).getAllByRole('button')) { + expect(btn.className).toMatch(/(^|\s)min-h-\[24px\](\s|$)/); + expect(btn.className).toMatch(/(^|\s)min-w-\[24px\](\s|$)/); + } + }); + + test('UI strings are ASCII and use no character separators', () => { + const p = paths(); + mount(); + open(p.a, p.pathA); + mark(p.a, 1, 'changed'); + const text = screen.getByTestId('stale-note').textContent ?? ''; + expect(text).toMatch(/^[\x20-\x7e]*$/); + expect(text).not.toMatch(/[|/]/); + }); + + test('Reload reloads the active tab and the note goes away', async () => { + const p = paths(); + mount(); + open(p.a, p.pathA); + mark(p.a, 1, 'changed'); + b.OpenFile.mockResolvedValue({ tabId: p.fresh, fileName: 'a.pdf', filePath: p.pathA, pageCount: 3, fileSize: 1, error: '' }); + + fireEvent.click(within(screen.getByTestId('stale-note')).getByRole('button', { name: 'Reload' })); + + await waitFor(() => expect(state.activeTabId).toBe(p.fresh)); + expect(b.OpenFile).toHaveBeenCalledWith(p.pathA); + expect(b.CloseDocument).toHaveBeenCalledWith(p.a); + expect(state.tabs.map((t) => t.tabId)).toEqual([p.fresh]); + expect(screen.queryByTestId('stale-note')).not.toBeInTheDocument(); + }); +}); + +function signedEntry(holeCheckError: string) { + return { + fieldName: 'Signature1', + fieldNodeId: '4 0 R', + signed: true, + type: 'signature', + subFilter: 'adbe.pkcs7.detached', + notes: ['trust not verified - structural decomposition only'], + certificates: [], + byteRange: [0, 100, 200, 50], + coversWholeFile: true, + trailingGap: 0, + holeMatchesContents: false, + coverageError: '', + holeCheckError, + }; +} + +describe('signatures on a file that changed on disk', () => { + test('keeps every signature, notes the hole as not checked and marks the tab', async () => { + const p = paths(); + b.GetSignatures.mockResolvedValue([signedEntry('file changed on disk since it was opened')]); + b.GetDiskStatus.mockResolvedValue({ state: 'changed', loadedModTime: at(14, 0), diskModTime: at(14, 5), diskSize: 4096 }); + mount(); + open(p.a, p.pathA); + + const sigTab = await screen.findByRole('tab', { name: /signatures/i }); + await waitFor(() => expect(b.GetDiskStatus).toHaveBeenCalledWith(p.a)); + await waitFor(() => expect(readDiskStatus(p.a)?.state).toBe('changed')); + expect(screen.getByTestId('stale-note')).toBeInTheDocument(); + + fireEvent.click(sigTab); + expect(await screen.findByText('not checked: File changed on disk. Reload to view.')).toBeInTheDocument(); + expect(screen.getByText('adbe.pkcs7.detached')).toBeInTheDocument(); + }); + + test('a file locked by another app notes the hole as busy and checks nothing', async () => { + const p = paths(); + b.GetSignatures.mockResolvedValue([signedEntry('file is busy: another process has it open')]); + mount(); + open(p.a, p.pathA); + + const sigTab = await screen.findByRole('tab', { name: /signatures/i }); + fireEvent.click(sigTab); + expect(await screen.findByText('not checked: File is in use by another app. Reload to try again.')).toBeInTheDocument(); + expect(b.GetDiskStatus).not.toHaveBeenCalled(); + }); + + test('another GetSignatures error still hides the tab and checks nothing', async () => { + const p = paths(); + b.GetSignatures.mockRejectedValue(envelope('malformed PDF: bad acroform')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount(); + open(p.a, p.pathA); + + await waitFor(() => expect(b.GetSignatures).toHaveBeenCalledWith(p.a)); + expect(screen.queryByRole('tab', { name: /signatures/i })).not.toBeInTheDocument(); + expect(b.GetDiskStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index c08a0ee..ee69202 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -6,10 +6,19 @@ */ import { Fragment, useState, useEffect, useCallback, useMemo, useRef, memo } from 'react'; import * as Tabs from '@radix-ui/react-tabs'; -import { GetObjectDetail, GetContentStream, GetImageData, DescribeImage, SaveImageToFile, GetReverseRefs, GetFontView, GetSignatures, OpenFile, OpenFileDialog, CloseDocument } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; -import { ContentStreamData, ImageData as PdfImageData } from '../../bindings/unidoc-pdf-debugger/internal/pdfcore/models.js'; -import { useAppState, useAppDispatch } from '../hooks/useDocumentState'; +import * as Dialog from '@radix-ui/react-dialog'; +import { Events } from '@wailsio/runtime'; +import { GetObjectDetail, GetContentStream, GetImageData, DescribeImage, SaveImageToFile, GetReverseRefs, GetFontView, GetSignatures, OpenFile, OpenFileDialog, CloseDocument, GetTreeRoot, GetChildren } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; +import { ContentStreamData, DocumentInfo, ImageData as PdfImageData } from '../../bindings/unidoc-pdf-debugger/internal/pdfcore/models.js'; +import { useAppState, useAppDispatch, type TreeNode } from '../hooks/useDocumentState'; import { extractErrorMessage } from '../lib/extractErrorMessage'; +import { FILE_MISSING_MESSAGE, isFileChangedMessage } from '../lib/fileChange'; +import { checkDiskStatus, markTabFromDiskCheck } from '../lib/diskCheck'; +import { formatTimestamp } from '../lib/formatTimestamp'; +import { dispatchOpenedDocument } from '../lib/openedDocuments'; +import { reloadActiveTab, reloadedMessage, reloadFailureReason } from '../lib/reloadDocument'; +import { showReloadToast } from './ReloadToast'; +import { StaleNote } from './StaleNote'; import { IMAGE_WARN_THRESHOLD_BYTES, IMAGE_SLOW_THRESHOLD_SECONDS } from '../lib/imageConstants'; import { formatBytes } from '../lib/formatBytes'; import { escapeDisplayValue } from '../lib/escapeDisplayValue'; @@ -50,6 +59,25 @@ const INDIRECT_NODE_RE = /^obj:\d+:\d+$/; */ const REV_REFS_UNAVAILABLE_MARKER = 'reverse-ref index unavailable'; +/** Disk size above which Compare asks before loading a second copy. */ +const COMPARE_CONFIRM_BYTES = 500 * 1024 * 1024; + +/** A Diff right side parsed by Compare from the active tab's own file. */ +interface DiskCopy { + tabId: string; + info: DocumentInfo; + diskModTime: number; +} + +/** A Compare waiting for confirmation because the file is large. */ +interface CompareConfirm { + tabId: string; + filePath: string; + fileName: string; + diskSize: number; + diskModTime: number; +} + /** Maps PDF object type to a human-readable header label. */ const TYPE_LABEL_MAP: Record = { dict: 'Properties', @@ -164,6 +192,19 @@ function DetailPanelInner() { // stale comparison from a previously-active tab. const [diffRightTabId, setDiffRightTabId] = useState(null); const [diffError, setDiffError] = useState(null); + // Set when the right side came from Compare with File on Disk. It applies + // only while its tabId is the current right side, so every path that + // replaces or clears diffRightTabId drops it too. + const [diskCopy, setDiskCopy] = useState(null); + const rightDiskCopy = diskCopy && diskCopy.tabId === diffRightTabId ? diskCopy : null; + const [compareConfirm, setCompareConfirm] = useState(null); + // The right-side tab id promoted to the app tab by the Diff header Reload. + // The cleanup effect skips closing it: the tab owns it now. + const promotedRightRef = useRef(null); + const promotingRef = useRef(false); + // True while Compare's OpenFile runs, so a second Compare does not parse + // another copy whose tab id could be overwritten before it is ever shown. + const comparingRef = useRef(false); // Set by Close diff so the compare button takes focus once the picker // replaces the unmounted Close diff button. Other paths that clear the // comparison (document switch) leave focus alone. @@ -187,6 +228,10 @@ function DetailPanelInner() { // it against a document that is no longer the left-hand side of the diff. const activeTabIdRef = useRef(activeTabId); activeTabIdRef.current = activeTabId; + const tabsRef = useRef(tabs); + tabsRef.current = tabs; + const diffRightTabIdRef = useRef(diffRightTabId); + diffRightTabIdRef.current = diffRightTabId; useEffect(() => { setDetailView('object'); @@ -195,6 +240,7 @@ function DetailPanelInner() { setSignatures(null); setDiffRightTabId(null); setDiffError(null); + setCompareConfirm(null); }, [activeTabId]); // The comparison document is opened backend-only (not an app tab), @@ -203,6 +249,10 @@ function DetailPanelInner() { useEffect(() => { if (!diffRightTabId) return; return () => { + if (promotedRightRef.current === diffRightTabId) { + promotedRightRef.current = null; + return; + } CloseDocument(diffRightTabId).catch(() => { /* best-effort cleanup; a failed close must not break the UI */ }); @@ -217,14 +267,18 @@ function DetailPanelInner() { // One signature fetch per document tab. The result is passed // down to SignaturesView via the data prop so the view never issues a - // second fetch. A fetch failure hides the tab (empty list) and logs. + // second fetch. A fetch failure hides the tab (empty list) and logs. A + // signature whose hole could not be checked because the file changed marks + // the tab from a fresh disk check. useEffect(() => { if (!activeTabId) return; let cancelled = false; GetSignatures(activeTabId) .then((result: unknown) => { if (cancelled) return; - setSignatures((Array.isArray(result) ? result : []) as SignatureEntryData[]); + const list = (Array.isArray(result) ? result : []) as SignatureEntryData[]; + setSignatures(list); + if (list.some((sig) => isFileChangedMessage(sig.holeCheckError ?? ''))) markTabFromDiskCheck(activeTabId); }) .catch((err: unknown) => { if (cancelled) return; @@ -631,6 +685,128 @@ function DetailPanelInner() { .catch((err: unknown) => setDiffError(extractErrorMessage(err))); }, [activeTabId]); + /** + * Reloads the active tab from disk (the stale note's Reload). Shares the + * reload flow with the menu item and F5. + */ + const handleReload = useCallback(() => { + reloadActiveTab(dispatch, () => tabsRef.current, activeTabIdRef.current); + }, [dispatch]); + + /** + * Parses the active tab's file again backend-only and shows it as the Diff + * right side. The same discard rule as a picked file applies: if the active + * tab changed or the panel unmounted meanwhile, the new document is closed. + */ + const runCompare = useCallback((req: CompareConfirm) => { + if (comparingRef.current) return; + comparingRef.current = true; + setDiffError(null); + OpenFile(req.filePath) + .then((info: unknown) => { + const di = info as DocumentInfo | null; + if (!di?.tabId) return; + if (mountedRef.current && activeTabIdRef.current === req.tabId) { + setDiskCopy({ tabId: di.tabId, info: di, diskModTime: req.diskModTime }); + setDiffRightTabId(di.tabId); + setDetailView('diff'); + } else { + CloseDocument(di.tabId).catch(() => {}); + } + }) + .catch((err: unknown) => { + if (!mountedRef.current || activeTabIdRef.current !== req.tabId) return; + setDiffRightTabId(null); + setDiffError(extractErrorMessage(err)); + setDetailView('diff'); + }) + .finally(() => { + comparingRef.current = false; + }); + }, []); + + /** + * Compare with File on Disk for the active tab. Checks the disk first: a + * missing file parses nothing, and a file over the size limit asks before a + * second copy is loaded. + */ + const handleCompare = useCallback(() => { + const tabId = activeTabIdRef.current; + const tab = tabsRef.current.find((t) => t.tabId === tabId); + if (!tabId || !tab?.filePath) return; + // checkDiskStatus stores the result even when the compare itself is + // dropped below. + checkDiskStatus(tabId) + .then((st) => { + if (!st) return; + if (!mountedRef.current || activeTabIdRef.current !== tabId) return; + if (st.state === 'missing') { + setDiffRightTabId(null); + setDiffError(FILE_MISSING_MESSAGE); + setDetailView('diff'); + return; + } + const req = { tabId, filePath: tab.filePath, fileName: tab.fileName, diskSize: st.diskSize, diskModTime: st.diskModTime }; + if (st.diskSize > COMPARE_CONFIRM_BYTES) { + setCompareConfirm(req); + return; + } + runCompare(req); + }) + .catch((err: unknown) => { + if (!mountedRef.current || activeTabIdRef.current !== tabId) return; + setDiffRightTabId(null); + setDiffError(extractErrorMessage(err)); + setDetailView('diff'); + }); + }, [runCompare]); + + // File > Compare with File on Disk. DetailPanel owns the Diff state, so it + // subscribes here; with no document open the handler does nothing. + const handleCompareRef = useRef(handleCompare); + handleCompareRef.current = handleCompare; + useEffect(() => Events.On('document:compare-active', () => handleCompareRef.current()), []); + + /** + * Diff header Reload for a disk copy: promotes the compared document to the + * tab without parsing again, so the tab shows exactly the compared version. + * The tab's old document is closed by the in-place replace; the promoted id + * is skipped by the right-side cleanup and seeded in the disk-status store. + */ + const handlePromote = useCallback(() => { + const copy = rightDiskCopy; + const leftId = activeTabIdRef.current; + if (!copy || !leftId || promotingRef.current) return; + promotingRef.current = true; + const { info } = copy; + Promise.all([GetTreeRoot(copy.tabId), GetChildren(copy.tabId, 'root')]) + .then(([rootNode, rootChildren]) => { + // A right side closed or replaced meanwhile has had its document + // closed by the cleanup effect, so it is not promoted. + if (!mountedRef.current || activeTabIdRef.current !== leftId || diffRightTabIdRef.current !== copy.tabId) return; + promotedRightRef.current = copy.tabId; + dispatchOpenedDocument(dispatch, tabsRef.current, { + tabId: copy.tabId, + fileName: info.fileName, + filePath: info.filePath, + pageCount: info.pageCount, + rootNode, + rootChildren: rootChildren as TreeNode[] | null, + reload: true, + warning: info.error || null, + }); + showReloadToast(reloadedMessage(info.fileName)); + markTabFromDiskCheck(copy.tabId); + }) + .catch((err: unknown) => { + if (!mountedRef.current || diffRightTabIdRef.current !== copy.tabId) return; + dispatch({ type: 'SET_DOCUMENT_ERROR', payload: { message: `Could not reload ${info.fileName}: ${reloadFailureReason(extractErrorMessage(err))}`, reload: true } }); + }) + .finally(() => { + promotingRef.current = false; + }); + }, [rightDiskCopy, dispatch]); + // --- Object-tab find (Model B: scoped to the active tab). Derives a linear // match source from the rendered object text (keys + values) and reuses the // shared find machinery. The window listener is gated on the Object tab being @@ -774,9 +950,13 @@ function DetailPanelInner() { activationMode="manual" className="h-full flex flex-col" > + {/* The stale note is a sibling of the tab list (a tablist holds only + tabs). The row takes the list's border and background; the note + fills spare space and truncates first, so the triggers never move. */} +
)} + +
{ focusPickAfterCloseRef.current = true; setDiffRightTabId(null); @@ -1239,6 +1423,43 @@ function DetailPanelInner() { )} + { if (!open) setCompareConfirm(null); }}> + + + + Compare with File on Disk + + {compareConfirm + ? `${compareConfirm.fileName} is ${formatBytes(compareConfirm.diskSize)} on disk. Comparing loads a second copy into memory. Continue?` + : ''} + +
+ + + + +
+
+
+
); } diff --git a/frontend/src/components/DiffView.tsx b/frontend/src/components/DiffView.tsx index ee10e42..398f1ff 100644 --- a/frontend/src/components/DiffView.tsx +++ b/frontend/src/components/DiffView.tsx @@ -59,6 +59,10 @@ export interface DiffViewProps { active: boolean; /** Ends this comparison so another document can be picked. */ onClose: () => void; + /** Label naming the right side, e.g. `On disk (14:05)`. */ + rightLabel?: string; + /** When set, the header shows Reload, which makes the right side the tab's document. */ + onReload?: () => void; } /** Reports whether a node or any descendant is not "unchanged", or is a @@ -149,7 +153,7 @@ function flatten(node: DiffNodeData, depth: number, expanded: Set, out: * one selection; the LEFT pane also owns expand/collapse. A Close diff button * in every state hands control back through onClose. */ -export function DiffView({ leftTabId, rightTabId, active, onClose }: DiffViewProps) { +export function DiffView({ leftTabId, rightTabId, active, onClose, rightLabel, onReload }: DiffViewProps) { const [result, setResult] = useState(null); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>(new Set()); @@ -409,7 +413,24 @@ export function DiffView({ leftTabId, rightTabId, active, onClose }: DiffViewPro > Next change -
{closeButton}
+
+ {rightLabel && ( + + Right: {rightLabel} + + )} + {onReload && ( + + )} + {closeButton} +
diff --git a/frontend/src/components/DiskChangeAnnouncer.tsx b/frontend/src/components/DiskChangeAnnouncer.tsx new file mode 100644 index 0000000..9e178d9 --- /dev/null +++ b/frontend/src/components/DiskChangeAnnouncer.tsx @@ -0,0 +1,44 @@ +/** + * @file Visually hidden polite live region that announces a file changed on + * disk once per new change. Driven by its own module store so an + * announcement never dispatches to AppState. + */ +import { useEffect, useSyncExternalStore } from 'react'; + +type Listener = () => void; +const listeners = new Set(); +interface Announcement { + id: number; + text: string; +} + +let message: Announcement = { id: 0, text: '' }; + +function subscribe(l: Listener): () => void { + listeners.add(l); + return () => { + listeners.delete(l); + }; +} + +function getSnapshot(): Announcement { + return message; +} + +/** Sets the text the live region announces; the same text again is announced again. */ +export function announceDiskChange(text: string): void { + message = { id: message.id + 1, text }; + for (const l of listeners) l(); +} + +/** Mount-once live region for on-disk change announcements. Its text never outlives it. */ +export function DiskChangeAnnouncer() { + const announcement = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + useEffect(() => () => announceDiskChange(''), []); + return ( +
+ {/* A new node per announcement, so a repeated text is announced again. */} + {announcement.text} +
+ ); +} diff --git a/frontend/src/components/PlainTextView.fileChanged.test.tsx b/frontend/src/components/PlainTextView.fileChanged.test.tsx new file mode 100644 index 0000000..d1515a6 --- /dev/null +++ b/frontend/src/components/PlainTextView.fileChanged.test.tsx @@ -0,0 +1,83 @@ +/** + * Plain Text load refused because the file changed on disk: the view shows a + * reload hint instead of its generic error, and asks GetDiskStatus once so the + * tab is marked at once (or not, when the file already went back). + * + * Run: cd frontend && npx vitest run src/components/PlainTextView.fileChanged.test.tsx + */ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { ReactNode } from 'react'; +import { PlainTextView } from './PlainTextView'; +import { AppProvider } from '../hooks/useDocumentState'; +import { readDiskStatus, resetDiskStatusStore } from '../lib/diskStatusStore'; + +function ProviderWrapper({ children }: { children: ReactNode }) { + return {children}; +} + +const mockGetPlainText = vi.fn(); +const mockGetPlainTextSize = vi.fn(); +const mockGetDiskStatus = vi.fn(); + +vi.mock('../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ + GetPlainText: (...args: unknown[]) => mockGetPlainText(...args), + GetPlainTextSize: (...args: unknown[]) => mockGetPlainTextSize(...args), + GetDiskStatus: (...args: unknown[]) => mockGetDiskStatus(...args), +})); + +const changedStatus = { state: 'changed', loadedModTime: 1_768_219_200_000, diskModTime: 1_768_219_500_000, diskSize: 4096 }; + +function envelope(message: string) { + return new Error(JSON.stringify({ message, cause: {}, kind: 'RuntimeError' })); +} + +beforeEach(() => { + vi.clearAllMocks(); + resetDiskStatusStore(); + mockGetPlainTextSize.mockResolvedValue(17); +}); + +describe('Plain Text on a file changed on disk', () => { + test('shows the reload hint and marks the tab from GetDiskStatus', async () => { + mockGetPlainText.mockRejectedValue(envelope('file changed on disk since it was opened')); + mockGetDiskStatus.mockResolvedValue(changedStatus); + + render(, { wrapper: ProviderWrapper }); + + expect(await screen.findByText('File changed on disk. Reload to view.')).toBeInTheDocument(); + await waitFor(() => expect(mockGetDiskStatus).toHaveBeenCalledWith('tab-1')); + await waitFor(() => expect(readDiskStatus('tab-1')?.state).toBe('changed')); + expect(mockGetDiskStatus).toHaveBeenCalledTimes(1); + }); + + test('a file that already went back is not marked', async () => { + mockGetPlainText.mockRejectedValue(envelope('file changed on disk since it was opened: open x: no such file')); + mockGetDiskStatus.mockResolvedValue({ ...changedStatus, state: 'current' }); + + render(, { wrapper: ProviderWrapper }); + + await waitFor(() => expect(mockGetDiskStatus).toHaveBeenCalledWith('tab-1')); + expect(readDiskStatus('tab-1')).toBeUndefined(); + }); + + test('a file locked by another app shows the busy hint and does not check the disk', async () => { + mockGetPlainText.mockRejectedValue(envelope('file is busy: another process has it open: read x.pdf: lock violation')); + + render(, { wrapper: ProviderWrapper }); + + expect(await screen.findByText('File is in use by another app. Reload to try again.')).toBeInTheDocument(); + expect(screen.queryByText(/lock violation/)).not.toBeInTheDocument(); + expect(mockGetDiskStatus).not.toHaveBeenCalled(); + }); + + test('other load errors keep the generic error and do not check the disk', async () => { + mockGetPlainText.mockRejectedValue(envelope('malformed PDF: read failed')); + + render(, { wrapper: ProviderWrapper }); + + await screen.findByTestId('plain-text-error'); + expect(screen.queryByText('File changed on disk. Reload to view.')).not.toBeInTheDocument(); + expect(mockGetDiskStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/PlainTextView.tsx b/frontend/src/components/PlainTextView.tsx index e0c2e11..a913d3a 100644 --- a/frontend/src/components/PlainTextView.tsx +++ b/frontend/src/components/PlainTextView.tsx @@ -21,6 +21,8 @@ import { GetPlainTextSize, } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; import { extractErrorMessage } from '../lib/extractErrorMessage'; +import { FILE_BUSY_MESSAGE, FILE_CHANGED_MESSAGE, isFileBusyMessage, isFileChangedMessage } from '../lib/fileChange'; +import { markTabFromDiskCheck } from '../lib/diskCheck'; import { useAppDispatch, useAppState } from '../hooks/useDocumentState'; import { useFindBar } from '../hooks/useFindBar'; import { FindBar } from './FindBar'; @@ -190,6 +192,18 @@ export function PlainTextView({ tabId, active }: PlainTextViewProps) { loadStateRef.current = 'cancelled'; setLoadState('cancelled'); setCancelling(false); + } else if (isFileChangedMessage(msg)) { + // The backend refused bytes from a file that changed since Open: + // show the reload hint and mark the tab from a fresh disk check. + loadStateRef.current = 'error'; + setError(FILE_CHANGED_MESSAGE); + setLoadState('error'); + markTabFromDiskCheck(tabIdAtFetch); + } else if (isFileBusyMessage(msg)) { + // Another app holds a lock on the file: show the busy hint. + loadStateRef.current = 'error'; + setError(FILE_BUSY_MESSAGE); + setLoadState('error'); } else { loadStateRef.current = 'error'; setError(msg); diff --git a/frontend/src/components/ReloadToast.test.tsx b/frontend/src/components/ReloadToast.test.tsx new file mode 100644 index 0000000..04098de --- /dev/null +++ b/frontend/src/components/ReloadToast.test.tsx @@ -0,0 +1,78 @@ +/** + * Reload confirmation pill: a fixed, non-interactive status message at + * top-center that disappears after about 3 seconds. Driven by its own module + * store (showReloadToast), so showing it never dispatches to AppState. + * + * Run: cd frontend && npx vitest run src/components/ReloadToast.test.tsx + */ +import { render, screen, act } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ReloadToast, showReloadToast } from './ReloadToast'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + act(() => { + vi.runOnlyPendingTimers(); + }); + vi.useRealTimers(); +}); + +describe('ReloadToast', () => { + test('shows the message in a polite status region', () => { + render(); + act(() => showReloadToast('a.pdf reloaded - view reset')); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('a.pdf reloaded - view reset'); + expect(status).not.toHaveAttribute('aria-live', 'assertive'); + }); + + test('is a fixed overlay that ignores the pointer and takes no focus', () => { + render(); + act(() => showReloadToast('a.pdf reloaded - view reset')); + const pill = screen.getByText('a.pdf reloaded - view reset'); + const box = pill.closest('.fixed') as HTMLElement | null; + expect(box).not.toBeNull(); + expect(box?.className).toMatch(/pointer-events-none/); + expect(box?.querySelector('button, a, input, [tabindex]')).toBeNull(); + expect(box?.hasAttribute('tabindex')).toBe(false); + }); + + test('disappears after about 3 seconds', () => { + render(); + act(() => showReloadToast('a.pdf reloaded - view reset')); + act(() => { + vi.advanceTimersByTime(2500); + }); + expect(screen.getByText('a.pdf reloaded - view reset')).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(screen.queryByText('a.pdf reloaded - view reset')).not.toBeInTheDocument(); + }); + + test('a second message within the window replaces the text and restarts the timer', () => { + render(); + act(() => showReloadToast('a.pdf reloaded - view reset')); + act(() => { + vi.advanceTimersByTime(2000); + }); + act(() => showReloadToast('b.pdf is in use by another app - try again')); + expect(screen.queryByText('a.pdf reloaded - view reset')).not.toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(screen.getByText('b.pdf is in use by another app - try again')).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(screen.queryByText('b.pdf is in use by another app - try again')).not.toBeInTheDocument(); + }); + + test('renders nothing visible before any message', () => { + render(); + expect(screen.queryByText(/reloaded/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ReloadToast.tsx b/frontend/src/components/ReloadToast.tsx new file mode 100644 index 0000000..5f59e17 --- /dev/null +++ b/frontend/src/components/ReloadToast.tsx @@ -0,0 +1,73 @@ +/** + * @file Reload confirmation pill: a fixed, non-interactive status message at + * top-center that disappears after about 3 seconds. Driven by its own module + * store, so showing it never dispatches to AppState. + */ +import { useEffect, useSyncExternalStore } from 'react'; + +const VISIBLE_MS = 3000; + +interface Toast { + id: number; + message: string; +} + +type Listener = () => void; +const listeners = new Set(); +let current: Toast | null = null; +let nextId = 1; +let hideTimer: ReturnType | null = null; + +function notify() { + for (const l of listeners) l(); +} + +function subscribe(l: Listener): () => void { + listeners.add(l); + return () => { + listeners.delete(l); + }; +} + +function getSnapshot(): Toast | null { + return current; +} + +/** Shows message in the pill, replacing any message on screen and restarting the timer. */ +export function showReloadToast(message: string): void { + if (hideTimer) clearTimeout(hideTimer); + current = { id: nextId++, message }; + notify(); + hideTimer = setTimeout(() => { + hideTimer = null; + current = null; + notify(); + }, VISIBLE_MS); +} + +/** Removes the pill and its timer. */ +function clearToast(): void { + if (hideTimer) clearTimeout(hideTimer); + hideTimer = null; + current = null; + notify(); +} + +/** Mount-once host for the reload confirmation pill. A message never outlives the host. */ +export function ReloadToast() { + const toast = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + useEffect(() => clearToast, []); + return ( +
+ {toast && ( + // key restarts the fade when a new message replaces the old one. +
+ {toast.message} +
+ )} +
+ ); +} diff --git a/frontend/src/components/SignaturesView.tsx b/frontend/src/components/SignaturesView.tsx index c7aca33..3062b9c 100644 --- a/frontend/src/components/SignaturesView.tsx +++ b/frontend/src/components/SignaturesView.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'; import { GetSignatures } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; import { extractErrorMessage } from '../lib/extractErrorMessage'; +import { FILE_BUSY_MESSAGE, FILE_CHANGED_MESSAGE, isFileBusyMessage, isFileChangedMessage } from '../lib/fileChange'; import { SMALL_BUTTON } from './buttonStyles'; /** One decomposed X.509 certificate, mirroring `pdfcore.CertInfo`. */ @@ -48,6 +49,8 @@ export interface SignatureEntryData { trailingGap?: number; holeMatchesContents?: boolean; coverageError?: string; + /** Set when the hole could not be read from disk (file changed or busy). */ + holeCheckError?: string; } /** Props for {@link SignaturesView}. */ @@ -66,6 +69,13 @@ export interface SignaturesViewProps { data?: SignatureEntryData[] | null; } +/** UI text for a hole that could not be read from disk. */ +function holeCheckMessage(raw: string): string { + if (isFileChangedMessage(raw)) return FILE_CHANGED_MESSAGE; + if (isFileBusyMessage(raw)) return FILE_BUSY_MESSAGE; + return raw; +} + /** Extracts the CN component from an X.509 DN string, falling back to the * full DN. Card rows show the CN; the expanded chain shows full DNs. */ function cnOf(dn: string): string { @@ -199,7 +209,8 @@ function SignatureCard({ entry, onNavigate }: { entry: SignatureEntryData; onNav /> ) : null} diff --git a/frontend/src/components/StaleNote.tsx b/frontend/src/components/StaleNote.tsx new file mode 100644 index 0000000..b6207c7 --- /dev/null +++ b/frontend/src/components/StaleNote.tsx @@ -0,0 +1,60 @@ +/** + * @file Muted note in the detail panel's inner-tab row for an active tab whose + * file changed on disk or went missing. The only clickable surface of the + * on-disk change indicator. Subscribes to its own tab's disk status, so a + * status update re-renders the note and nothing else. + */ +import { useDiskStatus } from '../lib/diskStatusStore'; +import { formatTimestamp } from '../lib/formatTimestamp'; +import { FILE_MISSING_MESSAGE } from '../lib/fileChange'; + +/** Props for {@link StaleNote}. */ +export interface StaleNoteProps { + /** Active document tab id. */ + tabId: string | null; + /** Reloads the active tab from disk. */ + onReload: () => void; + /** Compares the active tab with the file on disk. */ + onCompare: () => void; +} + +const NOTE_BUTTON = + 'flex-shrink-0 min-h-[24px] min-w-[24px] px-1.5 text-xs text-text-secondary rounded hover:bg-surface-hover hover:text-text cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-border-focus'; + +/** + * Shows `Document updated` with Reload and Compare for a changed file, or + * `File not found` with no buttons for a missing one; renders nothing while + * the file is current. The label truncates first; the buttons stay whole. + */ +export function StaleNote({ tabId, onReload, onCompare }: StaleNoteProps) { + const status = useDiskStatus(tabId); + if (!status) return null; + if (status.state === 'missing') { + return ( +
+ + File not found + +
+ ); + } + // Computed on render so "today" is judged against the clock now. + const tooltip = `Showing version from ${formatTimestamp(status.loadedModTime)}. Changed on disk ${formatTimestamp(status.diskModTime)}.`; + return ( +
+ + Document updated + + + + + + + +
+ ); +} diff --git a/frontend/src/components/TabBar.diskStatus.test.tsx b/frontend/src/components/TabBar.diskStatus.test.tsx new file mode 100644 index 0000000..2269cbe --- /dev/null +++ b/frontend/src/components/TabBar.diskStatus.test.tsx @@ -0,0 +1,221 @@ +/** + * Tab dot for a file changed on disk: an indicator only. It sits in a fixed + * slot between the file name and the close button that is reserved whether or + * not the dot shows, is hidden from assistive tech (the tab's accessible name + * and tooltip carry the text), and a click on it activates the tab like a + * click anywhere else on the tab. + * + * Test ids: tab-disk-slot- (always rendered), tab-disk-dot- + * (only while the tab is changed or missing). + * + * Run: cd frontend && npx vitest run src/components/TabBar.diskStatus.test.tsx + */ +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Dispatch } from 'react'; +import { AppProvider, useAppDispatch, type AppAction } from '../hooks/useDocumentState'; +import { applyDiskStatusEvent, resetDiskStatusStore } from '../lib/diskStatusStore'; +import { TabBar } from './TabBar'; + +vi.mock('@wailsio/runtime', () => ({ + Events: { + On: vi.fn(() => vi.fn()), + Emit: vi.fn(), + }, +})); + +vi.mock('../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({ + OpenFile: vi.fn(), + GetTreeRoot: vi.fn(), + GetChildren: vi.fn(), + CloseDocument: vi.fn().mockResolvedValue(undefined), + OpenFileDialog: vi.fn(), + GetDiskStatus: vi.fn(), +})); + +const catalogNode = { + id: 'root', + label: 'Catalog', + rawKey: '', + nodeType: 'dict', + valueType: '', + hasChildren: true, + childCount: 1, + iconHint: 'catalog', + error: '', +}; + +let dispatch: Dispatch; +function Grab() { + dispatch = useAppDispatch(); + return null; +} + +function mount() { + render( + + + + , + ); +} + +function open(tabId: string, filePath: string) { + act(() => + dispatch({ + type: 'OPEN_DOCUMENT', + payload: { tabId, fileName: filePath.split('/').pop() ?? '', filePath, pageCount: 1, rootNode: catalogNode, rootChildren: [] }, + }), + ); +} + +// 2026-01-12, local time. +const today = (h: number, m: number) => new Date(2026, 0, 12, h, m).getTime(); + +function mark(tabId: string, seq: number, state: 'changed' | 'missing' | 'current') { + act(() => + applyDiskStatusEvent({ + tabId, + seq, + state, + loadedModTime: today(14, 0), + diskModTime: state === 'missing' ? 0 : today(14, 5), + diskSize: state === 'missing' ? 0 : 2048, + }), + ); +} + +const originalPlatform = Object.getOwnPropertyDescriptor(window.navigator, 'platform'); + +beforeEach(() => { + resetDiskStatusStore(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 0, 12, 18, 0)); +}); + +afterEach(() => { + vi.useRealTimers(); + if (originalPlatform) Object.defineProperty(window.navigator, 'platform', originalPlatform); +}); + +describe('tab dot', () => { + test('a stale active tab and a stale background tab both show the dot', () => { + mount(); + open('a1', '/docs/a.pdf'); + open('b1', '/docs/b.pdf'); + mark('a1', 1, 'changed'); + mark('b1', 1, 'missing'); + expect(screen.getByTestId('tab-disk-dot-a1')).toBeInTheDocument(); + expect(screen.getByTestId('tab-disk-dot-b1')).toBeInTheDocument(); + }); + + test('a tab that is current shows no dot but keeps its slot', () => { + mount(); + open('a1', '/docs/a.pdf'); + expect(screen.queryByTestId('tab-disk-dot-a1')).not.toBeInTheDocument(); + expect(screen.getByTestId('tab-disk-slot-a1')).toBeInTheDocument(); + }); + + test('the dot is aria-hidden, not focusable and not a control', () => { + mount(); + open('a1', '/docs/a.pdf'); + mark('a1', 1, 'changed'); + const dot = screen.getByTestId('tab-disk-dot-a1'); + expect(dot).toHaveAttribute('aria-hidden', 'true'); + expect(dot.tagName).toBe('SPAN'); + expect(dot).not.toHaveAttribute('tabindex'); + expect(dot).not.toHaveAttribute('role'); + expect(dot.querySelector('button, a, [tabindex]')).toBeNull(); + }); + + test('clicking the dot activates its tab like clicking the label', async () => { + const user = userEvent.setup(); + mount(); + open('a1', '/docs/a.pdf'); + open('b1', '/docs/b.pdf'); + mark('a1', 1, 'changed'); + expect(screen.getByTestId('tab-a1')).toHaveAttribute('aria-selected', 'false'); + + await user.click(screen.getByTestId('tab-disk-dot-a1')); + + expect(screen.getByTestId('tab-a1')).toHaveAttribute('aria-selected', 'true'); + }); + + test('the slot sits between the file name and the close button with the same classes stale or not', () => { + mount(); + open('a1', '/docs/a.pdf'); + const slotBefore = screen.getByTestId('tab-disk-slot-a1').className; + const closeBefore = screen.getByTestId('tab-close-a1').className; + + mark('a1', 1, 'changed'); + + const slot = screen.getByTestId('tab-disk-slot-a1'); + const close = screen.getByTestId('tab-close-a1'); + const name = screen.getByText('a.pdf', { selector: 'span' }); + expect(slot.className).toBe(slotBefore); + expect(close.className).toBe(closeBefore); + expect(name.compareDocumentPosition(slot) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(slot.compareDocumentPosition(close) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(slot.contains(close)).toBe(false); + expect(slot.contains(screen.getByTestId('tab-disk-dot-a1'))).toBe(true); + }); + + test('the dot goes away when the tab returns to current', () => { + mount(); + open('a1', '/docs/a.pdf'); + mark('a1', 1, 'changed'); + mark('a1', 2, 'current'); + expect(screen.queryByTestId('tab-disk-dot-a1')).not.toBeInTheDocument(); + }); +}); + +describe('tab tooltip and accessible name', () => { + test('changed: tooltip adds the disk time and the Ctrl+R hint off macOS', () => { + Object.defineProperty(window.navigator, 'platform', { value: 'Win32', configurable: true }); + mount(); + open('a1', '/docs/a.pdf'); + mark('a1', 1, 'changed'); + expect(screen.getByTestId('tab-a1')).toHaveAttribute( + 'title', + '/docs/a.pdf\nDocument updated on disk 14:05 - press Ctrl+R to reload', + ); + }); + + test('changed: tooltip says Cmd+R on macOS', () => { + Object.defineProperty(window.navigator, 'platform', { value: 'MacIntel', configurable: true }); + mount(); + open('a1', '/docs/a.pdf'); + mark('a1', 1, 'changed'); + expect(screen.getByTestId('tab-a1').getAttribute('title')).toContain('press Cmd+R to reload'); + }); + + test('missing: tooltip says the file is no longer found', () => { + mount(); + open('a1', '/docs/a.pdf'); + mark('a1', 1, 'missing'); + expect(screen.getByTestId('tab-a1')).toHaveAttribute('title', '/docs/a.pdf\nFile no longer found at this path'); + }); + + test('current: tooltip is the file path alone', () => { + mount(); + open('a1', '/docs/a.pdf'); + expect(screen.getByTestId('tab-a1')).toHaveAttribute('title', '/docs/a.pdf'); + }); + + test('accessible name carries the change through visually hidden text', () => { + mount(); + open('a1', '/docs/a.pdf'); + open('b1', '/docs/b.pdf'); + mark('a1', 1, 'changed'); + mark('b1', 1, 'missing'); + expect(screen.getByRole('tab', { name: /a\.pdf, changed on disk/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /b\.pdf, file not found/ })).toBeInTheDocument(); + }); + + test('accessible name has no change text for a current tab', () => { + mount(); + open('a1', '/docs/a.pdf'); + expect(screen.getByRole('tab', { name: /a\.pdf/ }).textContent).not.toMatch(/changed on disk|file not found/); + }); +}); diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx index d99fd4e..538a77a 100644 --- a/frontend/src/components/TabBar.tsx +++ b/frontend/src/components/TabBar.tsx @@ -6,8 +6,12 @@ import { useEffect, useCallback } from 'react'; import * as Tabs from '@radix-ui/react-tabs'; import { Events } from '@wailsio/runtime'; -import { useAppState, useAppDispatch } from '../hooks/useDocumentState'; +import { useAppState, useAppDispatch, type TabState } from '../hooks/useDocumentState'; import { closeDocumentTab } from '../lib/openedDocuments'; +import { useDiskStatus, type DiskStatusEntry } from '../lib/diskStatusStore'; +import { formatTimestamp } from '../lib/formatTimestamp'; +import { FILE_MISSING_MESSAGE } from '../lib/fileChange'; +import { getShortcutHint } from '../lib/platform'; /** Tab bar displaying all open documents with switch and close controls. */ export function TabBar() { @@ -99,28 +103,69 @@ export function TabBar() { data-testid="tab-list" > {tabs.map((tab) => ( - -
- {tab.fileName} - -
-
+ ))} ); } + +/** Second tooltip line for a tab whose file changed or went missing on disk. */ +function diskStatusLine(status: DiskStatusEntry): string { + if (status.state === 'missing') return FILE_MISSING_MESSAGE; + return `Document updated on disk ${formatTimestamp(status.diskModTime)} - press ${getShortcutHint('R')} to reload`; +} + +interface TabItemProps { + tab: TabState; + onClose: (tabId: string, e: React.MouseEvent) => void; +} + +/** + * One tab trigger. Subscribes to its own tab's disk status only, so a status + * update re-renders this tab and nothing else. The dot is an indicator: it is + * aria-hidden, has no handler of its own and sits in a fixed slot that is + * reserved whether or not it shows, so tab widths never change. + */ +function TabItem({ tab, onClose }: TabItemProps) { + const status = useDiskStatus(tab.tabId); + const path = tab.filePath || tab.fileName; + const title = status ? `${path}\n${diskStatusLine(status)}` : path; + const srText = status ? (status.state === 'missing' ? ', file not found' : ', changed on disk') : ''; + return ( + +
+ {tab.fileName} + {srText && {srText}} +
+
+ ); +} diff --git a/frontend/src/hooks/useDocumentState.reload.test.tsx b/frontend/src/hooks/useDocumentState.reload.test.tsx new file mode 100644 index 0000000..358e3c9 --- /dev/null +++ b/frontend/src/hooks/useDocumentState.reload.test.tsx @@ -0,0 +1,260 @@ +/** + * Reload through OPEN_DOCUMENT with `reload: true`: the tab is replaced in + * place exactly like re-opening an open file (fresh content, selection and + * history cleared, rail view and find case kept), and a reload never touches + * the batch-open counters or the batch warning. + * + * Run: cd frontend && npx vitest run src/hooks/useDocumentState.reload.test.tsx + */ +import { render, act } from '@testing-library/react'; +import { describe, test, expect } from 'vitest'; +import type { Dispatch } from 'react'; +import { AppProvider, useAppState, useAppDispatch, type AppAction, type AppState } from './useDocumentState'; + +let state: AppState; +let dispatch: Dispatch; + +function Probe() { + state = useAppState(); + dispatch = useAppDispatch(); + return null; +} + +function send(action: Record) { + act(() => dispatch(action as unknown as AppAction)); +} + +function mount() { + render( + + + , + ); +} + +const catalogNode = { + id: 'root', + label: 'Catalog', + rawKey: '', + nodeType: 'dict', + valueType: '', + hasChildren: true, + childCount: 1, + iconHint: 'catalog', + error: '', +}; + +const pagesNode = { ...catalogNode, id: 'obj:0:2', label: 'Pages', iconHint: 'pages' }; + +function open(tabId: string, filePath: string, extra: Record = {}) { + send({ + type: 'OPEN_DOCUMENT', + payload: { + tabId, + fileName: filePath.split('/').pop(), + filePath, + pageCount: 1, + rootNode: catalogNode, + rootChildren: [], + ...extra, + }, + }); +} + +function reload(newTabId: string, filePath: string, pageCount = 9) { + open(newTabId, filePath, { reload: true, pageCount, rootChildren: [pagesNode] }); +} + +function tab(tabId: string) { + return state.tabs.find((t) => t.tabId === tabId); +} + +describe('reload replaces the tab like a new open', () => { + test('new tab id at the same strip position with fresh content', () => { + mount(); + open('a1', '/docs/a.pdf'); + open('b1', '/docs/b.pdf'); + send({ type: 'ACTIVATE_TAB', payload: { tabId: 'a1' } }); + + reload('a2', '/docs/a.pdf', 9); + + expect(state.tabs.map((t) => t.tabId)).toEqual(['a2', 'b1']); + expect(state.activeTabId).toBe('a2'); + expect(tab('a2')?.pageCount).toBe(9); + expect(tab('a2')?.rootChildren).toEqual([pagesNode]); + }); + + test('selection, history, pending nav and recent jumps are cleared; rail view and find case are kept', () => { + mount(); + open('a1', '/docs/a.pdf'); + send({ type: 'SET_FIND_CASE_SENSITIVE', payload: { tabId: 'a1', value: true } }); + send({ type: 'SELECT_NODE', payload: { nodeId: 'obj:0:2', label: 'Pages' } }); + send({ type: 'SELECT_NODE', payload: { nodeId: 'obj:0:3', label: 'Page' } }); + send({ type: 'PUSH_RECENT_JUMP', payload: { tabId: 'a1', entry: { objNum: 3, gen: 0, typeName: 'Page', nodeId: 'obj:0:3' } } }); + send({ type: 'NAVIGATE_TO_REF', payload: { targetNodeId: 'obj:0:4' } }); + send({ type: 'SELECT_LEFT_VIEW', payload: { view: 'pages' } }); + expect(tab('a1')?.pendingNavTarget).toBe('obj:0:4'); + + reload('a2', '/docs/a.pdf'); + + const t = tab('a2'); + expect(t?.leftView).toBe('pages'); + expect(t?.findCaseSensitive).toBe(true); + expect(t?.selectedNodeId).toBeNull(); + expect(t?.navHistory).toEqual([]); + expect(t?.navHistoryIndex).toBe(-1); + expect(t?.recentJumps).toEqual([]); + expect(t?.pendingNavTarget).toBeNull(); + expect(t?.navError).toBeNull(); + }); + + test('other tabs keep their entries and rail view', () => { + mount(); + open('b1', '/docs/b.pdf'); + send({ type: 'SELECT_LEFT_VIEW', payload: { view: 'images' } }); + send({ type: 'SELECT_NODE', payload: { nodeId: 'obj:0:7', label: 'Img' } }); + open('a1', '/docs/a.pdf'); + const before = tab('b1'); + + reload('a2', '/docs/a.pdf'); + + expect(tab('b1')).toEqual(before); + }); + + test('clears the document error and bumps the activation version', () => { + mount(); + open('a1', '/docs/a.pdf'); + send({ type: 'SET_DOCUMENT_ERROR', payload: { message: 'Could not reload a.pdf: file not found' } }); + const version = state.tabActivationVersion; + + reload('a2', '/docs/a.pdf'); + + expect(state.documentError).toBeNull(); + expect(state.tabActivationVersion).toBe(version + 1); + }); + + test('a reload that lands after the user switched tabs leaves the other tab active', () => { + mount(); + open('a1', '/docs/a.pdf'); + open('b1', '/docs/b.pdf'); + const version = state.tabActivationVersion; + + reload('a2', '/docs/a.pdf'); + + expect(state.activeTabId).toBe('b1'); + expect(state.tabs.map((t) => t.tabId)).toEqual(['a2', 'b1']); + expect(state.tabActivationVersion).toBe(version); + }); + + test('a reload of the active tab keeps it active under the new id', () => { + mount(); + open('a1', '/docs/a.pdf'); + open('b1', '/docs/b.pdf'); + send({ type: 'ACTIVATE_TAB', payload: { tabId: 'a1' } }); + + reload('a2', '/docs/a.pdf'); + + expect(state.activeTabId).toBe('a2'); + }); +}); + +describe('reload leaves batch-open bookkeeping alone', () => { + test('a reload during a batch open does not count toward the batch', () => { + mount(); + open('a1', '/docs/a.pdf'); + send({ type: 'BATCH_OPEN_START', payload: { total: 3 } }); + open('c1', '/docs/c.pdf'); + expect(state.batchOpenCompleted).toBe(1); + + reload('a2', '/docs/a.pdf'); + + expect(state.batchOpenCompleted).toBe(1); + expect(state.batchOpenTotal).toBe(3); + expect(state.batchOpenCancelled).toBe(false); + }); + + test('a reload keeps the cancelled-batch warning text and counters', () => { + mount(); + open('a1', '/docs/a.pdf'); + send({ type: 'BATCH_OPEN_START', payload: { total: 4 } }); + open('c1', '/docs/c.pdf'); + send({ type: 'BATCH_OPEN_CANCEL' }); + send({ type: 'BATCH_OPEN_COMPLETE' }); + open('d1', '/docs/d.pdf'); + const warning = state.documentWarning; + expect(warning).toBe('Loading cancelled. 2 of 4 files opened.'); + const completed = state.batchOpenCompleted; + + reload('a2', '/docs/a.pdf'); + + expect(state.documentWarning).toBe(warning); + expect(state.batchOpenCompleted).toBe(completed); + expect(state.batchOpenCancelled).toBe(true); + }); + + test('a clean reload of the active tab clears its structural warning', () => { + mount(); + open('a1', '/docs/a.pdf'); + send({ type: 'SET_DOCUMENT_WARNING', payload: { message: 'This PDF has structural errors. Some objects may display incorrectly.' } }); + + reload('a2', '/docs/a.pdf'); + + expect(state.documentWarning).toBeNull(); + }); + + test('a reload of the active tab shows the warning of the new parse', () => { + mount(); + open('a1', '/docs/a.pdf'); + + open('a2', '/docs/a.pdf', { reload: true, warning: 'This PDF has structural errors. Some objects may display incorrectly.' }); + + expect(state.documentWarning).toBe('This PDF has structural errors. Some objects may display incorrectly.'); + }); + + test('a reload in the background keeps the active tab\'s warning', () => { + mount(); + open('a1', '/docs/a.pdf'); + open('b1', '/docs/b.pdf'); + send({ type: 'SET_DOCUMENT_WARNING', payload: { message: 'b has a warning' } }); + + reload('a2', '/docs/a.pdf'); + + expect(state.documentWarning).toBe('b has a warning'); + }); + + test('a reload leaves an open in progress alone', () => { + mount(); + open('a1', '/docs/a.pdf'); + send({ type: 'OPENING_START', payload: { fileName: 'x.pdf' } }); + expect(state.isOpening).toBe(true); + + reload('a2', '/docs/a.pdf'); + + expect(state.isOpening).toBe(true); + expect(state.openingFileName).toBe('x.pdf'); + }); + + test('a failed reload sets only the error banner', () => { + mount(); + open('a1', '/docs/a.pdf'); + send({ type: 'SET_DOCUMENT_WARNING', payload: { message: 'This PDF has structural errors. Some objects may display incorrectly.' } }); + send({ type: 'OPENING_START', payload: { fileName: 'x.pdf' } }); + + send({ type: 'SET_DOCUMENT_ERROR', payload: { message: 'Could not reload a.pdf: file not found', reload: true } }); + + expect(state.documentError).toBe('Could not reload a.pdf: file not found'); + expect(state.documentWarning).toBe('This PDF has structural errors. Some objects may display incorrectly.'); + expect(state.isOpening).toBe(true); + expect(state.openingFileName).toBe('x.pdf'); + }); + + test('a real open of the same file still runs batch bookkeeping', () => { + mount(); + open('a1', '/docs/a.pdf'); + send({ type: 'BATCH_OPEN_START', payload: { total: 2 } }); + + open('a2', '/docs/a.pdf'); + + expect(state.batchOpenCompleted).toBe(1); + }); +}); diff --git a/frontend/src/hooks/useDocumentState.tsx b/frontend/src/hooks/useDocumentState.tsx index 775ab4c..5ee4c33 100644 --- a/frontend/src/hooks/useDocumentState.tsx +++ b/frontend/src/hooks/useDocumentState.tsx @@ -142,11 +142,15 @@ export interface AppState { /** Union of all actions the app reducer handles. */ export type AppAction = - | { type: 'OPEN_DOCUMENT'; payload: { tabId: string; fileName: string; filePath: string; pageCount?: number; rootNode: TreeNode | null; rootChildren: TreeNode[] | null } } + // reload: a reload of an open tab; it skips the batch-open bookkeeping and + // leaves the open-in-progress state alone. warning: the reloaded parse's + // warning, shown when the reloaded tab is active. + | { type: 'OPEN_DOCUMENT'; payload: { tabId: string; fileName: string; filePath: string; pageCount?: number; rootNode: TreeNode | null; rootChildren: TreeNode[] | null; reload?: boolean; warning?: string | null } } | { type: 'ACTIVATE_TAB'; payload: { tabId: string } } | { type: 'CLOSE_DOCUMENT'; payload: { tabId: string } } | { type: 'SELECT_NODE'; payload: { nodeId: string; label?: string; rawKey?: string; iconHint?: string; isHistoryNav?: boolean } } - | { type: 'SET_DOCUMENT_ERROR'; payload: { message: string } } + // reload: a failed reload; only the error banner changes. + | { type: 'SET_DOCUMENT_ERROR'; payload: { message: string; reload?: boolean } } | { type: 'DISMISS_ERROR' } | { type: 'NAVIGATE_TO_REF'; payload: { targetNodeId: string } } | { type: 'CLEAR_NAV_TARGET' } @@ -211,7 +215,10 @@ function appReducer(state: AppState, action: AppAction): AppState { // Drive batch progress from OPEN_DOCUMENT itself: incrementing here is // atomic with the tab being added, so the count can never lag behind // the actual number of opened tabs. - const inBatch = state.batchOpenTotal > 0; + // A reload is not part of any batch: it leaves the counters and the + // cancel flag exactly as they are. + const isReload = action.payload.reload === true; + const inBatch = !isReload && state.batchOpenTotal > 0; const batchOpenCompleted = inBatch ? state.batchOpenCompleted + 1 : state.batchOpenCompleted; @@ -261,6 +268,12 @@ function appReducer(state: AppState, action: AppAction): AppState { const existingIndex = action.payload.filePath ? state.tabs.findIndex((t) => t.filePath === action.payload.filePath) : -1; + // A reload of a background tab (the user switched away while it parsed) + // replaces it without taking focus; a reload of the active tab keeps it + // active under the new id. + const replacedId = existingIndex >= 0 ? state.tabs[existingIndex].tabId : null; + const reloadInBackground = isReload && replacedId !== null && replacedId !== state.activeTabId; + const activeTabId = reloadInBackground ? state.activeTabId : action.payload.tabId; const tabs = existingIndex < 0 ? [...state.tabs, parsed] : state.tabs.map((tab, i) => (i === existingIndex @@ -269,11 +282,28 @@ function appReducer(state: AppState, action: AppAction): AppState { // Activating a different tab id is a tab-context change; bump the // version so the Cmd+K palette closes (mirrors ACTIVATE_TAB and the // CLOSE_DOCUMENT-of-active-tab path). - const activatedOtherTab = state.activeTabId !== action.payload.tabId; + const activatedOtherTab = state.activeTabId !== activeTabId; + if (isReload) { + // The reloaded parse's warning replaces the banner only when its tab is + // the active one; the batch-cancel note is never replaced. + const reloadWarning = reloadInBackground || state.batchOpenCancelled + ? state.documentWarning + : action.payload.warning ?? null; + return { + ...state, + tabs, + activeTabId, + documentError: reloadInBackground ? state.documentError : null, + documentWarning: reloadWarning, + tabActivationVersion: activatedOtherTab + ? state.tabActivationVersion + 1 + : state.tabActivationVersion, + }; + } return { ...state, tabs, - activeTabId: action.payload.tabId, + activeTabId, documentError: null, documentWarning, batchOpenCompleted, @@ -370,6 +400,10 @@ function appReducer(state: AppState, action: AppAction): AppState { }; } case 'SET_DOCUMENT_ERROR': { + // A failed reload leaves the warning and any open in progress alone. + if (action.payload.reload) { + return { ...state, documentError: action.payload.message }; + } // In multi-tab mode, preserve existing tabs -- the error banner is // informational about the failed open attempt, not a reason to close // every open document. diff --git a/frontend/src/hooks/useFindBar.ts b/frontend/src/hooks/useFindBar.ts index aaef158..a19fcf7 100644 --- a/frontend/src/hooks/useFindBar.ts +++ b/frontend/src/hooks/useFindBar.ts @@ -46,8 +46,8 @@ export interface UseFindBarReturn { prev: () => void; } -/** True when the event target is an unrelated text input (mirror App.jsx). */ -function isInTextField(target: EventTarget | null): boolean { +/** True when target is a text input, a textarea or a contentEditable element. */ +export function isInTextField(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; const tag = target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return true; diff --git a/frontend/src/hooks/usePDFService.ts b/frontend/src/hooks/usePDFService.ts index 53fb76b..9686cfd 100644 --- a/frontend/src/hooks/usePDFService.ts +++ b/frontend/src/hooks/usePDFService.ts @@ -5,24 +5,38 @@ import { OpenFile, GetTreeRoot, GetChildren, CloseDocument, OpenFileDialog as _OpenFileDialog } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; import type { TreeNode } from './useDocumentState'; +/** Failure category of a raw backend open error. */ +export type OpenErrorKind = 'pdfOnly' | 'encrypted' | 'malformed' | 'notFound' | 'unexpected'; + +/** + * Classifies a raw backend open error. The one matcher behind both the open + * error messages and the short reload failure reasons. + */ +export function openErrorKind(rawMessage: string): OpenErrorKind { + if (/only.*pdf/i.test(rawMessage)) return 'pdfOnly'; + if (/encrypted|password/i.test(rawMessage)) return 'encrypted'; + if (/malformed/i.test(rawMessage)) return 'malformed'; + if (/not found/i.test(rawMessage)) return 'notFound'; + return 'unexpected'; +} + /** * Map raw backend error strings to user-facing messages. * Matches common failure categories (encrypted, malformed, not found). */ export function mapErrorMessage(rawMessage: string): string { - if (/only.*pdf/i.test(rawMessage)) { - return rawMessage; - } - if (/encrypted|password/i.test(rawMessage)) { - return 'This PDF could not be opened. The file appears to be encrypted.'; - } - if (/malformed/i.test(rawMessage)) { - return 'This PDF could not be opened. The file appears to be damaged or corrupt.'; - } - if (/not found/i.test(rawMessage)) { - return 'The file could not be found. It may have been moved or deleted.'; + switch (openErrorKind(rawMessage)) { + case 'pdfOnly': + return rawMessage; + case 'encrypted': + return 'This PDF could not be opened. The file appears to be encrypted.'; + case 'malformed': + return 'This PDF could not be opened. The file appears to be damaged or corrupt.'; + case 'notFound': + return 'The file could not be found. It may have been moved or deleted.'; + default: + return 'This PDF could not be opened. An unexpected error occurred.'; } - return 'This PDF could not be opened. An unexpected error occurred.'; } /** diff --git a/frontend/src/lib/diskCheck.ts b/frontend/src/lib/diskCheck.ts new file mode 100644 index 0000000..678c8ab --- /dev/null +++ b/frontend/src/lib/diskCheck.ts @@ -0,0 +1,25 @@ +/** + * @file On-demand disk check that marks a tab at once. + */ +import { GetDiskStatus } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; +import { diskStatusSeq, setDiskStatusFromCheck, type DiskStatusInfo } from './diskStatusStore'; + +/** + * Asks the backend for tabId's disk status and writes it to the store, so a + * refused read marks the tab at once (or clears it when the file went back). + * A watcher event that arrives first wins over the result. Resolves with the + * status, or rejects with the binding's error. + */ +export function checkDiskStatus(tabId: string): Promise { + const seqAtRequest = diskStatusSeq(tabId); + return Promise.resolve(GetDiskStatus(tabId)).then((res: unknown) => { + const st = (res ?? null) as DiskStatusInfo | null; + if (st) setDiskStatusFromCheck(tabId, st, seqAtRequest); + return st; + }); +} + +/** checkDiskStatus with failures ignored: the watcher reports the change later. */ +export function markTabFromDiskCheck(tabId: string): void { + checkDiskStatus(tabId).catch(() => {}); +} diff --git a/frontend/src/lib/diskStatusStore.test.ts b/frontend/src/lib/diskStatusStore.test.ts new file mode 100644 index 0000000..4e73193 --- /dev/null +++ b/frontend/src/lib/diskStatusStore.test.ts @@ -0,0 +1,139 @@ +/** + * Per-tab on-disk status store, kept outside AppState so a status update + * re-renders only the subscribers of that tab. + * + * Contract used here: + * applyDiskStatusEvent({tabId, seq, state, loadedModTime, diskModTime, diskSize}) + * - drops an event whose seq is not above the last one applied for the tab + * (also after the entry was removed) + * - a 'current' event removes the tab's entry + * setDiskStatusFromCheck(tabId, {state, loadedModTime, diskModTime, diskSize}, seqAtRequest) + * - writes a GetDiskStatus result; 'current' removes the entry + * retainDiskStatus(tabIds) drops entries for every other tab + * readDiskStatus(tabId) / useDiskStatus(tabId) return the entry or undefined + * resetDiskStatusStore() empties the store + * + * Run: cd frontend && npx vitest run src/lib/diskStatusStore.test.ts + */ +import { renderHook, act } from '@testing-library/react'; +import { describe, test, expect, beforeEach } from 'vitest'; +import { + applyDiskStatusEvent, + setDiskStatusFromCheck, + diskStatusSeq, + retainDiskStatus, + readDiskStatus, + useDiskStatus, + resetDiskStatusStore, +} from './diskStatusStore'; + +const loaded = 1_768_219_200_000; + +function ev(tabId: string, seq: number, state: string, diskSize = 200) { + return { tabId, seq, state, loadedModTime: loaded, diskModTime: state === 'missing' ? 0 : loaded + 60_000, diskSize: state === 'missing' ? 0 : diskSize }; +} + +beforeEach(() => { + resetDiskStatusStore(); +}); + +describe('diskStatusStore', () => { + test('a changed event creates an entry with the payload', () => { + applyDiskStatusEvent(ev('t1', 1, 'changed')); + expect(readDiskStatus('t1')).toMatchObject({ seq: 1, state: 'changed', loadedModTime: loaded, diskModTime: loaded + 60_000, diskSize: 200 }); + }); + + test('a current event removes the entry', () => { + applyDiskStatusEvent(ev('t1', 1, 'changed')); + applyDiskStatusEvent(ev('t1', 2, 'current')); + expect(readDiskStatus('t1')).toBeUndefined(); + }); + + test('an event whose seq is not above the last applied one changes nothing', () => { + applyDiskStatusEvent(ev('t1', 5, 'changed', 300)); + applyDiskStatusEvent(ev('t1', 4, 'missing')); + applyDiskStatusEvent(ev('t1', 5, 'current')); + expect(readDiskStatus('t1')).toMatchObject({ seq: 5, state: 'changed', diskSize: 300 }); + }); + + test('a late lower-seq change does not re-mark a tab that went back to current', () => { + applyDiskStatusEvent(ev('t1', 2, 'changed')); + applyDiskStatusEvent(ev('t1', 3, 'current')); + applyDiskStatusEvent(ev('t1', 2, 'changed')); + expect(readDiskStatus('t1')).toBeUndefined(); + }); + + test('seq is tracked per tab', () => { + applyDiskStatusEvent(ev('t1', 9, 'changed')); + applyDiskStatusEvent(ev('t2', 1, 'missing')); + expect(readDiskStatus('t2')?.state).toBe('missing'); + }); + + test('a GetDiskStatus result marks or clears the tab', () => { + setDiskStatusFromCheck('t1', { state: 'changed', loadedModTime: loaded, diskModTime: loaded + 1, diskSize: 10 }, diskStatusSeq('t1')); + expect(readDiskStatus('t1')?.state).toBe('changed'); + setDiskStatusFromCheck('t1', { state: 'current', loadedModTime: loaded, diskModTime: loaded, diskSize: 10 }, diskStatusSeq('t1')); + expect(readDiskStatus('t1')).toBeUndefined(); + }); + + test('a watcher event after a check result still applies', () => { + applyDiskStatusEvent(ev('t1', 3, 'changed')); + setDiskStatusFromCheck('t1', { state: 'missing', loadedModTime: loaded, diskModTime: 0, diskSize: 0 }, diskStatusSeq('t1')); + applyDiskStatusEvent(ev('t1', 4, 'current')); + expect(readDiskStatus('t1')).toBeUndefined(); + }); + + test('a check result older than a watcher event applied since the request is dropped', () => { + applyDiskStatusEvent(ev('t1', 3, 'changed')); + const seqAtRequest = diskStatusSeq('t1'); + applyDiskStatusEvent(ev('t1', 4, 'current')); + + const written = setDiskStatusFromCheck('t1', { state: 'changed', loadedModTime: loaded, diskModTime: loaded + 1, diskSize: 10 }, seqAtRequest); + + expect(written).toBe(false); + expect(readDiskStatus('t1')).toBeUndefined(); + }); + + test('a check result for a tab that left the tab list is dropped', () => { + retainDiskStatus(['t1', 't2']); + retainDiskStatus(['t2']); + + const written = setDiskStatusFromCheck('t1', { state: 'changed', loadedModTime: loaded, diskModTime: loaded + 1, diskSize: 10 }, 0); + + expect(written).toBe(false); + expect(readDiskStatus('t1')).toBeUndefined(); + }); + + test('retain drops entries of tabs that left the tab list', () => { + applyDiskStatusEvent(ev('t1', 1, 'changed')); + applyDiskStatusEvent(ev('t2', 1, 'changed')); + retainDiskStatus(['t2']); + expect(readDiskStatus('t1')).toBeUndefined(); + expect(readDiskStatus('t2')?.state).toBe('changed'); + }); + + test('useDiskStatus re-renders only for its own tab', () => { + let rendersT1 = 0; + const { result } = renderHook(() => { + rendersT1 += 1; + return useDiskStatus('t1'); + }); + expect(result.current).toBeUndefined(); + const before = rendersT1; + + act(() => applyDiskStatusEvent(ev('t2', 1, 'changed'))); + expect(rendersT1).toBe(before); + + act(() => applyDiskStatusEvent(ev('t1', 1, 'changed'))); + expect(result.current?.state).toBe('changed'); + expect(rendersT1).toBe(before + 1); + }); + + test('entries are replaced, never mutated', () => { + applyDiskStatusEvent(ev('t1', 1, 'changed', 100)); + const first = readDiskStatus('t1'); + applyDiskStatusEvent(ev('t1', 2, 'changed', 200)); + expect(first?.diskSize).toBe(100); + expect(readDiskStatus('t1')).not.toBe(first); + }); +}); diff --git a/frontend/src/lib/diskStatusStore.ts b/frontend/src/lib/diskStatusStore.ts new file mode 100644 index 0000000..f21b613 --- /dev/null +++ b/frontend/src/lib/diskStatusStore.ts @@ -0,0 +1,150 @@ +/** + * @file Per-tab on-disk status, kept outside AppState so a status update + * re-renders only the subscribers of that tab. Fed by the watcher's + * `document:disk-status` events and by GetDiskStatus results. + */ +import { useCallback, useSyncExternalStore } from 'react'; + +/** On-disk state of a tab's file; `current` entries are never stored. */ +export type DiskState = 'current' | 'changed' | 'missing'; + +/** Disk status as reported by GetDiskStatus. Times are Unix milliseconds. */ +export interface DiskStatusInfo { + state: string; + loadedModTime: number; + diskModTime: number; + diskSize: number; +} + +/** A `document:disk-status` event payload. */ +export interface DiskStatusEvent extends DiskStatusInfo { + tabId: string; + seq: number; +} + +/** Stored entry for a tab whose file is changed or missing. */ +export interface DiskStatusEntry { + seq: number; + state: DiskState; + loadedModTime: number; + diskModTime: number; + diskSize: number; +} + +type Listener = () => void; +const listeners = new Set(); +let entries = new Map(); +const lastSeq = new Map(); +// Tab ids passed to the last retainDiskStatus, and ids that left the tab list +// since. Tab ids are never reused, so a late result for a released id is +// dropped. +let retained = new Set(); +const released = new Set(); + +function notify() { + for (const l of listeners) l(); +} + +function subscribe(l: Listener): () => void { + listeners.add(l); + return () => { + listeners.delete(l); + }; +} + +function write(tabId: string, seq: number, info: DiskStatusInfo) { + const next = new Map(entries); + if (info.state === 'changed' || info.state === 'missing') { + next.set(tabId, { + seq, + state: info.state, + loadedModTime: info.loadedModTime, + diskModTime: info.diskModTime, + diskSize: info.diskSize, + }); + } else if (!next.delete(tabId)) { + return; + } + entries = next; + notify(); +} + +/** + * Applies a watcher event and returns whether it was applied. An event whose + * `seq` is not above the last one applied for its tab is dropped; a `current` + * event removes the entry. + */ +export function applyDiskStatusEvent(ev: DiskStatusEvent): boolean { + if (released.has(ev.tabId)) return false; + const last = lastSeq.get(ev.tabId); + if (last !== undefined && ev.seq <= last) return false; + lastSeq.set(ev.tabId, ev.seq); + write(ev.tabId, ev.seq, ev); + return true; +} + +/** Seq of the last watcher event applied for tabId, 0 when none. */ +export function diskStatusSeq(tabId: string): number { + return lastSeq.get(tabId) ?? 0; +} + +/** + * Writes a GetDiskStatus result for tabId; `current` removes the entry. + * seqAtRequest is diskStatusSeq(tabId) when the check was requested: a + * watcher event applied since then is newer than the result, which is + * dropped; the watcher then emits its own view again. A result for a tab that + * has left the tab list is dropped too. Returns whether the result was written. + */ +export function setDiskStatusFromCheck(tabId: string, info: DiskStatusInfo, seqAtRequest: number): boolean { + if (released.has(tabId)) return false; + const seq = lastSeq.get(tabId) ?? 0; + if (seq > seqAtRequest) return false; + write(tabId, seq, info); + return true; +} + +/** + * Drops entries (and seq history) of every tab not in tabIds; ids that were + * in the previous list and are not in this one are released for good. + */ +export function retainDiskStatus(tabIds: readonly string[]): void { + const keep = new Set(tabIds); + for (const id of retained) { + if (!keep.has(id)) released.add(id); + } + retained = keep; + for (const id of lastSeq.keys()) { + if (!keep.has(id)) lastSeq.delete(id); + } + let changed = false; + const next = new Map(entries); + for (const id of entries.keys()) { + if (!keep.has(id)) { + next.delete(id); + changed = true; + } + } + if (!changed) return; + entries = next; + notify(); +} + +/** Returns the stored entry for tabId, or undefined when its file is current. */ +export function readDiskStatus(tabId: string): DiskStatusEntry | undefined { + return entries.get(tabId); +} + +/** Subscribes to tabId's entry; re-renders only when that entry changes. */ +export function useDiskStatus(tabId: string | null | undefined): DiskStatusEntry | undefined { + const get = useCallback(() => (tabId ? entries.get(tabId) : undefined), [tabId]); + return useSyncExternalStore(subscribe, get, get); +} + +/** Empties the store. */ +export function resetDiskStatusStore(): void { + entries = new Map(); + lastSeq.clear(); + retained = new Set(); + released.clear(); + notify(); +} diff --git a/frontend/src/lib/fileChange.ts b/frontend/src/lib/fileChange.ts new file mode 100644 index 0000000..4fe6a58 --- /dev/null +++ b/frontend/src/lib/fileChange.ts @@ -0,0 +1,29 @@ +/** + * @file Backend error markers for a file that changed on disk or is busy, and + * the UI strings shown for them. + */ + +/** Text carried by pdfcore.ErrFileChanged. */ +export const FILE_CHANGED_MARKER = 'file changed on disk'; + +/** Text carried by pdfcore.ErrFileBusy. */ +export const FILE_BUSY_MARKER = 'file is busy'; + +/** Shown in place of a view whose read was refused because the file changed. */ +export const FILE_CHANGED_MESSAGE = 'File changed on disk. Reload to view.'; + +/** Shown in place of a view whose read was refused because another app has the file locked. */ +export const FILE_BUSY_MESSAGE = 'File is in use by another app. Reload to try again.'; + +/** Shown when the file is no longer at its path. */ +export const FILE_MISSING_MESSAGE = 'File no longer found at this path'; + +/** True when an extracted backend error message carries the file-changed marker. */ +export function isFileChangedMessage(message: string): boolean { + return message.includes(FILE_CHANGED_MARKER); +} + +/** True when an extracted backend error message carries the busy marker. */ +export function isFileBusyMessage(message: string): boolean { + return message.includes(FILE_BUSY_MARKER); +} diff --git a/frontend/src/lib/formatTimestamp.test.ts b/frontend/src/lib/formatTimestamp.test.ts new file mode 100644 index 0000000..701af6d --- /dev/null +++ b/frontend/src/lib/formatTimestamp.test.ts @@ -0,0 +1,65 @@ +/** + * Timestamp text for the tab tooltip, the stale note tooltip and the Compare + * label: local 24-hour HH:MM when the timestamp's local date is today, the + * full date ("12 January 2026 14:02") otherwise. Each timestamp is judged on + * its own against the clock at call time. + * + * Run: cd frontend && npx vitest run src/lib/formatTimestamp.test.ts + */ +import { describe, test, expect, vi, afterEach } from 'vitest'; +import { formatTimestamp } from './formatTimestamp'; + +function local(y: number, mo: number, d: number, h: number, mi: number): Date { + return new Date(y, mo - 1, d, h, mi, 0, 0); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('formatTimestamp', () => { + test('same day shows time only', () => { + const now = local(2026, 1, 12, 18, 30); + expect(formatTimestamp(local(2026, 1, 12, 14, 2).getTime(), now)).toBe('14:02'); + }); + + test('previous day shows the full date', () => { + const now = local(2026, 1, 12, 18, 30); + expect(formatTimestamp(local(2026, 1, 11, 14, 2).getTime(), now)).toBe('11 January 2026 14:02'); + }); + + test('previous year shows the full date with the year', () => { + const now = local(2026, 1, 12, 9, 0); + expect(formatTimestamp(local(2025, 12, 31, 9, 5).getTime(), now)).toBe('31 December 2025 09:05'); + }); + + test('across midnight: just before midnight is yesterday, just after is today', () => { + const now = local(2026, 1, 12, 0, 1); + expect(formatTimestamp(local(2026, 1, 11, 23, 59).getTime(), now)).toBe('11 January 2026 23:59'); + expect(formatTimestamp(local(2026, 1, 12, 0, 0).getTime(), now)).toBe('00:00'); + }); + + test('day has no leading zero, hours and minutes do', () => { + const now = local(2026, 3, 20, 12, 0); + expect(formatTimestamp(local(2026, 3, 5, 7, 4).getTime(), now)).toBe('5 March 2026 07:04'); + }); + + test('a future date on another day is not today', () => { + const now = local(2026, 1, 12, 12, 0); + expect(formatTimestamp(local(2026, 1, 13, 8, 0).getTime(), now)).toBe('13 January 2026 08:00'); + }); + + test('defaults to the current clock when no reference time is given', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(local(2026, 1, 12, 18, 30)); + expect(formatTimestamp(local(2026, 1, 12, 14, 2).getTime())).toBe('14:02'); + vi.setSystemTime(local(2026, 1, 13, 0, 5)); + expect(formatTimestamp(local(2026, 1, 12, 14, 2).getTime())).toBe('12 January 2026 14:02'); + }); + + test('output is ASCII only', () => { + const now = local(2026, 1, 12, 18, 30); + const out = formatTimestamp(local(2025, 9, 1, 1, 1).getTime(), now); + expect(out).toMatch(/^[\x20-\x7e]+$/); + }); +}); diff --git a/frontend/src/lib/formatTimestamp.ts b/frontend/src/lib/formatTimestamp.ts new file mode 100644 index 0000000..e516719 --- /dev/null +++ b/frontend/src/lib/formatTimestamp.ts @@ -0,0 +1,31 @@ +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +/** + * Formats a Unix-millisecond timestamp in local time: `HH:MM` (24-hour) when + * its local date is the same as `now`, otherwise `D Month YYYY HH:MM`. + */ +export function formatTimestamp(ms: number, now: Date = new Date()): string { + const d = new Date(ms); + const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + const sameDay = + d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); + if (sameDay) return time; + return `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()} ${time}`; +} diff --git a/frontend/src/lib/reloadDocument.ts b/frontend/src/lib/reloadDocument.ts new file mode 100644 index 0000000..b6a8a41 --- /dev/null +++ b/frontend/src/lib/reloadDocument.ts @@ -0,0 +1,98 @@ +/** + * @file Reload from disk: opens the tab's file again under a new tab id and + * replaces the tab in place. Shared by every reload trigger. + */ +import type { Dispatch } from 'react'; +import { CloseDocument } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; +import type { AppAction, TabState } from '../hooks/useDocumentState'; +import { openErrorKind, openPDFFile } from '../hooks/usePDFService'; +import { dispatchOpenedDocument } from './openedDocuments'; +import { extractErrorMessage } from './extractErrorMessage'; +import { isFileBusyMessage } from './fileChange'; +import { showReloadToast } from '../components/ReloadToast'; +import { markTabFromDiskCheck } from './diskCheck'; + +/** Tab ids with a reload in flight; a second trigger for one of them is ignored. */ +const reloading = new Set(); + +/** Short reason for a failed reload, from the extracted backend message. */ +export function reloadFailureReason(message: string): string { + switch (openErrorKind(message)) { + case 'notFound': + return 'file not found'; + case 'encrypted': + return 'file is encrypted'; + case 'malformed': + return 'file is damaged or not a valid PDF'; + default: + return 'unexpected error'; + } +} + +/** Reloads activeTabId when there is one: the shared path of every reload trigger. */ +export function reloadActiveTab( + dispatch: Dispatch, + getTabs: () => readonly TabState[], + activeTabId: string | null, +): void { + if (activeTabId) void reloadTab(dispatch, getTabs, activeTabId); +} + +/** Pill text after a successful reload of fileName. */ +export function reloadedMessage(fileName: string): string { + return `${fileName} reloaded - view reset`; +} + +/** + * Reloads tabId from disk. The tab is replaced only after the new parse fully + * succeeds. getTabs must return the live tab list: when the tab left it while + * the parse ran, the new document is closed and nothing changes. A reload + * that lands after the user switched tabs replaces the tab without taking + * focus. A busy file shows the pill; any other failure sets the document error + * and keeps the old tab untouched. + */ +export async function reloadTab( + dispatch: Dispatch, + getTabs: () => readonly TabState[], + tabId: string, +): Promise { + if (reloading.has(tabId)) return; + const tab = getTabs().find((t) => t.tabId === tabId); + if (!tab || !tab.filePath) return; + reloading.add(tabId); + try { + let result: Awaited>; + try { + result = await openPDFFile(tab.filePath); + } catch (err) { + if (!getTabs().some((t) => t.tabId === tabId)) return; + const msg = extractErrorMessage(err); + if (isFileBusyMessage(msg)) { + showReloadToast(`${tab.fileName} is in use by another app - try again`); + return; + } + dispatch({ type: 'SET_DOCUMENT_ERROR', payload: { message: `Could not reload ${tab.fileName}: ${reloadFailureReason(msg)}`, reload: true } }); + return; + } + if (!getTabs().some((t) => t.tabId === tabId)) { + CloseDocument(result.tabId).catch(() => {}); + return; + } + dispatchOpenedDocument(dispatch, getTabs(), { + tabId: result.tabId, + fileName: result.fileName, + filePath: result.filePath, + pageCount: result.pageCount, + rootNode: result.rootNode, + rootChildren: result.rootChildren, + reload: true, + warning: result.warning ?? null, + }); + // The file can change again while the parse runs; the watcher may already + // have reported that for the new id before its tab existed. + markTabFromDiskCheck(result.tabId); + showReloadToast(reloadedMessage(result.fileName || tab.fileName)); + } finally { + reloading.delete(tabId); + } +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 1eade1b..6d7e11f 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -223,6 +223,26 @@ body { --color-error: var(--color-error-on-selected); } +/* Reload confirmation pill: one fade in, hold, one fade out over 3s */ +@keyframes reload-toast { + 0% { opacity: 0; } + 5% { opacity: 1; } + 90% { opacity: 1; } + 100% { opacity: 0; } +} +.reload-toast { + animation: reload-toast 3s ease-in-out forwards; +} + +/* On-disk change dot: one fade-in, no pulse */ +@keyframes disk-dot-in { + from { opacity: 0; } + to { opacity: 1; } +} +.disk-dot-in { + animation: disk-dot-in 150ms ease-out; +} + /* Reduced motion */ @media (prefers-reduced-motion: reduce) { *, @@ -233,4 +253,9 @@ body { transition-duration: 0.001ms !important; scroll-behavior: auto !important; } + /* The pill's fade ends at opacity 0 and holds it; with no fade it stays + fully visible until the store removes it. */ + .reload-toast { + animation: none !important; + } } diff --git a/internal/filewatch/watcher.go b/internal/filewatch/watcher.go new file mode 100644 index 0000000..bf94264 --- /dev/null +++ b/internal/filewatch/watcher.go @@ -0,0 +1,326 @@ +// Package filewatch reports when the file behind an open document changes on +// disk. It polls stat-based status for every open tab from one scheduler +// goroutine and emits an event once a new observation has settled. +package filewatch + +import ( + "errors" + "sync" + "time" + + "unidoc-pdf-debugger/internal/pdfcore" +) + +// Poll intervals while the main window is focused and unfocused. +const ( + focusedInterval = 2 * time.Second + unfocusedInterval = 10 * time.Second +) + +// Source lists open tabs and reports each tab's disk status. A tab whose +// DiskStatus returns pdfcore.ErrDocumentNotFound is dropped. +type Source interface { + TabIDs() []string + DiskStatus(tabID string) (pdfcore.DiskStatus, error) +} + +// Emitter delivers settled status transitions. +type Emitter interface { + Emit(Event) +} + +// Clock arms the next tick. +type Clock interface { + After(d time.Duration) <-chan time.Time +} + +// SystemClock is the Clock backed by time.After. +type SystemClock struct{} + +// After returns time.After(d). +func (SystemClock) After(d time.Duration) <-chan time.Time { return time.After(d) } + +// Event is one settled disk-status transition for a tab. Seq increases with +// every emit so receivers can drop events that arrive out of order. +type Event struct { + TabID string `json:"tabId"` + Seq int64 `json:"seq"` + State string `json:"state"` + LoadedModTime int64 `json:"loadedModTime"` + DiskModTime int64 `json:"diskModTime"` + DiskSize int64 `json:"diskSize"` +} + +// tabState is the scheduler-owned settle state of one tab. +type tabState struct { + lastReported pdfcore.DiskStatus + // candidate is the latest observation. stateStreak counts consecutive + // observations with its State; sameStreak counts consecutive observations + // equal to it in every field. + candidate pdfcore.DiskStatus + stateStreak int + sameStreak int + // resync makes the next settled observation emit even when it repeats + // lastReported, so the frontend gets the watcher's view after a recorded + // GetDiskStatus result it may have dropped. + resync bool + inFlight bool +} + +type statResult struct { + tabID string + st pdfcore.DiskStatus + err error +} + +// Watcher polls every open tab's disk status. All settle state is owned by +// the scheduler goroutine; SetFocused and RecordReported only queue input for +// it and never block. +type Watcher struct { + src Source + emit Emitter + clk Clock + + results chan statResult + wake chan struct{} + stop chan struct{} + done chan struct{} + + mu sync.Mutex + focused bool + focusDirty bool + focusGained bool + records map[string]pdfcore.DiskStatus + started bool + stopOnce sync.Once +} + +// New returns a Watcher that starts in focused mode. Call Start to run it. +func New(src Source, emit Emitter, clk Clock) *Watcher { + return &Watcher{ + src: src, + emit: emit, + clk: clk, + results: make(chan statResult), + wake: make(chan struct{}, 1), + stop: make(chan struct{}), + done: make(chan struct{}), + focused: true, + records: map[string]pdfcore.DiskStatus{}, + } +} + +// Start launches the scheduler goroutine. Calling it more than once has no +// further effect. +func (w *Watcher) Start() { + w.mu.Lock() + defer w.mu.Unlock() + if w.started { + return + } + w.started = true + go w.run() +} + +// Stop ends the scheduler and waits for it to exit. It never waits for stat +// calls still in flight: a stat on a dead network share cannot be cancelled. +func (w *Watcher) Stop() { + w.stopOnce.Do(func() { close(w.stop) }) + w.mu.Lock() + started := w.started + w.mu.Unlock() + if started { + <-w.done + } +} + +// SetFocused switches between the focused and unfocused interval. The last +// call wins, so callers pass the window's current focus state rather than the +// event that fired. A call with focused true checks every tab immediately, +// even when a later call already took focus away again. +func (w *Watcher) SetFocused(focused bool) { + w.mu.Lock() + w.focused = focused + w.focusDirty = true + if focused { + w.focusGained = true + } + w.mu.Unlock() + w.signal() +} + +// RecordReported sets the status last reported for tabID, as GetDiskStatus +// reported it to the frontend. The tab's next settled observation is emitted +// even if it repeats that status, so a frontend that dropped the result as +// older than a watcher event still converges on the watcher's view. +func (w *Watcher) RecordReported(tabID string, st pdfcore.DiskStatus) { + w.mu.Lock() + w.records[tabID] = st + w.mu.Unlock() + w.signal() +} + +func (w *Watcher) signal() { + select { + case w.wake <- struct{}{}: + default: + } +} + +func intervalFor(focused bool) time.Duration { + if focused { + return focusedInterval + } + return unfocusedInterval +} + +func (w *Watcher) run() { + defer close(w.done) + tabs := map[string]*tabState{} + var seq int64 + focused := true + tick := w.clk.After(intervalFor(focused)) + + // applyPending takes queued focus changes and recorded statuses before any + // tick or result is handled, so they apply in call order. + applyPending := func() { + w.mu.Lock() + records := w.records + w.records = map[string]pdfcore.DiskStatus{} + newFocus, dirty, gained := w.focused, w.focusDirty, w.focusGained + w.focusDirty, w.focusGained = false, false + w.mu.Unlock() + + for id, st := range records { + ts := tabs[id] + if ts == nil { + ts = &tabState{} + tabs[id] = ts + } + // The recorded status supersedes any half-settled observation, so + // a stat already in flight cannot settle against it. + ts.lastReported = st + ts.candidate = pdfcore.DiskStatus{} + ts.stateStreak, ts.sameStreak = 0, 0 + ts.resync = true + } + if dirty && newFocus != focused { + focused = newFocus + tick = w.clk.After(intervalFor(focused)) + } + // Focus gained and lost again before this wake still counts as a + // focus event. + if gained { + w.checkAll(tabs) + } + } + + for { + select { + case <-w.stop: + return + case <-w.wake: + applyPending() + case <-tick: + applyPending() + w.checkAll(tabs) + tick = w.clk.After(intervalFor(focused)) + case r := <-w.results: + applyPending() + ts := tabs[r.tabID] + if ts == nil { + continue + } + ts.inFlight = false + if errors.Is(r.err, pdfcore.ErrDocumentNotFound) { + delete(tabs, r.tabID) + continue + } + if r.err != nil { + ts.stateStreak, ts.sameStreak = 0, 0 + ts.candidate = pdfcore.DiskStatus{} + continue + } + if ev, ok := settle(ts, r.st); ok { + seq++ + ev.TabID = r.tabID + ev.Seq = seq + w.emit.Emit(ev) + } + } + } +} + +// checkAll reconciles tabs against the source and starts one stat for every +// tab that has none in flight. New tabs start at their Open fingerprint. +func (w *Watcher) checkAll(tabs map[string]*tabState) { + ids := w.src.TabIDs() + live := make(map[string]bool, len(ids)) + for _, id := range ids { + live[id] = true + if tabs[id] == nil { + tabs[id] = &tabState{lastReported: pdfcore.DiskStatus{State: pdfcore.DiskStateCurrent}} + } + } + for id := range tabs { + if !live[id] { + delete(tabs, id) + } + } + for id, ts := range tabs { + if ts.inFlight { + continue + } + ts.inFlight = true + go w.stat(id) + } +} + +// stat runs one source call and hands the result to the scheduler, or drops +// it once the scheduler has stopped. +func (w *Watcher) stat(tabID string) { + st, err := w.src.DiskStatus(tabID) + select { + case w.results <- statResult{tabID: tabID, st: st, err: err}: + case <-w.stop: + } +} + +// settle records obs and reports whether it completes a transition to emit. +// A state seen on two checks in a row is settled even while its times keep +// changing, so a file rewritten faster than the poll interval is still +// reported. Once settled, a change of state emits; within the same state, new +// times emit only after two identical observations, so a file being written +// produces one change event and one refresh when it stops. Two current +// observations never emit unless a resync is pending. +func settle(ts *tabState, obs pdfcore.DiskStatus) (Event, bool) { + if ts.stateStreak > 0 && obs.State == ts.candidate.State { + ts.stateStreak++ + if obs == ts.candidate { + ts.sameStreak++ + } else { + ts.sameStreak = 1 + } + } else { + ts.stateStreak, ts.sameStreak = 1, 1 + } + ts.candidate = obs + if ts.stateStreak < 2 { + return Event{}, false + } + last := ts.lastReported + emit := ts.resync || + obs.State != last.State || + (obs.State != pdfcore.DiskStateCurrent && ts.sameStreak >= 2 && obs != last) + if !emit { + return Event{}, false + } + ts.resync = false + ts.lastReported = obs + return Event{ + State: obs.State, + LoadedModTime: obs.LoadedModTime, + DiskModTime: obs.DiskModTime, + DiskSize: obs.DiskSize, + }, true +} diff --git a/internal/filewatch/watcher_test.go b/internal/filewatch/watcher_test.go new file mode 100644 index 0000000..70ce13e --- /dev/null +++ b/internal/filewatch/watcher_test.go @@ -0,0 +1,621 @@ +// Watcher: stat-based change detection for every open document, driven by an +// injected clock, source and emitter. +// +// Contract the tests rely on: +// +// New(src Source, emit Emitter, clk Clock) *Watcher +// Source: TabIDs() []string; DiskStatus(tabID string) (pdfcore.DiskStatus, error) +// Emitter: Emit(Event) Event carries TabID, Seq, State, LoadedModTime, +// DiskModTime, DiskSize (flat or through an embedded +// pdfcore.DiskStatus) and marshals to +// {tabId, seq, state, loadedModTime, diskModTime, diskSize} +// Clock: After(d time.Duration) <-chan time.Time; the scheduler asks for +// the next tick with the current interval +// (*Watcher) Start(), Stop(), SetFocused(bool), RecordReported(tabID, pdfcore.DiskStatus) +// +// One "check" fires a tick and waits until the source has been asked for the +// tab once more. A tick that lands while the previous result for the tab is +// still unapplied starts no stat (in-flight rule), so check fires again until +// a new call starts. +// +// Run: cd code && go test -race ./internal/filewatch/ + +package filewatch + +import ( + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "unidoc-pdf-debugger/internal/pdfcore" +) + +const waitLimit = 3 * time.Second + +// --- fake clock --- + +type fakeClock struct { + mu sync.Mutex + reqs []time.Duration + chans []chan time.Time + fired int // index of the next unfired channel +} + +func (c *fakeClock) After(d time.Duration) <-chan time.Time { + c.mu.Lock() + defer c.mu.Unlock() + ch := make(chan time.Time) + c.reqs = append(c.reqs, d) + c.chans = append(c.chans, ch) + return ch +} + +// lastInterval returns the most recently requested interval, or 0. +func (c *fakeClock) lastInterval() time.Duration { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.reqs) == 0 { + return 0 + } + return c.reqs[len(c.reqs)-1] +} + +// fire delivers one tick on the latest armed channel and returns once the +// scheduler received it. +func (c *fakeClock) fire(t *testing.T) { + t.Helper() + deadline := time.Now().Add(waitLimit) + for time.Now().Before(deadline) { + c.mu.Lock() + var ch chan time.Time + if n := len(c.chans); n > 0 && c.fired < n { + ch = c.chans[n-1] + c.fired = n + } + c.mu.Unlock() + if ch != nil { + select { + case ch <- time.Now(): + return + case <-time.After(waitLimit): + t.Fatalf("scheduler did not take the tick") + } + } + time.Sleep(time.Millisecond) + } + t.Fatalf("scheduler never armed the clock") +} + +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(waitLimit) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +// --- fake source --- + +type answer struct { + st pdfcore.DiskStatus + err error +} + +type fakeSource struct { + mu sync.Mutex + tabs []string + answers map[string]answer + calls map[string]int + gates map[string]chan struct{} + inflight map[string]int + maxIn map[string]int +} + +func newFakeSource(tabs ...string) *fakeSource { + s := &fakeSource{ + tabs: tabs, + answers: map[string]answer{}, + calls: map[string]int{}, + gates: map[string]chan struct{}{}, + inflight: map[string]int{}, + maxIn: map[string]int{}, + } + for _, id := range tabs { + s.answers[id] = answer{st: current()} + } + return s +} + +func (s *fakeSource) TabIDs() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.tabs...) +} + +func (s *fakeSource) DiskStatus(tabID string) (pdfcore.DiskStatus, error) { + s.mu.Lock() + s.calls[tabID]++ + s.inflight[tabID]++ + if s.inflight[tabID] > s.maxIn[tabID] { + s.maxIn[tabID] = s.inflight[tabID] + } + gate := s.gates[tabID] + // The answer is taken when the call starts, so changing it after check + // returned never leaks into a call that already began. + a := s.answers[tabID] + s.mu.Unlock() + if gate != nil { + <-gate + } + s.mu.Lock() + s.inflight[tabID]-- + s.mu.Unlock() + return a.st, a.err +} + +func (s *fakeSource) set(tabID string, st pdfcore.DiskStatus, err error) { + s.mu.Lock() + defer s.mu.Unlock() + s.answers[tabID] = answer{st: st, err: err} +} + +func (s *fakeSource) setTabs(tabs ...string) { + s.mu.Lock() + defer s.mu.Unlock() + s.tabs = tabs +} + +// block makes the next DiskStatus calls for tabID wait until release. +func (s *fakeSource) block(tabID string) (release func()) { + ch := make(chan struct{}) + s.mu.Lock() + s.gates[tabID] = ch + s.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + s.mu.Lock() + delete(s.gates, tabID) + s.mu.Unlock() + close(ch) + }) + } +} + +func (s *fakeSource) callCount(tabID string) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls[tabID] +} + +func (s *fakeSource) maxInflight(tabID string) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.maxIn[tabID] +} + +// --- fake emitter --- + +type emitted struct { + ev Event + callsAtEmit int +} + +type fakeEmitter struct { + mu sync.Mutex + src *fakeSource + events []emitted +} + +func (e *fakeEmitter) Emit(ev Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, emitted{ev: ev, callsAtEmit: e.src.callCount(ev.TabID)}) +} + +func (e *fakeEmitter) all() []emitted { + e.mu.Lock() + defer e.mu.Unlock() + return append([]emitted(nil), e.events...) +} + +func (e *fakeEmitter) forTab(tabID string) []emitted { + var out []emitted + for _, x := range e.all() { + if x.ev.TabID == tabID { + out = append(out, x) + } + } + return out +} + +// --- statuses --- + +const loadedMs = int64(1_700_000_000_000) + +func current() pdfcore.DiskStatus { + return pdfcore.DiskStatus{State: "current", LoadedModTime: loadedMs, DiskModTime: loadedMs, DiskSize: 100} +} + +func changed(size, mtimeMs int64) pdfcore.DiskStatus { + return pdfcore.DiskStatus{State: "changed", LoadedModTime: loadedMs, DiskModTime: mtimeMs, DiskSize: size} +} + +func missing() pdfcore.DiskStatus { + return pdfcore.DiskStatus{State: "missing", LoadedModTime: loadedMs} +} + +// --- harness --- + +type harness struct { + t *testing.T + w *Watcher + clk *fakeClock + src *fakeSource + em *fakeEmitter +} + +func start(t *testing.T, tabs ...string) *harness { + t.Helper() + src := newFakeSource(tabs...) + h := &harness{t: t, clk: &fakeClock{}, src: src, em: &fakeEmitter{src: src}} + h.w = New(src, h.em, h.clk) + h.w.Start() + t.Cleanup(h.w.Stop) + return h +} + +// check makes one more stat of tabID happen and returns the new call count. +func (h *harness) check(tabID string) int { + h.t.Helper() + n := h.src.callCount(tabID) + deadline := time.Now().Add(waitLimit) + for time.Now().Before(deadline) { + h.clk.fire(h.t) + inner := time.Now().Add(20 * time.Millisecond) + for time.Now().Before(inner) { + if c := h.src.callCount(tabID); c > n { + return c + } + time.Sleep(200 * time.Microsecond) + } + } + h.t.Fatalf("no new stat of %s after ticks", tabID) + return 0 +} + +// warm makes sure tabID has been observed at its baseline at least once, so +// later call counts start from a known point. +func (h *harness) warm(tabID string) int { + h.t.Helper() + h.check(tabID) + return h.check(tabID) +} + +func (h *harness) waitEvents(tabID string, n int) []emitted { + h.t.Helper() + waitFor(h.t, "events", func() bool { return len(h.em.forTab(tabID)) >= n }) + return h.em.forTab(tabID) +} + +// --- settle --- + +func TestWatcherSettleReportsChangeAfterTwoIdenticalChecks(t *testing.T) { + h := start(t, "a") + base := h.warm("a") + + h.src.set("a", changed(200, loadedMs+5000), nil) + h.check("a") + h.check("a") + evs := h.waitEvents("a", 1) + if evs[0].ev.State != "changed" { + t.Fatalf("first event state %q, want changed", evs[0].ev.State) + } + if evs[0].callsAtEmit != base+2 { + t.Errorf("changed emitted after check %d, want after the second identical check (%d)", evs[0].callsAtEmit-base, 2) + } + if evs[0].ev.DiskSize != 200 || evs[0].ev.DiskModTime != loadedMs+5000 || evs[0].ev.LoadedModTime != loadedMs { + t.Errorf("event payload %+v does not carry the observed status", evs[0].ev) + } + + // Further identical checks emit nothing; going back to the Open + // fingerprint emits current after two checks. + h.check("a") + h.check("a") + h.src.set("a", current(), nil) + h.check("a") + back := h.check("a") + evs = h.waitEvents("a", 2) + if len(evs) != 2 { + t.Fatalf("events %+v, want exactly changed then current", evs) + } + if evs[1].ev.State != "current" || evs[1].callsAtEmit != back { + t.Errorf("second event %+v at call %d, want current after call %d", evs[1].ev, evs[1].callsAtEmit, back) + } + if evs[1].ev.Seq <= evs[0].ev.Seq { + t.Errorf("seq must increase across emits: %d then %d", evs[0].ev.Seq, evs[1].ev.Seq) + } +} + +// A file rewritten on every check is still reported once its changed state +// has held for two checks; when the writes stop, one refresh carries the final +// size and time. +func TestWatcherSettleReportsFileStillBeingWritten(t *testing.T) { + h := start(t, "a") + base := h.warm("a") + for size := int64(101); size <= 105; size++ { + h.src.set("a", changed(size, loadedMs+size), nil) + h.check("a") + } + h.check("a") // second identical observation of size 105 + evs := h.waitEvents("a", 2) + if len(evs) != 2 { + t.Fatalf("events %+v, want a changed event and one refresh", evs) + } + if evs[0].ev.State != "changed" || evs[0].ev.DiskSize != 102 || evs[0].callsAtEmit != base+2 { + t.Errorf("first event %+v at check %d, want changed with size 102 after check 2", evs[0].ev, evs[0].callsAtEmit-base) + } + if evs[1].ev.State != "changed" || evs[1].ev.DiskSize != 105 || evs[1].callsAtEmit != base+6 { + t.Errorf("refresh %+v at check %d, want changed with size 105 after check 6", evs[1].ev, evs[1].callsAtEmit-base) + } +} + +func TestWatcherSettleReportsMissingAfterTwoChecks(t *testing.T) { + h := start(t, "a") + base := h.warm("a") + h.src.set("a", missing(), nil) + h.check("a") + h.check("a") + evs := h.waitEvents("a", 1) + if evs[0].ev.State != "missing" || evs[0].callsAtEmit != base+2 { + t.Errorf("event %+v at check %d, want missing after check 2", evs[0].ev, evs[0].callsAtEmit-base) + } + if evs[0].ev.DiskSize != 0 || evs[0].ev.DiskModTime != 0 { + t.Errorf("missing carries disk size and time 0, got %+v", evs[0].ev) + } +} + +func TestWatcherStatErrorResetsSettleStreak(t *testing.T) { + h := start(t, "a") + base := h.warm("a") + h.src.set("a", changed(200, loadedMs+1), nil) + h.check("a") + h.src.set("a", pdfcore.DiskStatus{}, errors.New("permission denied")) + h.check("a") + h.src.set("a", changed(200, loadedMs+1), nil) + h.check("a") + h.check("a") + evs := h.waitEvents("a", 1) + if len(evs) != 1 || evs[0].callsAtEmit != base+4 { + t.Errorf("events %+v, want one changed event after check 4 (the error resets the streak)", evs) + } +} + +func TestWatcherRecordedStatusLetsReturnToCurrentEmit(t *testing.T) { + h := start(t, "a") + h.warm("a") + h.w.RecordReported("a", changed(200, loadedMs+1)) + h.check("a") + h.check("a") + evs := h.waitEvents("a", 1) + if len(evs) != 1 || evs[0].ev.State != "current" { + t.Errorf("events %+v, want one current event after a recorded change went back", evs) + } +} + +// A status recorded from GetDiskStatus restarts the settle streak: an older +// observation still counting toward a change cannot complete against it. The +// next settled observation is emitted even though it repeats the recorded +// status, so a frontend that dropped the result converges. +func TestWatcherRecordedStatusRestartsSettleStreak(t *testing.T) { + h := start(t, "a") + h.warm("a") + h.src.set("a", changed(200, loadedMs+1), nil) + h.check("a") + h.w.RecordReported("a", current()) + h.check("a") + h.src.set("a", current(), nil) + h.check("a") + h.check("a") + evs := h.waitEvents("a", 1) + if len(evs) != 1 || evs[0].ev.State != "current" { + t.Errorf("events %+v, want one current resync and no changed", evs) + } + h.check("a") + h.check("a") + if evs := h.em.forTab("a"); len(evs) != 1 { + t.Errorf("events %+v, want nothing more after the resync", evs) + } +} + +func TestWatcherEventJSONShape(t *testing.T) { + h := start(t, "a") + h.warm("a") + h.src.set("a", changed(200, loadedMs+1), nil) + h.check("a") + h.check("a") + evs := h.waitEvents("a", 1) + raw, err := json.Marshal(evs[0].ev) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, key := range []string{"tabId", "seq", "state", "loadedModTime", "diskModTime", "diskSize"} { + if _, ok := m[key]; !ok { + t.Errorf("event JSON %s lacks %q", raw, key) + } + } + if len(m) != 6 { + t.Errorf("event JSON %s must be the flat six-field payload", raw) + } +} + +// --- cadence and focus --- + +func TestWatcherCadenceFollowsFocus(t *testing.T) { + h := start(t, "a", "b") + waitFor(t, "first tick armed", func() bool { return h.clk.lastInterval() != 0 }) + if got := h.clk.lastInterval(); got != 2*time.Second { + t.Errorf("starts focused: interval %v, want 2s", got) + } + + h.w.SetFocused(false) + waitFor(t, "unfocused interval", func() bool { return h.clk.lastInterval() == 10*time.Second }) + + a, b := h.src.callCount("a"), h.src.callCount("b") + h.w.SetFocused(true) + waitFor(t, "focused interval", func() bool { return h.clk.lastInterval() == 2*time.Second }) + waitFor(t, "immediate check of every tab on focus", func() bool { + return h.src.callCount("a") > a && h.src.callCount("b") > b + }) +} + +func TestWatcherFocusTakesCallerValueRegardlessOfOrder(t *testing.T) { + h := start(t, "a") + waitFor(t, "first tick armed", func() bool { return h.clk.lastInterval() != 0 }) + + // A lost-focus handler that runs after a focus handler passes the window's + // current IsFocused value; the last call wins. + h.w.SetFocused(true) + h.w.SetFocused(false) + waitFor(t, "unfocused after the later call", func() bool { return h.clk.lastInterval() == 10*time.Second }) + + h.w.SetFocused(false) + h.w.SetFocused(true) + waitFor(t, "focused after the later call", func() bool { return h.clk.lastInterval() == 2*time.Second }) +} + +// Focus gained and lost again before the scheduler wakes still checks every +// tab: the merged calls leave the focus value unchanged. +func TestWatcherMergedFocusGainStillChecks(t *testing.T) { + h := start(t, "a") + waitFor(t, "first tick armed", func() bool { return h.clk.lastInterval() != 0 }) + n := h.src.callCount("a") + + h.w.mu.Lock() + h.w.focused = true + h.w.focusDirty = true + h.w.focusGained = true + h.w.mu.Unlock() + h.w.signal() + + waitFor(t, "check on the merged focus gain", func() bool { return h.src.callCount("a") > n }) +} + +func TestWatcherChecksBackgroundTabsAndNeverEmitsForUnchangedOnes(t *testing.T) { + h := start(t, "active", "background") + h.warm("background") + h.src.set("background", changed(300, loadedMs+9), nil) + h.check("background") + h.check("background") + evs := h.waitEvents("background", 1) + if evs[0].ev.State != "changed" { + t.Errorf("background tab event %+v, want changed", evs[0].ev) + } + if h.src.callCount("active") == 0 { + t.Errorf("every open tab is checked") + } + if got := h.em.forTab("active"); len(got) != 0 { + t.Errorf("a tab that never left current emits nothing, got %+v", got) + } +} + +func TestWatcherNewTabStartsAtBaselineWithoutEvent(t *testing.T) { + h := start(t, "a") + h.warm("a") + h.src.setTabs("a", "late") + h.src.set("late", current(), nil) + h.check("late") + h.check("late") + h.src.set("a", changed(5, loadedMs+5), nil) + h.check("a") + h.check("a") + h.waitEvents("a", 1) + if got := h.em.forTab("late"); len(got) != 0 { + t.Errorf("a newly opened tab emits nothing at its baseline, got %+v", got) + } +} + +// --- isolation --- + +func TestWatcherBlockedTabDoesNotDelayOthers(t *testing.T) { + h := start(t, "slow", "fast") + h.warm("slow") + h.warm("fast") + release := h.src.block("slow") + t.Cleanup(release) + h.check("slow") // this call hangs + + h.src.set("fast", changed(7, loadedMs+7), nil) + h.check("fast") + h.check("fast") + h.check("fast") + evs := h.waitEvents("fast", 1) + if evs[0].ev.State != "changed" { + t.Errorf("fast tab event %+v, want changed while the slow tab is blocked", evs[0].ev) + } + slowCalls := h.src.callCount("slow") + h.check("fast") + if h.src.callCount("slow") != slowCalls { + t.Errorf("a new stat started for a tab whose stat is still in flight") + } + if got := h.src.maxInflight("slow"); got != 1 { + t.Errorf("max concurrent stats for the blocked tab = %d, want 1", got) + } +} + +func TestWatcherDiscardsLateResultForClosedTab(t *testing.T) { + h := start(t, "gone", "keep") + h.warm("gone") + h.warm("keep") + // One changed observation for "gone"; a second would settle it. + h.src.set("gone", changed(9, loadedMs+9), nil) + h.check("gone") + release := h.src.block("gone") + h.check("gone") // second observation is in flight + + h.src.setTabs("keep") + h.check("keep") // reconcile removes "gone" + release() + + h.src.set("keep", changed(4, loadedMs+4), nil) + h.check("keep") + h.check("keep") + h.check("keep") + h.waitEvents("keep", 1) + if got := h.em.forTab("gone"); len(got) != 0 { + t.Errorf("a result for a closed tab must be discarded, got %+v", got) + } +} + +func TestWatcherStopReturnsWhileStatIsBlocked(t *testing.T) { + src := newFakeSource("a") + clk := &fakeClock{} + w := New(src, &fakeEmitter{src: src}, clk) + w.Start() + release := src.block("a") + defer release() + + h := &harness{t: t, w: w, clk: clk, src: src} + h.check("a") // blocked stat + + done := make(chan struct{}) + go func() { + w.Stop() + close(done) + }() + select { + case <-done: + case <-time.After(waitLimit): + t.Fatalf("Stop must not wait for a blocked stat") + } +} diff --git a/internal/pdfcore/errors.go b/internal/pdfcore/errors.go index adc73c6..763a81f 100644 --- a/internal/pdfcore/errors.go +++ b/internal/pdfcore/errors.go @@ -27,6 +27,14 @@ var ( // generic DictView when iconHint='font' fires on a non-Font dict (the // /Resources /Font resource-map false positive). ErrNotAFont = errors.New("not a font") + // ErrFileChanged indicates the file on disk no longer matches the one + // parsed at Open (size, mtime or file identity differ, or it is gone). + // The text carries the marker the frontend matches. + ErrFileChanged = errors.New("file changed on disk since it was opened") + // ErrFileBusy indicates another process holds the file without read + // sharing or holds a byte-range lock over it (Windows sharing or lock + // violation). The text carries the marker the frontend matches. + ErrFileBusy = errors.New("file is busy: another process has it open") ) // safeCall executes fn inside a panic-recovery wrapper. pdfcpu can panic on diff --git a/internal/pdfcore/filebusy_other.go b/internal/pdfcore/filebusy_other.go new file mode 100644 index 0000000..543a557 --- /dev/null +++ b/internal/pdfcore/filebusy_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package pdfcore + +// isFileBusy is always false off Windows: no other OS reports a sharing +// violation on open or read. +func isFileBusy(error) bool { + return false +} diff --git a/internal/pdfcore/filebusy_other_test.go b/internal/pdfcore/filebusy_other_test.go new file mode 100644 index 0000000..4c88b1d --- /dev/null +++ b/internal/pdfcore/filebusy_other_test.go @@ -0,0 +1,28 @@ +//go:build !windows + +// Busy detection is Windows-only; elsewhere every error is not busy. +// +// Run: cd code && go test -run TestFileBusy ./internal/pdfcore/ + +package pdfcore + +import ( + "errors" + "io/fs" + "syscall" + "testing" +) + +func TestFileBusyIsFalseOffWindows(t *testing.T) { + for _, err := range []error{ + syscall.Errno(32), + syscall.Errno(33), + &fs.PathError{Op: "open", Path: "x.pdf", Err: syscall.EACCES}, + errors.New("boom"), + nil, + } { + if isFileBusy(err) { + t.Errorf("isFileBusy(%v) = true, want false off Windows", err) + } + } +} diff --git a/internal/pdfcore/filebusy_windows.go b/internal/pdfcore/filebusy_windows.go new file mode 100644 index 0000000..243cc22 --- /dev/null +++ b/internal/pdfcore/filebusy_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package pdfcore + +import ( + "errors" + "syscall" +) + +// Windows errnos for a file another process holds without read sharing. +const ( + errnoSharingViolation = syscall.Errno(32) + errnoLockViolation = syscall.Errno(33) +) + +// isFileBusy reports whether err is a Windows sharing or lock violation. +func isFileBusy(err error) bool { + var errno syscall.Errno + if !errors.As(err, &errno) { + return false + } + return errno == errnoSharingViolation || errno == errnoLockViolation +} diff --git a/internal/pdfcore/filebusy_windows_test.go b/internal/pdfcore/filebusy_windows_test.go new file mode 100644 index 0000000..309533c --- /dev/null +++ b/internal/pdfcore/filebusy_windows_test.go @@ -0,0 +1,130 @@ +//go:build windows + +// Windows sharing and lock violations are "busy, try again", not a parse +// failure. +// +// Run: cd code && go test -run TestFileBusy ./internal/pdfcore/ + +package pdfcore + +import ( + "context" + "errors" + "io" + "io/fs" + "strings" + "syscall" + "testing" + "unsafe" +) + +const ( + errorFileNotFound = syscall.Errno(2) + errorSharingViolation = syscall.Errno(32) + errorLockViolation = syscall.Errno(33) +) + +func TestFileBusyRecognisesWindowsErrnos(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"sharing violation in a PathError", &fs.PathError{Op: "open", Path: "x.pdf", Err: errorSharingViolation}, true}, + {"lock violation in a PathError", &fs.PathError{Op: "read", Path: "x.pdf", Err: errorLockViolation}, true}, + {"bare sharing violation", errorSharingViolation, true}, + {"file not found", &fs.PathError{Op: "open", Path: "x.pdf", Err: errorFileNotFound}, false}, + {"unrelated error", errors.New("boom"), false}, + {"nil", nil, false}, + } + for _, tc := range cases { + if got := isFileBusy(tc.err); got != tc.want { + t.Errorf("%s: isFileBusy = %v, want %v", tc.name, got, tc.want) + } + } +} + +// Opening a file another handle holds an exclusive byte-range lock on returns +// ErrFileBusy, not ErrMalformedPDF. +func TestFileBusyOpenOfExclusivelyLockedFile(t *testing.T) { + path := copyFixtureToTemp(t, "minimal.pdf") + lockWholeFile(t, path) + + ins := NewInspector() + _, err := ins.Open("tab-busy", path) + if !errors.Is(err, ErrFileBusy) { + t.Fatalf("Open of a locked file: err = %v, want ErrFileBusy", err) + } + if errors.Is(err, ErrMalformedPDF) { + t.Errorf("a busy file must not read as malformed: %v", err) + } + if !strings.Contains(err.Error(), "file is busy") { + t.Errorf("error text %q must carry the busy marker", err.Error()) + } +} + +// A plain-text read of a file another handle holds an exclusive byte-range +// lock on returns ErrFileBusy, not ErrMalformedPDF. +func TestFileBusyPlainTextReadOfExclusivelyLockedFile(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-busy-read") + lockWholeFile(t, path) + + _, err := ins.GetPlainText(context.Background(), "tab-busy-read") + if !errors.Is(err, ErrFileBusy) { + t.Fatalf("GetPlainText of a locked file: err = %v, want ErrFileBusy", err) + } + if errors.Is(err, ErrMalformedPDF) { + t.Errorf("a busy file must not read as malformed: %v", err) + } +} + +// A signature hole read of a file another handle holds an exclusive +// byte-range lock on is noted as busy on the signature, not a hole mismatch. +func TestFileBusySignaturesHoleReadOfLockedFile(t *testing.T) { + ins, path := openTempCopy(t, "signed.pdf", "tab-busy-sig") + lockWholeFile(t, path) + + list, err := ins.GetSignatures("tab-busy-sig") + if err != nil { + t.Fatalf("GetSignatures of a locked file: %v", err) + } + if len(list.Signatures) == 0 || list.Signatures[0].HoleCheckError != ErrFileBusy.Error() { + t.Fatalf("signatures %+v, want holeCheckError %q", list.Signatures, ErrFileBusy.Error()) + } +} + +// Copying the raw bytes of a file another handle holds an exclusive byte-range +// lock on returns ErrFileBusy. +func TestFileBusyCopyOfLockedFile(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-busy-copy") + lockWholeFile(t, path) + + if _, err := ins.CopyDocumentFile("tab-busy-copy", io.Discard); !errors.Is(err, ErrFileBusy) { + t.Fatalf("CopyDocumentFile of a locked file: err = %v, want ErrFileBusy", err) + } +} + +var procLockFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("LockFileEx") + +// lockWholeFile takes an exclusive byte-range lock on all of path through a +// second handle, held until the test ends. Reads through any other handle then +// fail with a lock violation. A share-mode lock is not used: os.Open sets +// FILE_FLAG_BACKUP_SEMANTICS, which gets past it on an elevated CI runner. +func lockWholeFile(t *testing.T, path string) { + t.Helper() + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(p, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("open file to lock: %v", err) + } + t.Cleanup(func() { _ = syscall.CloseHandle(h) }) + const lockfileFailImmediately, lockfileExclusiveLock = 0x1, 0x2 + var ol syscall.Overlapped + r, _, e := procLockFileEx.Call(uintptr(h), lockfileExclusiveLock|lockfileFailImmediately, 0, 0xFFFFFFFF, 0xFFFFFFFF, uintptr(unsafe.Pointer(&ol))) + if r == 0 { + t.Fatalf("lock file: %v", e) + } +} diff --git a/internal/pdfcore/filechange.go b/internal/pdfcore/filechange.go new file mode 100644 index 0000000..6ce2b4b --- /dev/null +++ b/internal/pdfcore/filechange.go @@ -0,0 +1,161 @@ +package pdfcore + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "time" +) + +// Disk states reported by DiskStatus. +const ( + // DiskStateCurrent means the file on disk matches the Open fingerprint. + DiskStateCurrent = "current" + // DiskStateChanged means the file on disk differs from the Open fingerprint. + DiskStateChanged = "changed" + // DiskStateMissing means no file exists at the opened path any more. + DiskStateMissing = "missing" +) + +// FileFingerprint is the stat-based identity of the opened file, captured at +// Open: size, modification time and file identity. +type FileFingerprint struct { + Size int64 + ModTime time.Time + info os.FileInfo +} + +// newFileFingerprint captures fi. os.SameFile(fi, fi) loads the file ID now: +// on Windows os.Stat stores only the path and loads the ID on the first +// SameFile, which after a rename-over would read the new file's ID. +func newFileFingerprint(fi os.FileInfo) FileFingerprint { + os.SameFile(fi, fi) + return FileFingerprint{Size: fi.Size(), ModTime: fi.ModTime(), info: fi} +} + +// Matches reports whether fi has the fingerprint's size and mtime and is the +// same file. The mtime must be equal, not newer: restores move it backwards. +func (fp FileFingerprint) Matches(fi os.FileInfo) bool { + if fp.info == nil || fi == nil { + return false + } + return fi.Size() == fp.Size && fi.ModTime().Equal(fp.ModTime) && os.SameFile(fp.info, fi) +} + +// DiskStatus reports how the file on disk compares with the Open fingerprint. +// Times are Unix milliseconds; DiskModTime and DiskSize are 0 when missing. +type DiskStatus struct { + State string `json:"state"` + LoadedModTime int64 `json:"loadedModTime"` + DiskModTime int64 `json:"diskModTime"` + DiskSize int64 `json:"diskSize"` +} + +// openVerified opens path for reading and checks the handle against fp. A +// missing file or a mismatch returns ErrFileChanged (wrapping fs.ErrNotExist +// for a missing file), a file another process holds without read sharing +// returns ErrFileBusy, and other open errors are returned as is. The caller +// closes the returned file. +func openVerified(path string, fp FileFingerprint) (*os.File, error) { + f, err := os.Open(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%w: %w", ErrFileChanged, err) + } + if isFileBusy(err) { + return nil, fmt.Errorf("%w: %w", ErrFileBusy, err) + } + return nil, err + } + if err := verifyHandle(f, fp); err != nil { + _ = f.Close() + return nil, err + } + return f, nil +} + +// verifyHandle returns ErrFileChanged when the open handle no longer matches fp. +func verifyHandle(f *os.File, fp FileFingerprint) error { + fi, err := f.Stat() + if err != nil { + return err + } + if !fp.Matches(fi) { + return ErrFileChanged + } + return nil +} + +// TabIDs returns the tab IDs of every open document, in no particular order. +func (ins *Inspector) TabIDs() []string { + ins.mu.Lock() + defer ins.mu.Unlock() + ids := make([]string, 0, len(ins.documents)) + for id := range ins.documents { + ids = append(ids, id) + } + return ids +} + +// DiskStatus stats the file behind tabID and compares it with the Open +// fingerprint. ins.mu is held only for the lookup; the stat runs outside every +// lock. A missing file is a status, not an error. Unknown tabs return +// ErrDocumentNotFound; other stat errors are returned as is. +func (ins *Inspector) DiskStatus(tabID string) (DiskStatus, error) { + doc, err := ins.GetDocument(tabID) + if err != nil { + return DiskStatus{}, err + } + st := DiskStatus{LoadedModTime: doc.fingerprint.ModTime.UnixMilli()} + fi, err := statWithIdentity(doc.FilePath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + st.State = DiskStateMissing + return st, nil + } + return DiskStatus{}, err + } + st.DiskModTime = fi.ModTime().UnixMilli() + st.DiskSize = fi.Size() + if doc.fingerprint.Matches(fi) { + st.State = DiskStateCurrent + } else { + st.State = DiskStateChanged + } + return st, nil +} + +// CopyDocumentFile writes the bytes of the file behind tabID to w. The handle +// is checked against the Open fingerprint before the first byte and again +// after the last: a file that changed or is gone returns ErrFileChanged +// (before anything is written when the change came first), and a read refused +// by another process's lock returns ErrFileBusy. Any other error is the copy's. +func (ins *Inspector) CopyDocumentFile(tabID string, w io.Writer) (int64, error) { + doc, err := ins.GetDocument(tabID) + if err != nil { + return 0, err + } + f, err := openVerified(doc.FilePath, doc.fingerprint) + if err != nil { + return 0, err + } + defer func() { _ = f.Close() }() + n, err := io.Copy(w, busyReader{f}) + if err != nil { + return n, err + } + return n, verifyHandle(f, doc.fingerprint) +} + +// busyReader reports a read refused by another process's lock as ErrFileBusy. +type busyReader struct{ f *os.File } + +func (r busyReader) Read(p []byte) (int, error) { + n, err := r.f.Read(p) + if err != nil && isFileBusy(err) { + err = fmt.Errorf("%w: %w", ErrFileBusy, err) + } + return n, err +} diff --git a/internal/pdfcore/filechange_test.go b/internal/pdfcore/filechange_test.go new file mode 100644 index 0000000..b6102ee --- /dev/null +++ b/internal/pdfcore/filechange_test.go @@ -0,0 +1,555 @@ +// Stale-read guard and on-disk status: reads of the opened file after Open +// refuse bytes that no longer belong to the parsed document, and DiskStatus +// reports how the file on disk compares with the Open-time fingerprint. +// +// Run: cd code && go test -race -run 'TestFileChange|TestDiskStatus|TestTabIDs' ./internal/pdfcore/ + +package pdfcore + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + "time" +) + +// copyFixtureToTemp copies testdata/ into a fresh temp dir and returns +// the copy's path. +func copyFixtureToTemp(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(testdataDir(t), name)) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write temp copy: %v", err) + } + return path +} + +// openTempCopy opens a temp copy of the named fixture under tabID. +func openTempCopy(t *testing.T, name, tabID string) (*Inspector, string) { + t.Helper() + path := copyFixtureToTemp(t, name) + ins := NewInspector() + if _, err := ins.Open(tabID, path); err != nil { + t.Fatalf("open %s: %v", path, err) + } + t.Cleanup(func() { _ = ins.Close(tabID) }) + return ins, path +} + +// overwriteWithFixture replaces path's content with another fixture's bytes +// (a different size) and moves the mtime an hour ahead. +func overwriteWithFixture(t *testing.T, path, other string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(testdataDir(t), other)) + if err != nil { + t.Fatalf("read fixture %s: %v", other, err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("overwrite: %v", err) + } + later := time.Now().Add(time.Hour) + if err := os.Chtimes(path, later, later); err != nil { + t.Fatalf("chtimes: %v", err) + } +} + +// renameOverWithSameBytes writes the file's own bytes to a sibling path, gives +// the sibling the original mtime and renames it over path. Size and mtime +// match the Open fingerprint; only the file identity differs. +func renameOverWithSameBytes(t *testing.T, path string) { + t.Helper() + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + sibling := path + ".new" + if err := os.WriteFile(sibling, data, 0o644); err != nil { + t.Fatalf("write sibling: %v", err) + } + if err := os.Chtimes(sibling, fi.ModTime(), fi.ModTime()); err != nil { + t.Fatalf("chtimes sibling: %v", err) + } + if err := os.Rename(sibling, path); err != nil { + t.Fatalf("rename over: %v", err) + } + after, err := os.Stat(path) + if err != nil { + t.Fatalf("stat after rename: %v", err) + } + if after.Size() != fi.Size() || !after.ModTime().Equal(fi.ModTime()) { + t.Fatalf("rename-over setup must keep size and mtime: size %d->%d mtime %v->%v", fi.Size(), after.Size(), fi.ModTime(), after.ModTime()) + } +} + +func TestFileChangeSentinelsCarryFrontendMarkers(t *testing.T) { + if !strings.Contains(ErrFileChanged.Error(), "file changed on disk") { + t.Errorf("ErrFileChanged text %q must contain the marker %q", ErrFileChanged.Error(), "file changed on disk") + } + if !strings.Contains(ErrFileBusy.Error(), "file is busy") { + t.Errorf("ErrFileBusy text %q must contain the marker %q", ErrFileBusy.Error(), "file is busy") + } +} + +func TestFileChangePlainTextRefusesOverwrittenFileAndCachesNothing(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-pt-overwrite") + original, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + overwriteWithFixture(t, path, "multipage.pdf") + doc, err := ins.GetPlainText(t.Context(), "tab-pt-overwrite") + if !errors.Is(err, ErrFileChanged) { + t.Fatalf("GetPlainText after overwrite: err = %v, want ErrFileChanged", err) + } + if doc != nil { + t.Errorf("GetPlainText after overwrite returned a document; no bytes may be returned") + } + if errors.Is(err, ErrMalformedPDF) { + t.Errorf("ErrFileChanged must not be reclassified as ErrMalformedPDF: %v", err) + } + + // Restoring the Open-time bytes and mtime makes the next call succeed, + // which proves the refused read was not cached. + if err := os.WriteFile(path, original, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, fi.ModTime(), fi.ModTime()); err != nil { + t.Fatal(err) + } + doc, err = ins.GetPlainText(t.Context(), "tab-pt-overwrite") + if err != nil { + t.Fatalf("GetPlainText after restoring the original file: %v", err) + } + if doc.TotalBytes != int64(len(original)) { + t.Errorf("TotalBytes = %d, want %d (the original file)", doc.TotalBytes, len(original)) + } +} + +func TestFileChangePlainTextRefusesDeletedFile(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-pt-deleted") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + doc, err := ins.GetPlainText(t.Context(), "tab-pt-deleted") + if !errors.Is(err, ErrFileChanged) { + t.Errorf("err = %v, want ErrFileChanged", err) + } + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("err = %v, want it to wrap fs.ErrNotExist too", err) + } + if doc != nil { + t.Errorf("a deleted file must return no document") + } +} + +// An in-place rewrite that keeps the size, the mtime and the file identity is +// not detected: the guard is stat-only by design. This case renames a new file +// over the path instead, so only the file identity differs. +func TestFileChangePlainTextRefusesRenameOver(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-pt-rename") + renameOverWithSameBytes(t, path) + _, err := ins.GetPlainText(t.Context(), "tab-pt-rename") + if !errors.Is(err, ErrFileChanged) { + t.Errorf("rename-over with equal size and mtime: err = %v, want ErrFileChanged (file identity differs)", err) + } +} + +func TestFileChangePlainTextReturnsCachedResultAfterLaterChange(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-pt-cached") + first, err := ins.GetPlainText(t.Context(), "tab-pt-cached") + if err != nil { + t.Fatalf("first read: %v", err) + } + overwriteWithFixture(t, path, "multipage.pdf") + second, err := ins.GetPlainText(t.Context(), "tab-pt-cached") + if err != nil { + t.Fatalf("a read verified before the change is served from cache: %v", err) + } + if second != first { + t.Errorf("cached result must be returned as is") + } +} + +// A changed file keeps every signature's parsed details; only the hole check, +// the one part that reads the file, is reported as not checked. +func TestFileChangeSignaturesNoteChangedFile(t *testing.T) { + ins, path := openTempCopy(t, "signed.pdf", "tab-sig-changed") + overwriteWithFixture(t, path, "minimal.pdf") + list, err := ins.GetSignatures("tab-sig-changed") + if err != nil { + t.Fatalf("err = %v, want the list with a per-signature note", err) + } + if len(list.Signatures) == 0 { + t.Fatalf("want the parsed signatures, got none") + } + sig := list.Signatures[0] + if sig.HoleCheckError != ErrFileChanged.Error() { + t.Errorf("holeCheckError = %q, want %q", sig.HoleCheckError, ErrFileChanged.Error()) + } + if sig.HoleMatchesContents { + t.Errorf("an unchecked hole must not report a match") + } + if !sig.Signed || sig.SubFilter == "" || len(sig.ByteRange) != 4 { + t.Errorf("parsed details must stay: %+v", sig) + } +} + +func TestFileChangeSignaturesNoteDeletedFile(t *testing.T) { + ins, path := openTempCopy(t, "signed.pdf", "tab-sig-deleted") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + list, err := ins.GetSignatures("tab-sig-deleted") + if err != nil { + t.Fatalf("err = %v, want the list with a per-signature note", err) + } + if len(list.Signatures) == 0 || list.Signatures[0].HoleCheckError != ErrFileChanged.Error() { + t.Errorf("signatures %+v, want holeCheckError %q", list.Signatures, ErrFileChanged.Error()) + } +} + +// beforeFileSpanRead runs right before readFileSpan opens the file; it +// rewrites the file to simulate a save landing during the walk. +func TestFileChangeSignaturesNoteChangeDuringWalk(t *testing.T) { + ins, path := openTempCopy(t, "signed.pdf", "tab-sig-midwalk") + called := false + beforeFileSpanRead = func(p string) { + if called { + return + } + called = true + later := time.Now().Add(time.Hour) + if err := os.Chtimes(p, later, later); err != nil { + t.Errorf("chtimes in hook: %v", err) + } + } + t.Cleanup(func() { beforeFileSpanRead = nil }) + + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + list, err := ins.GetSignatures("tab-sig-midwalk") + if !called { + t.Fatalf("the span-read hook never ran; signed.pdf must reach the hole read") + } + if err != nil { + t.Fatalf("change during the walk: err = %v, want the list with a note", err) + } + if len(list.Signatures) == 0 || list.Signatures[0].HoleCheckError != ErrFileChanged.Error() { + t.Fatalf("signatures %+v, want holeCheckError %q", list.Signatures, ErrFileChanged.Error()) + } + + // The note is per call: once the Open-time mtime is back, the next walk + // checks the hole again. + if err := os.Chtimes(path, fi.ModTime(), fi.ModTime()); err != nil { + t.Fatal(err) + } + list, err = ins.GetSignatures("tab-sig-midwalk") + if err != nil { + t.Fatalf("GetSignatures after restoring the mtime: %v", err) + } + if len(list.Signatures) == 0 || !list.Signatures[0].HoleMatchesContents || list.Signatures[0].HoleCheckError != "" { + t.Errorf("restored file must report a matching hole again: %+v", list.Signatures) + } +} + +// A document without signed fields never reads the file, so a change on disk +// does not refuse the walk: it reports the same empty list as before. +func TestFileChangeSignaturesIgnoreChangeOnUnsignedFile(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-sig-unsigned") + overwriteWithFixture(t, path, "multipage.pdf") + list, err := ins.GetSignatures("tab-sig-unsigned") + if err != nil { + t.Fatalf("err = %v, want nil for a changed file without signatures", err) + } + if list == nil || len(list.Signatures) != 0 { + t.Errorf("want an empty signature list, got %+v", list) + } +} + +// changeOnFirstErrCtx moves the file's mtime the first time Err is called. +// readPlainText calls Err before each chunk, after the handle passed its +// first check, so the change lands while the read is under way. +type changeOnFirstErrCtx struct { + context.Context + t *testing.T + path string + done bool +} + +func (c *changeOnFirstErrCtx) Err() error { + if !c.done { + c.done = true + later := time.Now().Add(time.Hour) + if err := os.Chtimes(c.path, later, later); err != nil { + c.t.Errorf("chtimes during read: %v", err) + } + } + return c.Context.Err() +} + +func TestFileChangePlainTextRefusesChangeDuringRead(t *testing.T) { + path := copyFixtureToTemp(t, "minimal.pdf") + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + ctx := &changeOnFirstErrCtx{Context: t.Context(), t: t, path: path} + doc, err := readPlainText(ctx, path, fi.Size(), newFileFingerprint(fi), "tab-pt-during") + if !ctx.done { + t.Fatalf("the read never checked its context; the change was not injected") + } + if !errors.Is(err, ErrFileChanged) { + t.Fatalf("change during the read: err = %v, want ErrFileChanged", err) + } + if doc != nil { + t.Errorf("a change during the read must return no document") + } +} + +func TestFileChangeSignaturesUnchangedFileReportsAsBefore(t *testing.T) { + ins, _ := openTempCopy(t, "signed.pdf", "tab-sig-same") + got, err := ins.GetSignatures("tab-sig-same") + if err != nil { + t.Fatalf("unchanged file: %v", err) + } + ref := NewInspector() + if _, err := ref.Open("ref", filepath.Join(testdataDir(t), "signed.pdf")); err != nil { + t.Fatal(err) + } + defer func() { _ = ref.Close("ref") }() + want, err := ref.GetSignatures("ref") + if err != nil { + t.Fatal(err) + } + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) + if string(gotJSON) != string(wantJSON) { + t.Errorf("unchanged copy reports differently from the fixture:\n got %s\nwant %s", gotJSON, wantJSON) + } + if len(got.Signatures) == 0 || !got.Signatures[0].HoleMatchesContents { + t.Errorf("signed.pdf must still report a matching hole: %+v", got.Signatures) + } +} + +func TestTabIDsListsOpenDocuments(t *testing.T) { + ins := NewInspector() + for _, id := range []string{"tab-a", "tab-b"} { + if _, err := ins.Open(id, filepath.Join(testdataDir(t), "minimal.pdf")); err != nil { + t.Fatal(err) + } + } + ids := ins.TabIDs() + slices.Sort(ids) + if !slices.Equal(ids, []string{"tab-a", "tab-b"}) { + t.Errorf("TabIDs = %v, want [tab-a tab-b]", ids) + } + _ = ins.Close("tab-a") + if ids := ins.TabIDs(); !slices.Equal(ids, []string{"tab-b"}) { + t.Errorf("TabIDs after closing tab-a = %v, want [tab-b]", ids) + } + _ = ins.Close("tab-b") +} + +func TestDiskStatusCurrentChangedMissing(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-ds") + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + loadedMs := fi.ModTime().UnixMilli() + + st, err := ins.DiskStatus("tab-ds") + if err != nil { + t.Fatalf("DiskStatus: %v", err) + } + if st.State != "current" || st.LoadedModTime != loadedMs || st.DiskModTime != loadedMs || st.DiskSize != fi.Size() { + t.Errorf("fresh open: %+v, want current with loaded=disk=%d size=%d", st, loadedMs, fi.Size()) + } + + overwriteWithFixture(t, path, "multipage.pdf") + nfi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + st, err = ins.DiskStatus("tab-ds") + if err != nil { + t.Fatalf("DiskStatus after overwrite: %v", err) + } + if st.State != "changed" || st.LoadedModTime != loadedMs || st.DiskModTime != nfi.ModTime().UnixMilli() || st.DiskSize != nfi.Size() { + t.Errorf("after overwrite: %+v, want changed loaded=%d disk=%d size=%d", st, loadedMs, nfi.ModTime().UnixMilli(), nfi.Size()) + } + + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + st, err = ins.DiskStatus("tab-ds") + if err != nil { + t.Fatalf("a missing file is a status, not an error: %v", err) + } + if st.State != "missing" || st.DiskModTime != 0 || st.DiskSize != 0 || st.LoadedModTime != loadedMs { + t.Errorf("after delete: %+v, want missing with disk time and size 0", st) + } +} + +func TestDiskStatusDetectsRenameOver(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-ds-rename") + renameOverWithSameBytes(t, path) + st, err := ins.DiskStatus("tab-ds-rename") + if err != nil { + t.Fatal(err) + } + if st.State != "changed" { + t.Errorf("rename-over: state %q, want changed", st.State) + } +} + +// An in-place append whose mtime is set back to the Open value (a copy tool +// that preserves times, or a write inside the filesystem's mtime granularity) +// keeps the file identity and the mtime; only the size differs. +func TestFileChangeSizeOnlyChangeIsDetected(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-size-only") + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString("%appended\n"); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, fi.ModTime(), fi.ModTime()); err != nil { + t.Fatal(err) + } + after, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(fi, after) || !after.ModTime().Equal(fi.ModTime()) || after.Size() == fi.Size() { + t.Fatalf("setup must keep identity and mtime and change only the size: size %d->%d mtime %v->%v", fi.Size(), after.Size(), fi.ModTime(), after.ModTime()) + } + + st, err := ins.DiskStatus("tab-size-only") + if err != nil { + t.Fatal(err) + } + if st.State != DiskStateChanged || st.DiskSize != after.Size() { + t.Errorf("DiskStatus after a size-only change: %+v, want changed with diskSize %d", st, after.Size()) + } + if _, err := ins.GetPlainText(t.Context(), "tab-size-only"); !errors.Is(err, ErrFileChanged) { + t.Errorf("GetPlainText after a size-only change: err = %v, want ErrFileChanged", err) + } +} + +// A stat that fails for a reason other than a missing file (here a directory +// the process cannot search) is an error, not "missing": the file may still be +// there, so the tab must not be marked as gone. +func TestDiskStatusUnreadableDirectoryIsAnErrorNotMissing(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory search permission is a Unix mode bit") + } + if os.Geteuid() == 0 { + t.Skip("root bypasses directory permissions") + } + ins, path := openTempCopy(t, "minimal.pdf", "tab-ds-eacces") + dir := filepath.Dir(path) + if err := os.Chmod(dir, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + st, err := ins.DiskStatus("tab-ds-eacces") + if err == nil { + t.Fatalf("DiskStatus with an unsearchable directory: %+v, want an error", st) + } + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, ErrDocumentNotFound) { + t.Errorf("err = %v, want a permission error, not not-exist or unknown tab", err) + } + if st.State == DiskStateMissing { + t.Errorf("state = %q; a stat error must not report the file as missing", st.State) + } +} + +func TestDiskStatusUnknownTab(t *testing.T) { + ins := NewInspector() + if _, err := ins.DiskStatus("nope"); !errors.Is(err, ErrDocumentNotFound) { + t.Errorf("err = %v, want ErrDocumentNotFound", err) + } +} + +func TestDiskStatusJSONShape(t *testing.T) { + ins, _ := openTempCopy(t, "minimal.pdf", "tab-ds-json") + st, err := ins.DiskStatus("tab-ds-json") + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, key := range []string{"state", "loadedModTime", "diskModTime", "diskSize"} { + if _, ok := m[key]; !ok { + t.Errorf("DiskStatus JSON %s lacks %q", raw, key) + } + } + for _, key := range []string{"loadedModTime", "diskModTime", "diskSize"} { + if _, ok := m[key].(float64); !ok { + t.Errorf("%s must be a plain number (Unix ms / bytes), got %T in %s", key, m[key], raw) + } + } +} + +// CopyDocumentFile writes the opened file byte for byte while it is unchanged, +// and writes nothing for a file that changed before the copy. +func TestCopyDocumentFileGuardsTheRawBytes(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-copy") + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if n, err := ins.CopyDocumentFile("tab-copy", &buf); err != nil || n != int64(len(want)) || !bytes.Equal(buf.Bytes(), want) { + t.Fatalf("unchanged copy: n=%d err=%v equal=%v", n, err, bytes.Equal(buf.Bytes(), want)) + } + + overwriteWithFixture(t, path, "multipage.pdf") + buf.Reset() + if _, err := ins.CopyDocumentFile("tab-copy", &buf); !errors.Is(err, ErrFileChanged) { + t.Errorf("changed copy: err = %v, want ErrFileChanged", err) + } + if buf.Len() != 0 { + t.Errorf("changed copy wrote %d bytes, want none", buf.Len()) + } +} diff --git a/internal/pdfcore/filestat_other.go b/internal/pdfcore/filestat_other.go new file mode 100644 index 0000000..7eb4271 --- /dev/null +++ b/internal/pdfcore/filestat_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package pdfcore + +import "os" + +// statWithIdentity is os.Stat: off Windows the stat already carries the +// device and inode that os.SameFile compares. +func statWithIdentity(path string) (os.FileInfo, error) { + return os.Stat(path) +} diff --git a/internal/pdfcore/filestat_windows.go b/internal/pdfcore/filestat_windows.go new file mode 100644 index 0000000..dba6445 --- /dev/null +++ b/internal/pdfcore/filestat_windows.go @@ -0,0 +1,32 @@ +//go:build windows + +package pdfcore + +import ( + "io/fs" + "os" + "syscall" +) + +// fileReadAttributes is FILE_READ_ATTRIBUTES: enough to query the handle, and +// never refused by another process's share mode. +const fileReadAttributes = 0x80 + +// statWithIdentity stats path through one attributes-only handle, so the +// FileInfo already carries the file ID and os.SameFile opens nothing more. +// os.Stat would load the ID lazily, with a second open per comparison. +func statWithIdentity(path string) (os.FileInfo, error) { + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + return nil, &fs.PathError{Op: "stat", Path: path, Err: err} + } + h, err := syscall.CreateFile(p, fileReadAttributes, + syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, + nil, syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return nil, &fs.PathError{Op: "stat", Path: path, Err: err} + } + f := os.NewFile(uintptr(h), path) + defer func() { _ = f.Close() }() + return f.Stat() +} diff --git a/internal/pdfcore/filestat_windows_test.go b/internal/pdfcore/filestat_windows_test.go new file mode 100644 index 0000000..964ad2c --- /dev/null +++ b/internal/pdfcore/filestat_windows_test.go @@ -0,0 +1,38 @@ +//go:build windows + +package pdfcore + +import ( + "os" + "path/filepath" + "testing" +) + +// The identity is taken when statWithIdentity runs: a file renamed over the +// path afterwards is not the same file, where os.Stat's lazily loaded ID would +// read the new file and match it. +func TestStatWithIdentityCapturesTheFileIDAtStatTime(t *testing.T) { + path := copyFixtureToTemp(t, "minimal.pdf") + before, err := statWithIdentity(path) + if err != nil { + t.Fatal(err) + } + other := filepath.Join(filepath.Dir(path), "other.pdf") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(other, data, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Rename(other, path); err != nil { + t.Fatal(err) + } + after, err := statWithIdentity(path) + if err != nil { + t.Fatal(err) + } + if os.SameFile(before, after) { + t.Errorf("a file renamed over the path compares as the same file") + } +} diff --git a/internal/pdfcore/inspector.go b/internal/pdfcore/inspector.go index 22b21da..16f4756 100644 --- a/internal/pdfcore/inspector.go +++ b/internal/pdfcore/inspector.go @@ -3,7 +3,9 @@ package pdfcore import ( "cmp" "context" + "errors" "fmt" + "io/fs" "os" "path/filepath" "slices" @@ -54,6 +56,11 @@ type DocumentState struct { // moved or deleted after Open, this value is unchanged. FileSize int64 + // fingerprint is the size, mtime and file identity of FilePath from the + // pre-parse stat at Open. Reads of the file after Open check their handle + // against it and refuse bytes from a file that changed. + fingerprint FileFingerprint + // pdfMu serializes pdfcpu access for this DocumentState. Acquired by every // Inspector.GetX method that calls into pdfcpu, immediately after // GetDocument returns. Methods that do NOT call pdfcpu (GetPlainText, @@ -135,6 +142,9 @@ func (ins *Inspector) Open(tabID, filePath string) (*DocumentInfo, error) { return nil, fmt.Errorf("%w: empty file path", ErrDocumentNotFound) } + // The fingerprint comes from the stat before the parse. A save that lands + // during the parse leaves the document reading as changed, so the read + // guard refuses its bytes and the watcher marks the tab. fi, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { @@ -145,7 +155,7 @@ func (ins *Inspector) Open(tabID, filePath string) (*DocumentInfo, error) { if fi.IsDir() { return nil, fmt.Errorf("%w: path is a directory", ErrDocumentNotFound) } - fileSize := fi.Size() + fingerprint := newFileFingerprint(fi) var ctx *pdfcpu_model.Context err = safeCall(func() error { @@ -154,8 +164,17 @@ func (ins *Inspector) Open(tabID, filePath string) (*DocumentInfo, error) { return e }) if err != nil { + // The file vanished between the stat and the parse (a delete-then-write + // save), or another process holds it without read sharing. + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%w: %w", ErrDocumentNotFound, err) + } + if isFileBusy(err) { + return nil, fmt.Errorf("%w: %w", ErrFileBusy, err) + } return nil, wrapPDFError(err) } + fileSize := fi.Size() var warning string pageCount := 0 @@ -181,6 +200,8 @@ func (ins *Inspector) Open(tabID, filePath string) (*DocumentInfo, error) { FileSize: fileSize, + fingerprint: fingerprint, + streamCache: make(map[string]*ContentStreamData), closeCtx: closeCtx, diff --git a/internal/pdfcore/plaintext.go b/internal/pdfcore/plaintext.go index 2b376d5..729d666 100644 --- a/internal/pdfcore/plaintext.go +++ b/internal/pdfcore/plaintext.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "os" "strings" ) @@ -67,17 +66,17 @@ func (ins *Inspector) GetPlainText(ctx context.Context, tabID string) (*PlainTex var out *PlainTextDocument err = safeCall(func() error { var e error - out, e = readPlainText(readCtx, doc.FilePath, doc.FileSize, tabID) + out, e = readPlainText(readCtx, doc.FilePath, doc.FileSize, doc.fingerprint, tabID) return e }) if err != nil { - // Bypass wrapPDFError for the context termination sentinels AND - // ErrUnsupportedPDF (the 4 GiB ceiling guard) so errors.Is survives across - // the boundary. The wrapper's %v verb stringifies the inner error and - // severs the chain; a context termination would otherwise read as a - // misleading "malformed PDF", and the ErrUnsupportedPDF sentinel contract - // would break. - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrUnsupportedPDF) { + // Bypass wrapPDFError for the context termination sentinels, + // ErrUnsupportedPDF (the 4 GiB ceiling guard), ErrFileChanged and + // ErrFileBusy so errors.Is survives across the boundary. The wrapper's + // %v verb stringifies the inner error and severs the chain; a context + // termination, a changed file or a busy file would otherwise read as a + // misleading "malformed PDF", and the sentinel contracts would break. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrUnsupportedPDF) || errors.Is(err, ErrFileChanged) || errors.Is(err, ErrFileBusy) { return nil, err } return nil, wrapPDFError(err) @@ -106,11 +105,12 @@ func (ins *Inspector) GetPlainTextSize(tabID string) (int64, error) { // is observed between chunks -- the caller must NOT wrap this through // wrapPDFError. // -// If the underlying file grows between Open and this call, the buffer cap -// under-allocates and append() handles reallocation correctly (the 4 GiB -// ceiling still uses the cached size, so a file that grows past the ceiling -// post-Open is read up to whatever bytes are produced by the read loop). -func readPlainText(ctx context.Context, path string, size int64, tabID string) (*PlainTextDocument, error) { +// The open handle is checked against fp before the first chunk and again after +// the last one, so bytes from a file that changed since Open (or during the +// read), including one that grew or shrank, are never returned; a mismatch or +// a missing file returns ErrFileChanged. A read refused by another process's +// lock returns ErrFileBusy. +func readPlainText(ctx context.Context, path string, size int64, fp FileFingerprint, tabID string) (*PlainTextDocument, error) { totalBytes := size // 4 GiB ceiling: protect 32-bit int overflow + single-allocation OOM. @@ -126,7 +126,7 @@ func readPlainText(ctx context.Context, path string, size int64, tabID string) ( totalBytes = 0 } - f, err := os.Open(path) + f, err := openVerified(path, fp) if err != nil { return nil, err } @@ -152,6 +152,9 @@ func readPlainText(ctx context.Context, path string, size int64, tabID string) ( break } if readErr != nil { + if isFileBusy(readErr) { + return nil, fmt.Errorf("%w: %w", ErrFileBusy, readErr) + } return nil, fmt.Errorf("read %s: %w", path, readErr) } // Defensive: a Reader returning (0, nil) is allowed by io.Reader's @@ -161,6 +164,11 @@ func readPlainText(ctx context.Context, path string, size int64, tabID string) ( } } + // A rewrite that overlapped the read changes the handle's stat. + if err := verifyHandle(f, fp); err != nil { + return nil, err + } + // One final cancel check after the read loop. Closes the race window // where Cancel arrives after the last chunk read but before decode // begins: without this, the load completes "successfully" and a test diff --git a/internal/pdfcore/plaintext_async_test.go b/internal/pdfcore/plaintext_async_test.go index 24c3d9c..3980ffa 100644 --- a/internal/pdfcore/plaintext_async_test.go +++ b/internal/pdfcore/plaintext_async_test.go @@ -320,6 +320,7 @@ func TestGetPlainTextAsyncZeroByteFile(t *testing.T) { ins.mu.Lock() ins.documents[tabID] = &DocumentState{ FilePath: path, + fingerprint: newFileFingerprint(fi), streamCache: make(map[string]*ContentStreamData), closeCtx: closeCtx, closeCancel: closeCancel, diff --git a/internal/pdfcore/signature.go b/internal/pdfcore/signature.go index ff92597..80ccf23 100644 --- a/internal/pdfcore/signature.go +++ b/internal/pdfcore/signature.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "math/big" - "os" "strings" "time" @@ -94,6 +93,10 @@ type SignatureField struct { HoleMatchesContents bool `json:"holeMatchesContents"` // CoverageError is the malformed-/ByteRange degradation note. CoverageError string `json:"coverageError"` + // HoleCheckError is set when the hole could not be read from disk: the + // ErrFileChanged or ErrFileBusy text. HoleMatchesContents is then false + // and means "not checked"; every other field comes from the parse. + HoleCheckError string `json:"holeCheckError"` } // SignatureList is the document-level enumeration of signature fields in @@ -117,7 +120,11 @@ const maxHoleProbeBytes = 4 * 1024 * 1024 // each CMS-based /Contents into signer/chain/algorithm facts, and measures // /ByteRange coverage against the file. Per-signature failures degrade to // per-entry error fields; the call itself fails only for unknown tabs or a -// document-level pdfcpu failure. Runs under doc.pdfMu and inside safeCall. +// document-level pdfcpu failure. Only the hole check reads the file: a read +// refused because the file changed (ErrFileChanged) or is locked by another +// process (ErrFileBusy) sets HoleCheckError on that signature, and later +// signatures carry the same note without reading. Runs under doc.pdfMu and +// inside safeCall. func (ins *Inspector) GetSignatures(tabID string) (*SignatureList, error) { doc, err := ins.GetDocument(tabID) if err != nil { @@ -139,6 +146,12 @@ func (ins *Inspector) GetSignatures(tabID string) (*SignatureList, error) { return list, nil } +// holeRead carries the first refused hole read of one GetSignatures walk, so +// later signatures skip the read and carry the same note. +type holeRead struct { + refused error +} + // collectSignatures walks /AcroForm /Fields in document order. Caller MUST // hold doc.pdfMu and run inside safeCall. func collectSignatures(doc *DocumentState) []SignatureField { @@ -151,8 +164,9 @@ func collectSignatures(doc *DocumentState) []SignatureField { if acro == nil { return sigs } + hr := &holeRead{} for _, elem := range dereferenceArray(doc, acro["Fields"]) { - walkSignatureField(doc, elem, "", "", 0, &sigs) + walkSignatureField(doc, hr, elem, "", "", 0, &sigs) } return sigs } @@ -161,7 +175,7 @@ func collectSignatures(doc *DocumentState) []SignatureField { // kid walk (inheritedFT); a kid carrying its own /T is a child FIELD to // recurse into, while /T-less kids are widget annotations of THIS field. // Terminal fields whose effective /FT is Sig are appended to out. -func walkSignatureField(doc *DocumentState, obj pdfcpu_types.Object, parentName, inheritedFT string, depth int, out *[]SignatureField) { +func walkSignatureField(doc *DocumentState, hr *holeRead, obj pdfcpu_types.Object, parentName, inheritedFT string, depth int, out *[]SignatureField) { if depth > maxNodeIDDepth { return } @@ -194,20 +208,20 @@ func walkSignatureField(doc *DocumentState, obj pdfcpu_types.Object, parentName, continue } if _, hasT := kd["T"]; hasT { - walkSignatureField(doc, kid, name, ft, depth+1, out) + walkSignatureField(doc, hr, kid, name, ft, depth+1, out) recursed = true } } if recursed || ft != "Sig" { return } - *out = append(*out, buildSignatureField(doc, d, name, fieldNodeID)) + *out = append(*out, buildSignatureField(doc, hr, d, name, fieldNodeID)) } // buildSignatureField distills one terminal signature field into its // structural facts: /V resolution, dictionary entries, coverage measurement, // and per-SubFilter decomposition routing. -func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fieldNodeID string) SignatureField { +func buildSignatureField(doc *DocumentState, hr *holeRead, field pdfcpu_types.Dict, name, fieldNodeID string) SignatureField { f := SignatureField{ FieldName: name, FieldNodeID: fieldNodeID, @@ -256,7 +270,7 @@ func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fiel f.Location = stringValue(dereference(doc, sig["Location"])) f.ContactInfo = stringValue(dereference(doc, sig["ContactInfo"])) - computeByteRangeCoverage(doc, sig, &f) + computeByteRangeCoverage(doc, hr, sig, &f) switch { case isCMSSubFilter(f.SubFilter): @@ -551,7 +565,7 @@ func stripPDFWhitespace(s string) string { // coverage, the trailing gap, and whether the excluded hole exactly equals the // /Contents hex-string extent. Malformed arrays degrade to CoverageError. This // is explicitly a measurement, never a validity verdict. -func computeByteRangeCoverage(doc *DocumentState, sig pdfcpu_types.Dict, f *SignatureField) { +func computeByteRangeCoverage(doc *DocumentState, hr *holeRead, sig pdfcpu_types.Dict, f *SignatureField) { brObj, ok := sig["ByteRange"] if !ok { f.CoverageError = "/ByteRange missing" @@ -608,40 +622,73 @@ func computeByteRangeCoverage(doc *DocumentState, sig pdfcpu_types.Dict, f *Sign } f.TrailingGap = size - (start2 + len2) f.CoversWholeFile = start1 == 0 && f.TrailingGap == 0 - f.HoleMatchesContents = holeMatchesContents(doc, sig, holeStart, start2) + match, err := holeMatchesContents(doc, hr, sig, holeStart, start2) + f.HoleMatchesContents = match + if err != nil { + f.HoleCheckError = err.Error() + } } // holeMatchesContents reads the excluded span from disk and reports whether it -// is exactly the /Contents hex string: '<' + the dict's hex payload + '>'. -func holeMatchesContents(doc *DocumentState, sig pdfcpu_types.Dict, holeStart, holeEnd int64) bool { +// is exactly the /Contents hex string: '<' + the dict's hex payload + '>'. A +// file that changed or is locked by another process returns ErrFileChanged or +// ErrFileBusy (the bare sentinel) and is recorded in hr, so later holes are +// not read; any other read failure is a mismatch. +func holeMatchesContents(doc *DocumentState, hr *holeRead, sig pdfcpu_types.Dict, holeStart, holeEnd int64) (bool, error) { hx, ok := dereference(doc, sig["Contents"]).(pdfcpu_types.HexLiteral) if !ok { - return false + return false, nil } if holeEnd <= holeStart || holeEnd-holeStart > maxHoleProbeBytes { - return false + return false, nil + } + if hr.refused != nil { + return false, hr.refused + } + span, err := readFileSpan(doc.FilePath, doc.fingerprint, holeStart, holeEnd) + switch { + case errors.Is(err, ErrFileChanged): + hr.refused = ErrFileChanged + return false, hr.refused + case errors.Is(err, ErrFileBusy): + hr.refused = ErrFileBusy + return false, hr.refused } - span, err := readFileSpan(doc.FilePath, holeStart, holeEnd) if err != nil || len(span) < 2 { - return false + return false, nil } if span[0] != '<' || span[len(span)-1] != '>' { - return false + return false, nil } inner := stripPDFWhitespace(string(span[1 : len(span)-1])) want := stripPDFWhitespace(string(hx)) - return strings.EqualFold(inner, want) + return strings.EqualFold(inner, want), nil } -// readFileSpan reads the byte range [start, end) from the file at path. -func readFileSpan(path string, start, end int64) ([]byte, error) { - fh, err := os.Open(path) +// beforeFileSpanRead, when set, runs right before readFileSpan opens the file. +// Nil in production; tests use it to change the file during a walk. +var beforeFileSpanRead func(path string) + +// readFileSpan reads the byte range [start, end) from the file at path. The +// open handle must match fp before and after the read; otherwise it returns +// ErrFileChanged. A read refused by another process's lock is ErrFileBusy. +func readFileSpan(path string, fp FileFingerprint, start, end int64) ([]byte, error) { + if beforeFileSpanRead != nil { + beforeFileSpanRead(path) + } + fh, err := openVerified(path, fp) if err != nil { return nil, err } defer func() { _ = fh.Close() }() buf := make([]byte, end-start) if _, err := fh.ReadAt(buf, start); err != nil { + if isFileBusy(err) { + return nil, fmt.Errorf("%w: %w", ErrFileBusy, err) + } + return nil, err + } + if err := verifyHandle(fh, fp); err != nil { return nil, err } return buf, nil diff --git a/internal/pdfservice/service.go b/internal/pdfservice/service.go index ccc682c..f3aa484 100644 --- a/internal/pdfservice/service.go +++ b/internal/pdfservice/service.go @@ -8,10 +8,12 @@ import ( "runtime" "runtime/debug" "strings" + "sync" "github.com/google/uuid" "github.com/wailsapp/wails/v3/pkg/application" + "unidoc-pdf-debugger/internal/filewatch" "unidoc-pdf-debugger/internal/pdfcore" "unidoc-pdf-debugger/internal/pendingopen" ) @@ -56,6 +58,8 @@ type inspectorAPI interface { GetSignatures(tabID string) (*pdfcore.SignatureList, error) Validate(tabID, profile string) (*pdfcore.ValidationResult, error) DiffDocuments(leftTabID, rightTabID string) (*pdfcore.DiffResult, error) + TabIDs() []string + DiskStatus(tabID string) (pdfcore.DiskStatus, error) } // PDFService is the Wails-bound service that exposes PDF inspection to the @@ -72,6 +76,46 @@ type PDFService struct { // frontend drains them. Injected from main.go via // SetPendingOpens; nil in tests that do not exercise the cold-start path. pendingOpens *pendingopen.Queue + // diskStatusRecorded receives every GetDiskStatus result so the watcher + // treats it as the tab's last reported status. Set by WatchDiskStatus; + // nil in tests that do not run a watcher. + diskStatusRecorded func(tabID string, st pdfcore.DiskStatus) + // watched holds the tab ids the frontend shows in its tab strip; the + // disk watcher polls only these. + watched *watchedTabs +} + +// watchedTabs is the set of tab ids shown in the frontend tab strip. Backend-only +// documents (a Diff right side) are never in it. +type watchedTabs struct { + mu sync.Mutex + ids map[string]bool +} + +// watchSource is the disk watcher's view of the inspector: only watched tabs +// that are still open. +type watchSource struct { + inspector inspectorAPI + watched *watchedTabs +} + +// TabIDs returns the open documents whose tab ids are in the watched set. +func (w watchSource) TabIDs() []string { + open := w.inspector.TabIDs() + w.watched.mu.Lock() + defer w.watched.mu.Unlock() + var ids []string + for _, id := range open { + if w.watched.ids[id] { + ids = append(ids, id) + } + } + return ids +} + +// DiskStatus delegates to the inspector. +func (w watchSource) DiskStatus(tabID string) (pdfcore.DiskStatus, error) { + return w.inspector.DiskStatus(tabID) } // NewPDFService creates a PDFService backed by a fresh Inspector. @@ -79,6 +123,7 @@ func NewPDFService(app *application.App) PDFService { return PDFService{ inspector: pdfcore.NewInspector(), app: app, + watched: &watchedTabs{ids: map[string]bool{}}, } } @@ -530,6 +575,56 @@ func (s *PDFService) DiffDocuments(leftTabID, rightTabID string) (*pdfcore.DiffR return result, err } +// GetDiskStatus stats the file behind tabID and compares it with the +// fingerprint taken when it was opened: current, changed or missing. The +// result is recorded in the disk watcher as the tab's last reported status. +func (s *PDFService) GetDiskStatus(tabID string) (*pdfcore.DiskStatus, error) { + var result *pdfcore.DiskStatus + var err error + func() { + defer recoverRuntimePanic("GetDiskStatus", &err) + var st pdfcore.DiskStatus + st, err = s.inspector.DiskStatus(tabID) + if err != nil { + return + } + result = &st + if s.diskStatusRecorded != nil { + s.diskStatusRecorded(tabID, st) + } + }() + return result, err +} + +// SetWatchedTabs replaces the set of tab ids the disk watcher polls with the +// ids in the frontend tab strip. Documents opened backend-only are left out, +// so they are not polled and no status is recorded for them. +func (s *PDFService) SetWatchedTabs(tabIDs []string) { + if s.watched == nil { + return + } + ids := make(map[string]bool, len(tabIDs)) + for _, id := range tabIDs { + ids[id] = true + } + s.watched.mu.Lock() + s.watched.ids = ids + s.watched.mu.Unlock() +} + +// WatchDiskStatus builds a disk watcher over the documents in s whose tab ids +// were passed to SetWatchedTabs, and wires GetDiskStatus results into it. A +// package function rather than a method so it adds no frontend binding. The +// caller starts and stops the watcher. +func WatchDiskStatus(s *PDFService, emit filewatch.Emitter, clk filewatch.Clock) *filewatch.Watcher { + if s.watched == nil { + s.watched = &watchedTabs{ids: map[string]bool{}} + } + w := filewatch.New(watchSource{inspector: s.inspector, watched: s.watched}, emit, clk) + s.diskStatusRecorded = w.RecordReported + return w +} + // SaveBytesToFile shows a native Save-file dialog seeded with suggestedName and // writes data to the chosen path, returning the saved path. An empty path (user // cancelled) returns ("", nil) so the frontend can treat cancel as a no-op. diff --git a/internal/pdfservice/service_diskstatus_test.go b/internal/pdfservice/service_diskstatus_test.go new file mode 100644 index 0000000..a01b812 --- /dev/null +++ b/internal/pdfservice/service_diskstatus_test.go @@ -0,0 +1,205 @@ +// GetDiskStatus: the on-demand disk check the frontend uses for Compare's size +// check, immediate marking after a stale read, and seeding a promoted copy. +// +// Run: cd code && go test -run TestGetDiskStatus ./internal/pdfservice/ + +package pdfservice + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "unidoc-pdf-debugger/internal/filewatch" + "unidoc-pdf-debugger/internal/pdfcore" +) + +func TestGetDiskStatusReportsCurrentThenChanged(t *testing.T) { + data, err := os.ReadFile(filepath.Join(testdataDir(t), "minimal.pdf")) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "minimal.pdf") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + + svc := NewPDFService(nil) + info, err := svc.OpenFile(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = svc.CloseDocument(info.TabID) }) + + st, err := svc.GetDiskStatus(info.TabID) + if err != nil { + t.Fatalf("GetDiskStatus: %v", err) + } + if st == nil || st.State != "current" { + t.Fatalf("fresh open: %+v, want current", st) + } + + other, err := os.ReadFile(filepath.Join(testdataDir(t), "multipage.pdf")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, other, 0o644); err != nil { + t.Fatal(err) + } + later := time.Now().Add(time.Hour) + if err := os.Chtimes(path, later, later); err != nil { + t.Fatal(err) + } + st, err = svc.GetDiskStatus(info.TabID) + if err != nil { + t.Fatalf("GetDiskStatus after overwrite: %v", err) + } + if st.State != "changed" || st.DiskSize != int64(len(other)) { + t.Errorf("after overwrite: %+v, want changed with diskSize %d", st, len(other)) + } +} + +func TestGetDiskStatusUnknownTab(t *testing.T) { + svc := NewPDFService(nil) + if _, err := svc.GetDiskStatus("no-such-tab"); !errors.Is(err, pdfcore.ErrDocumentNotFound) { + t.Errorf("err = %v, want ErrDocumentNotFound", err) + } +} + +// tickClock hands the watcher one shared tick channel the test drives. +type tickClock struct{ ticks chan time.Time } + +func (c tickClock) After(time.Duration) <-chan time.Time { return c.ticks } + +type chanEmitter chan filewatch.Event + +func (e chanEmitter) Emit(ev filewatch.Event) { e <- ev } + +// A change GetDiskStatus reported (the frontend marked the tab from it) is the +// watcher's last reported status, so a return to the Open fingerprint emits +// current although the watcher itself never reported the change. +func TestGetDiskStatusResultIsRecordedInTheWatcher(t *testing.T) { + original, err := os.ReadFile(filepath.Join(testdataDir(t), "minimal.pdf")) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "minimal.pdf") + if err := os.WriteFile(path, original, 0o644); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + svc := NewPDFService(nil) + info, err := svc.OpenFile(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = svc.CloseDocument(info.TabID) }) + + clk := tickClock{ticks: make(chan time.Time)} + events := make(chanEmitter, 8) + w := WatchDiskStatus(&svc, events, clk) + svc.SetWatchedTabs([]string{info.TabID}) + w.Start() + t.Cleanup(w.Stop) + + other, err := os.ReadFile(filepath.Join(testdataDir(t), "multipage.pdf")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, other, 0o644); err != nil { + t.Fatal(err) + } + st, err := svc.GetDiskStatus(info.TabID) + if err != nil || st.State != pdfcore.DiskStateChanged { + t.Fatalf("GetDiskStatus after overwrite: %+v, %v; want changed", st, err) + } + + if err := os.WriteFile(path, original, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, fi.ModTime(), fi.ModTime()); err != nil { + t.Fatal(err) + } + + // Tick until the watcher settles on current; a tick that finds the stat + // still in flight starts nothing new. + deadline := time.After(5 * time.Second) + for { + select { + case ev := <-events: + if ev.TabID != info.TabID || ev.State != pdfcore.DiskStateCurrent { + t.Fatalf("event %+v, want current for %s", ev, info.TabID) + } + return + case clk.ticks <- time.Now(): + case <-deadline: + t.Fatal("no current event: the GetDiskStatus result was not recorded as the last reported status") + } + } +} + +// The watcher polls only tab ids passed to SetWatchedTabs: a backend-only +// document (a Diff right side) whose file changes emits nothing until the +// frontend adds its id. +func TestWatcherPollsOnlyWatchedTabs(t *testing.T) { + path := filepath.Join(t.TempDir(), "minimal.pdf") + data, err := os.ReadFile(filepath.Join(testdataDir(t), "minimal.pdf")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + + svc := NewPDFService(nil) + info, err := svc.OpenFile(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = svc.CloseDocument(info.TabID) }) + + clk := tickClock{ticks: make(chan time.Time)} + events := make(chanEmitter, 8) + w := WatchDiskStatus(&svc, events, clk) + w.Start() + t.Cleanup(w.Stop) + + other, err := os.ReadFile(filepath.Join(testdataDir(t), "multipage.pdf")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, other, 0o644); err != nil { + t.Fatal(err) + } + + for i := 0; i < 6; i++ { + select { + case ev := <-events: + t.Fatalf("unwatched tab emitted %+v", ev) + case clk.ticks <- time.Now(): + case <-time.After(2 * time.Second): + t.Fatal("watcher stopped taking ticks") + } + } + + svc.SetWatchedTabs([]string{info.TabID}) + deadline := time.After(5 * time.Second) + for { + select { + case ev := <-events: + if ev.TabID != info.TabID || ev.State != pdfcore.DiskStateChanged { + t.Fatalf("event %+v, want changed for %s", ev, info.TabID) + } + return + case clk.ticks <- time.Now(): + case <-deadline: + t.Fatal("no changed event after the tab was watched") + } + } +} diff --git a/main.go b/main.go index 7320197..9f3effe 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "embed" "fmt" "log" @@ -17,6 +18,7 @@ import ( "github.com/wailsapp/wails/v3/pkg/events" "unidoc-pdf-debugger/internal/clitool" + "unidoc-pdf-debugger/internal/filewatch" "unidoc-pdf-debugger/internal/pdfcore" "unidoc-pdf-debugger/internal/pdfservice" "unidoc-pdf-debugger/internal/pendingopen" @@ -385,6 +387,34 @@ func wireInstallCLIMenuItem(app *application.App, appSub *application.Menu) { } } +// diskWatchService runs the disk watcher for the app's lifetime. Only the +// Wails lifecycle methods are exported, so it adds no frontend binding. +type diskWatchService struct { + w *filewatch.Watcher +} + +// ServiceStartup starts the watcher's scheduler. +func (s *diskWatchService) ServiceStartup(_ context.Context, _ application.ServiceOptions) error { + s.w.Start() + return nil +} + +// ServiceShutdown stops the scheduler without waiting for stats in flight. +func (s *diskWatchService) ServiceShutdown() error { + s.w.Stop() + return nil +} + +// diskStatusEmitter forwards settled watcher events to the frontend. +type diskStatusEmitter struct { + app *application.App +} + +// Emit sends ev as a document:disk-status event. +func (e diskStatusEmitter) Emit(ev filewatch.Event) { + e.app.Event.Emit("document:disk-status", ev) +} + func main() { // --version short-circuit: must run BEFORE application.New so that // `unidoc-pdf-debugger --version` does not spin up a Wails webview/window @@ -450,6 +480,11 @@ func main() { app.RegisterService(application.NewService(&pdfService)) + // Disk watcher: polls every open document's file and emits + // document:disk-status once a change has settled. + diskWatcher := pdfservice.WatchDiskStatus(&pdfService, diskStatusEmitter{app: app}, filewatch.SystemClock{}) + app.RegisterService(application.NewService(&diskWatchService{w: diskWatcher})) + // Update-notification service. Version-aware; skips the check on "dev". updateSvc := updateservice.NewUpdateService(app, version) app.RegisterService(application.NewService(updateSvc)) @@ -623,6 +658,15 @@ func main() { OnClick(func(ctx *application.Context) { app.Event.Emit("document:close-active", nil) }) + fileMenu.Add("Reload from Disk"). + SetAccelerator("CmdOrCtrl+r"). + OnClick(func(ctx *application.Context) { + app.Event.Emit("document:reload-active", nil) + }) + fileMenu.Add("Compare with File on Disk"). + OnClick(func(ctx *application.Context) { + app.Event.Emit("document:compare-active", nil) + }) if runtime.GOOS != "darwin" { fileMenu.AddSeparator() fileMenu.Add("Quit"). @@ -862,6 +906,16 @@ func main() { }) }) + // Poll faster while the main window has focus. Wails handles each window + // event on its own goroutine, so both handlers pass the window's current + // focus state instead of trusting which event fired last. + window.OnWindowEvent(events.Common.WindowFocus, func(_ *application.WindowEvent) { + diskWatcher.SetFocused(window.IsFocused()) + }) + window.OnWindowEvent(events.Common.WindowLostFocus, func(_ *application.WindowEvent) { + diskWatcher.SetFocused(window.IsFocused()) + }) + window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) { files := event.Context().DroppedFiles() var pdfPaths []string diff --git a/tests/file-change-detection/file_change_detection_test.go b/tests/file-change-detection/file_change_detection_test.go new file mode 100644 index 0000000..b7d31f5 --- /dev/null +++ b/tests/file-change-detection/file_change_detection_test.go @@ -0,0 +1,191 @@ +// Package file_change_detection_test runs the colocated tests for reload from +// disk and on-disk change detection: the stale-read guard in pdfcore and the +// CLI, the on-disk status check, Windows busy-file handling, and the watcher's +// settle, cadence and isolation rules. +// +// Each test here names the colocated tests it needs and fails when any of +// them is missing, skipped or failing, so a deleted or renamed colocated test +// cannot pass silently. This suite is in the Windows CI subset: file identity +// (rename-over) and sharing violations behave differently there. +// +// Run: cd tests/file-change-detection && go test -v -count=1 ./... +package file_change_detection_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// projectRoot walks up to the go.mod of module unidoc-pdf-debugger. +func projectRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if data, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil { + if strings.Contains(string(data), "module unidoc-pdf-debugger") { + return dir + } + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("could not find project root (no go.mod with module unidoc-pdf-debugger)") + } + dir = parent + } +} + +// raceAvailable reports whether the toolchain can build with -race here. +func raceAvailable(t *testing.T, root string) bool { + t.Helper() + cmd := exec.Command("go", "env", "CGO_ENABLED") + cmd.Dir = root + out, err := cmd.Output() + return err == nil && strings.TrimSpace(string(out)) == "1" +} + +// runColocated runs the named tests in pkg in one go test invocation and +// requires a PASS line for every one of them. +func runColocated(t *testing.T, pkg string, race bool, names ...string) { + t.Helper() + root := projectRoot(t) + args := []string{"test", "-v", "-count=1", "-run", "^(" + strings.Join(names, "|") + ")$"} + if race && raceAvailable(t, root) { + args = append(args, "-race") + } + args = append(args, pkg) + cmd := exec.Command("go", args...) + cmd.Dir = root + output, err := cmd.CombinedOutput() + out := string(output) + if err != nil { + t.Fatalf("go %s failed:\n%s", strings.Join(args, " "), out) + } + for _, name := range names { + if !strings.Contains(out, "--- PASS: "+name+" ") { + t.Errorf("colocated test %s in %s did not run and pass", name, pkg) + } + } + if t.Failed() { + t.Logf("output:\n%s", out) + } +} + +// Plain Text reads refuse bytes from a file that was overwritten, deleted or +// renamed over since Open or during the read, cache nothing they refused, and still serve a +// result verified before the change. +func TestPlainTextReadRefusesChangedFile(t *testing.T) { + runColocated(t, "./internal/pdfcore/", true, + "TestFileChangeSentinelsCarryFrontendMarkers", + "TestFileChangePlainTextRefusesOverwrittenFileAndCachesNothing", + "TestFileChangePlainTextRefusesDeletedFile", + "TestFileChangePlainTextRefusesRenameOver", + "TestFileChangePlainTextReturnsCachedResultAfterLaterChange", + "TestFileChangePlainTextRefusesChangeDuringRead", + ) +} + +// Signature decomposition on a changed file keeps every signature's parsed +// details and notes the hole as not checked instead of reporting a mismatch; +// an unchanged file reports as before. +func TestSignaturesNoteChangedFile(t *testing.T) { + runColocated(t, "./internal/pdfcore/", true, + "TestFileChangeSignaturesNoteChangedFile", + "TestFileChangeSignaturesNoteDeletedFile", + "TestFileChangeSignaturesNoteChangeDuringWalk", + "TestFileChangeSignaturesIgnoreChangeOnUnsignedFile", + "TestFileChangeSignaturesUnchangedFileReportsAsBefore", + ) +} + +// dump bytes (raw and --json) on a file rewritten after the CLI parsed it +// exits 2 with the file-changed message on stderr; dump signatures prints the +// hole check as not checked. +func TestCLIReportsChangedFileAsViewError(t *testing.T) { + runColocated(t, "./cmd/cli/", false, + "TestCLIFileChangedBytesJSON", + "TestCLIFileChangedBytesRaw", + "TestCLIFileChangedSignatures", + "TestCLIFileChangedHookUnsetLeavesRunsUnaffected", + ) +} + +// The inspector lists its open tabs and reports current, changed or missing +// against the Open fingerprint; the bound GetDiskStatus does the same and +// records its result in the watcher as the tab's last reported status. +func TestDiskStatusAgainstOpenFingerprint(t *testing.T) { + runColocated(t, "./internal/pdfcore/", false, + "TestTabIDsListsOpenDocuments", + "TestDiskStatusCurrentChangedMissing", + "TestDiskStatusDetectsRenameOver", + "TestFileChangeSizeOnlyChangeIsDetected", + "TestCopyDocumentFileGuardsTheRawBytes", + "TestDiskStatusUnknownTab", + "TestDiskStatusJSONShape", + ) + runColocated(t, "./internal/pdfservice/", true, + "TestGetDiskStatusReportsCurrentThenChanged", + "TestGetDiskStatusUnknownTab", + "TestGetDiskStatusResultIsRecordedInTheWatcher", + "TestWatcherPollsOnlyWatchedTabs", + ) +} + +// A Windows sharing or lock violation is a busy file, not a malformed PDF; +// off Windows nothing is busy. +func TestBusyFileIsNotAParseFailure(t *testing.T) { + if runtime.GOOS == "windows" { + runColocated(t, "./internal/pdfcore/", false, + "TestFileBusyRecognisesWindowsErrnos", + "TestStatWithIdentityCapturesTheFileIDAtStatTime", + "TestFileBusyOpenOfExclusivelyLockedFile", + "TestFileBusyPlainTextReadOfExclusivelyLockedFile", + "TestFileBusySignaturesHoleReadOfLockedFile", + "TestFileBusyCopyOfLockedFile", + ) + return + } + runColocated(t, "./internal/pdfcore/", false, "TestFileBusyIsFalseOffWindows") +} + +// A change is reported once, after two identical checks; a file still being +// written, a stat error and a never-changed tab emit nothing. +func TestWatcherSettleRules(t *testing.T) { + runColocated(t, "./internal/filewatch/", true, + "TestWatcherSettleReportsChangeAfterTwoIdenticalChecks", + "TestWatcherSettleReportsFileStillBeingWritten", + "TestWatcherSettleReportsMissingAfterTwoChecks", + "TestWatcherStatErrorResetsSettleStreak", + "TestWatcherRecordedStatusLetsReturnToCurrentEmit", + "TestWatcherRecordedStatusRestartsSettleStreak", + "TestWatcherEventJSONShape", + "TestWatcherNewTabStartsAtBaselineWithoutEvent", + ) +} + +// Ticks every 2s focused and 10s unfocused, checks every tab at once on +// focus, takes the focus value from the caller, and checks background tabs. +func TestWatcherCadenceAndFocus(t *testing.T) { + runColocated(t, "./internal/filewatch/", true, + "TestWatcherCadenceFollowsFocus", + "TestWatcherFocusTakesCallerValueRegardlessOfOrder", + "TestWatcherMergedFocusGainStillChecks", + "TestWatcherChecksBackgroundTabsAndNeverEmitsForUnchangedOnes", + ) +} + +// A hung stat stalls only its own tab, a closed tab's late result is dropped, +// and Stop does not wait for a hung stat. +func TestWatcherIsolation(t *testing.T) { + runColocated(t, "./internal/filewatch/", true, + "TestWatcherBlockedTabDoesNotDelayOthers", + "TestWatcherDiscardsLateResultForClosedTab", + "TestWatcherStopReturnsWhileStatIsBlocked", + ) +} diff --git a/tests/file-change-detection/go.mod b/tests/file-change-detection/go.mod new file mode 100644 index 0000000..a594dcf --- /dev/null +++ b/tests/file-change-detection/go.mod @@ -0,0 +1,3 @@ +module file-change-detection-test + +go 1.25 diff --git a/tests/wails-alpha2-103-upgrade/bindings_contract_test.go b/tests/wails-alpha2-103-upgrade/bindings_contract_test.go index 8591363..5b91749 100644 --- a/tests/wails-alpha2-103-upgrade/bindings_contract_test.go +++ b/tests/wails-alpha2-103-upgrade/bindings_contract_test.go @@ -20,7 +20,7 @@ import ( // not a count. Sourced from the non-test `import { ... } from // '.../pdfservice/pdfservice.js'` sites: // -// App.jsx : ConsumePendingOpenFiles +// App.jsx : ConsumePendingOpenFiles, SetWatchedTabs // ObjectInfoPanel.tsx : GetObjectSource // DetailPanel.tsx : GetObjectDetail, GetContentStream, GetImageData, // GetReverseRefs, GetFontView @@ -34,6 +34,7 @@ import ( // usePDFService.ts : OpenFile, GetTreeRoot, GetChildren, CloseDocument, // OpenFileDialog // lib/openedDocuments.ts : CloseDocument +// lib/diskCheck.ts : GetDiskStatus // // If the dev adds/removes a frontend import in the same change, this list moves // WITH the real consumer dependency -- which is exactly the contract we want, @@ -58,6 +59,8 @@ var consumerBoundMethods = []string{ "GetImageIndex", "GetImagePages", "GetImagePageGroups", + "GetDiskStatus", + "SetWatchedTabs", } // bindingRelPath is the regenerated Wails JS binding for PDFService. diff --git a/tests/wails-alpha2-103-upgrade/event_surface_test.go b/tests/wails-alpha2-103-upgrade/event_surface_test.go index f9c1471..8e03fc8 100644 --- a/tests/wails-alpha2-103-upgrade/event_surface_test.go +++ b/tests/wails-alpha2-103-upgrade/event_surface_test.go @@ -37,6 +37,9 @@ var goEmittedEvents = []string{ "splash:dismiss", "splash:dismissed", "splash:timeout", + "document:reload-active", + "document:compare-active", + "document:disk-status", } // TestGoEventEmitNamesPreserved asserts every Go-emitted event name still appears @@ -68,6 +71,9 @@ var jsConsumedEvents = []string{ "splash:dismissed", "common:WindowDidMove", "common:WindowDidResize", + "document:reload-active", + "document:compare-active", + "document:disk-status", } // TestJsEventOnNamesPreserved asserts every event the frontend listens for still @@ -76,7 +82,7 @@ var jsConsumedEvents = []string{ // -- the same pattern TestJsToGoEventContract uses -- avoids the brittle // whole-tree-grep failure mode, where a bare literal false-passes on a match in a // comment or an unrelated string. Every consumed event is verified subscribed via -// this call form (App.jsx, main.jsx, TabBar.tsx). +// this call form (App.jsx, main.jsx, TabBar.tsx, DetailPanel.tsx). func TestJsEventOnNamesPreserved(t *testing.T) { src := loadFrontendSrcConcat(t) for _, name := range jsConsumedEvents { diff --git a/tests/wails-alpha2-117-upgrade/bindings_contract_test.go b/tests/wails-alpha2-117-upgrade/bindings_contract_test.go index eed3c68..320ec6f 100644 --- a/tests/wails-alpha2-117-upgrade/bindings_contract_test.go +++ b/tests/wails-alpha2-117-upgrade/bindings_contract_test.go @@ -28,7 +28,7 @@ import ( // '.../pdfservice/pdfservice.js'` sites (App.jsx, ObjectInfoPanel.tsx, // DetailPanel.tsx, XRefTableView.tsx, CommandPalette.tsx, TreePanel.tsx, // treeRows.tsx, useObjectIndex.ts, PagesPanel.tsx, ImagesPanel.tsx, usePDFService.ts, -// lib/openedDocuments.ts). If the dev adds/removes a +// lib/openedDocuments.ts, lib/diskCheck.ts). If the dev adds/removes a // frontend import in the same change, this list moves WITH the real consumer // dependency -- which is exactly the contract we want, unlike a magic count that // churns on every unrelated surface change. @@ -52,6 +52,8 @@ var consumerBoundMethods = []string{ "GetImageIndex", "GetImagePages", "GetImagePageGroups", + "GetDiskStatus", + "SetWatchedTabs", } // bindingRelPath is the regenerated Wails JS binding for PDFService.