From a7c4bbf76acb3caca2b1f3957c77df4cd2f4dfde Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Wed, 14 Oct 2026 22:37:12 +0700 Subject: [PATCH 01/10] feat: reload from disk and on-disk change detection - File > Reload from Disk, Cmd/Ctrl+R and F5 replace the tab in place with a fresh parse - Stat-polled change indicator on every tab, stale note with Reload and Compare - Compare with File on Disk through the existing Diff - Plain Text and signature reads refuse a file that changed since open --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 9 + cmd/cli/cli_open.go | 5 + cmd/cli/cmd_bytes.go | 3 + cmd/cli/cmd_signatures.go | 3 + cmd/cli/file_change_test.go | 110 ++++ frontend/src/App.jsx | 63 ++ frontend/src/App.reload.test.tsx | 441 ++++++++++++++ .../DetailPanel.compareDisk.test.tsx | 453 ++++++++++++++ .../components/DetailPanel.staleNote.test.tsx | 296 +++++++++ frontend/src/components/DetailPanel.tsx | 251 +++++++- frontend/src/components/DiffView.tsx | 25 +- .../src/components/DiskChangeAnnouncer.tsx | 44 ++ .../PlainTextView.fileChanged.test.tsx | 73 +++ frontend/src/components/PlainTextView.tsx | 9 + frontend/src/components/ReloadToast.test.tsx | 78 +++ frontend/src/components/ReloadToast.tsx | 73 +++ frontend/src/components/SignaturesView.tsx | 12 +- frontend/src/components/StaleNote.tsx | 60 ++ .../src/components/TabBar.diskStatus.test.tsx | 221 +++++++ frontend/src/components/TabBar.tsx | 87 ++- .../hooks/useDocumentState.reload.test.tsx | 201 ++++++ frontend/src/hooks/useDocumentState.tsx | 10 +- frontend/src/lib/diskCheck.ts | 18 + frontend/src/lib/diskStatusStore.test.ts | 117 ++++ frontend/src/lib/diskStatusStore.ts | 120 ++++ frontend/src/lib/fileChange.ts | 26 + frontend/src/lib/formatTimestamp.test.ts | 65 ++ frontend/src/lib/formatTimestamp.ts | 31 + frontend/src/lib/reloadDocument.ts | 80 +++ frontend/src/style.css | 25 + internal/filewatch/watcher.go | 296 +++++++++ internal/filewatch/watcher_test.go | 573 ++++++++++++++++++ internal/pdfcore/errors.go | 8 + internal/pdfcore/filebusy_other.go | 9 + internal/pdfcore/filebusy_other_test.go | 28 + internal/pdfcore/filebusy_windows.go | 23 + internal/pdfcore/filebusy_windows_test.go | 93 +++ internal/pdfcore/filechange.go | 143 +++++ internal/pdfcore/filechange_test.go | 517 ++++++++++++++++ internal/pdfcore/inspector.go | 22 + internal/pdfcore/plaintext.go | 31 +- internal/pdfcore/plaintext_async_test.go | 1 + internal/pdfcore/signature.go | 38 +- internal/pdfservice/service.go | 38 ++ .../pdfservice/service_diskstatus_test.go | 144 +++++ main.go | 54 ++ .../file_change_detection_test.go | 182 ++++++ tests/file-change-detection/go.mod | 3 + .../bindings_contract_test.go | 4 +- .../event_surface_test.go | 8 +- .../bindings_contract_test.go | 3 +- 52 files changed, 5176 insertions(+), 52 deletions(-) create mode 100644 cmd/cli/file_change_test.go create mode 100644 frontend/src/App.reload.test.tsx create mode 100644 frontend/src/components/DetailPanel.compareDisk.test.tsx create mode 100644 frontend/src/components/DetailPanel.staleNote.test.tsx create mode 100644 frontend/src/components/DiskChangeAnnouncer.tsx create mode 100644 frontend/src/components/PlainTextView.fileChanged.test.tsx create mode 100644 frontend/src/components/ReloadToast.test.tsx create mode 100644 frontend/src/components/ReloadToast.tsx create mode 100644 frontend/src/components/StaleNote.tsx create mode 100644 frontend/src/components/TabBar.diskStatus.test.tsx create mode 100644 frontend/src/hooks/useDocumentState.reload.test.tsx create mode 100644 frontend/src/lib/diskCheck.ts create mode 100644 frontend/src/lib/diskStatusStore.test.ts create mode 100644 frontend/src/lib/diskStatusStore.ts create mode 100644 frontend/src/lib/fileChange.ts create mode 100644 frontend/src/lib/formatTimestamp.test.ts create mode 100644 frontend/src/lib/formatTimestamp.ts create mode 100644 frontend/src/lib/reloadDocument.ts create mode 100644 internal/filewatch/watcher.go create mode 100644 internal/filewatch/watcher_test.go create mode 100644 internal/pdfcore/filebusy_other.go create mode 100644 internal/pdfcore/filebusy_other_test.go create mode 100644 internal/pdfcore/filebusy_windows.go create mode 100644 internal/pdfcore/filebusy_windows_test.go create mode 100644 internal/pdfcore/filechange.go create mode 100644 internal/pdfcore/filechange_test.go create mode 100644 internal/pdfservice/service_diskstatus_test.go create mode 100644 tests/file-change-detection/file_change_detection_test.go create mode 100644 tests/file-change-detection/go.mod diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a39fb857..4cb4571f 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 b47f0657..a7314f65 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. 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 settles while the window has focus, 10 to 20 seconds otherwise) 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, Signatures and the CLI's `dump bytes --json` and `dump signatures` 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 used to report a hole mismatch in that case + ## [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 1d1e7918..ad042124 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 81307a06..18915822 100644 --- a/cmd/cli/cmd_bytes.go +++ b/cmd/cli/cmd_bytes.go @@ -46,6 +46,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 diff --git a/cmd/cli/cmd_signatures.go b/cmd/cli/cmd_signatures.go index 56b69501..e7b99904 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 { diff --git a/cmd/cli/file_change_test.go b/cmd/cli/file_change_test.go new file mode 100644 index 00000000..a462c3d8 --- /dev/null +++ b/cmd/cli/file_change_test.go @@ -0,0 +1,110 @@ +// A file rewritten after the CLI parsed it is reported as a view error +// (stderr JSON, exit 2) instead of mixing the old parse with the new bytes. +// 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 TestCLIFileChangedSignatures(t *testing.T) { + path := signedCopy(t) + called := rewriteAfterOpen(t) + var code int + var stderr string + captureStdout(t, func() { + stderr = captureStderr(t, func() { + code = execSignaturesDump(path, docViewFlags{}) + }) + }) + if !*called { + t.Fatalf("afterCLIOpen was not called by dump signatures") + } + 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 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.jsx b/frontend/src/App.jsx index c2bf9d66..e24ee825 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,10 @@ 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, retainDiskStatus } from './lib/diskStatusStore' +import { reloadTab } from './lib/reloadDocument' /** * Inner shell that subscribes to Wails backend events and delegates @@ -46,6 +50,63 @@ 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. + useEffect(() => { + retainDiskStatus(tabs.map((t) => t.tabId)) + }, [tabs]) + + // Reload from disk (File > Reload from Disk, its Cmd/Ctrl+R accelerator, and + // F5) always acts on the active tab, stale or not. + useEffect(() => { + const reloadActive = () => { + const id = activeTabIdRef.current + if (id) void reloadTab(dispatch, () => tabsRef.current, id) + } + /** @param {EventTarget | null} target */ + function isInTextField(target) { + if (!(target instanceof HTMLElement)) return false + const tag = target.tagName + return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable + } + /** @param {KeyboardEvent} e */ + function onKeyDown(e) { + if (e.key !== 'F5' || e.repeat) return + if (isInTextField(e.target)) return + e.preventDefault() + reloadActive() + } + const offReload = Events.On('document:reload-active', 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 (a Diff right side) are ignored. + 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 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, + }) + if (applied && data.state === 'changed') announceDiskChange(`${tab.fileName} changed on disk`) + }) + }, []) // Sync navigation menu enabled state with backend const prevNavState = useRef({ canGoBack: false, canGoForward: false }) @@ -355,6 +416,8 @@ function AppContent() { + + ) } diff --git a/frontend/src/App.reload.test.tsx b/frontend/src/App.reload.test.tsx new file mode 100644 index 00000000..d10012e8 --- /dev/null +++ b/frontend/src/App.reload.test.tsx @@ -0,0 +1,441 @@ +/** + * 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'; + +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), + 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.toHaveBeenCalled(); + }); +}); + +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(); + }); +}); + +describe('on-disk status events', () => { + 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('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/components/DetailPanel.compareDisk.test.tsx b/frontend/src/components/DetailPanel.compareDisk.test.tsx new file mode 100644 index 00000000..255db534 --- /dev/null +++ b/frontend/src/components/DetailPanel.compareDisk.test.tsx @@ -0,0 +1,453 @@ +/** + * 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 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 00000000..44865a34 --- /dev/null +++ b/frontend/src/components/DetailPanel.staleNote.test.tsx @@ -0,0 +1,296 @@ +/** + * 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(); + }); +}); + +describe('signatures refused because the file changed', () => { + test('keeps the Signatures tab, shows the reload hint and marks the tab', async () => { + const p = paths(); + b.GetSignatures.mockRejectedValue(envelope('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('File changed on disk. Reload to view.')).toBeInTheDocument(); + }); + + 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 c08a0eef..7a36111b 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -6,10 +6,20 @@ */ 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 * as Dialog from '@radix-ui/react-dialog'; +import { Events } from '@wailsio/runtime'; +import { GetObjectDetail, GetContentStream, GetImageData, DescribeImage, SaveImageToFile, GetReverseRefs, GetFontView, GetSignatures, OpenFile, OpenFileDialog, CloseDocument, GetDiskStatus, GetTreeRoot, GetChildren } 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 { useAppState, useAppDispatch, type TreeNode } from '../hooks/useDocumentState'; import { extractErrorMessage } from '../lib/extractErrorMessage'; +import { FILE_MISSING_MESSAGE, isFileChangedMessage } from '../lib/fileChange'; +import { markTabFromDiskCheck } from '../lib/diskCheck'; +import { setDiskStatusFromCheck, type DiskStatusInfo } from '../lib/diskStatusStore'; +import { formatTimestamp } from '../lib/formatTimestamp'; +import { dispatchOpenedDocument } from '../lib/openedDocuments'; +import { reloadTab, 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 +60,35 @@ 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; + +/** Document metadata returned by OpenFile. */ +interface OpenedDocumentInfo { + tabId: string; + fileName: string; + filePath: string; + pageCount: number; + fileSize: number; + error: string; +} + +/** A Diff right side parsed by Compare from the active tab's own file. */ +interface DiskCopy { + tabId: string; + info: OpenedDocumentInfo; + 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', @@ -159,11 +198,27 @@ function DetailPanelInner() { // refetch per tab switch); the tab is simply absent until the fetch // resolves with >= 1 signature field. null = not yet resolved. const [signatures, setSignatures] = useState(null); + // True when GetSignatures was refused because the file changed on disk: the + // Signatures tab stays visible and shows the reload hint. + const [signaturesFileChanged, setSignaturesFileChanged] = useState(false); // The second (comparison) document's tab ID for the Diff tab, and // any picker error. Reset on document switch so the diff never carries over a // 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,14 +242,20 @@ 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'); setXrefEntryCount(null); setEmbeddedCount(null); setSignatures(null); + setSignaturesFileChanged(false); setDiffRightTabId(null); setDiffError(null); + setCompareConfirm(null); }, [activeTabId]); // The comparison document is opened backend-only (not an app tab), @@ -203,6 +264,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 */ }); @@ -229,6 +294,13 @@ function DetailPanelInner() { .catch((err: unknown) => { if (cancelled) return; setSignatures([]); + // A read refused because the file changed keeps the tab visible with + // the reload hint and marks the tab from a fresh disk check. + if (isFileChangedMessage(extractErrorMessage(err))) { + setSignaturesFileChanged(true); + markTabFromDiskCheck(activeTabId); + return; + } // eslint-disable-next-line no-console console.warn('GetSignatures failed:', err); }); @@ -631,6 +703,131 @@ 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(() => { + const id = activeTabIdRef.current; + if (id) void reloadTab(dispatch, () => tabsRef.current, id); + }, [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 OpenedDocumentInfo | 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; + GetDiskStatus(tabId) + .then((res: unknown) => { + if (!res) return; + const st = res as DiskStatusInfo; + // The backend recorded this result as reported, so it is stored even + // when the compare itself is dropped below; the watcher will not send it. + if (tabsRef.current.some((t) => t.tabId === tabId)) setDiskStatusFromCheck(tabId, st); + 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, + }); + if (info.error) dispatch({ type: 'SET_DOCUMENT_WARNING', payload: { message: info.error } }); + 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))}` } }); + }) + .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 @@ -735,7 +932,7 @@ function DetailPanelInner() { // The Signatures tab exists only when the document has >= 1 signature // field (hidden while unresolved or empty -- a deliberate departure from // the always-visible tabs, avoiding a permanently empty tab). - const showSignaturesTab = (signatures?.length ?? 0) > 0; + const showSignaturesTab = (signatures?.length ?? 0) > 0 || signaturesFileChanged; // FontPreview is active when iconHint='font', detail is a dict, and the // fetch resolved to a detail payload (not fallback / error). The header @@ -774,9 +971,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); @@ -1235,10 +1440,48 @@ function DetailPanelInner() { active={detailView === 'signatures'} onNavigate={handleSignaturesNavigate} data={signatures} + fileChanged={signaturesFileChanged} /> )} + { 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 ee10e42d..398f1ff4 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 00000000..9e178d97 --- /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 00000000..4eb11459 --- /dev/null +++ b/frontend/src/components/PlainTextView.fileChanged.test.tsx @@ -0,0 +1,73 @@ +/** + * 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('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 e0c2e114..d583f8b3 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_CHANGED_MESSAGE, 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,13 @@ 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 { 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 00000000..04098dee --- /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 00000000..5f59e175 --- /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 c7aca336..f7775728 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_CHANGED_MESSAGE } from '../lib/fileChange'; import { SMALL_BUTTON } from './buttonStyles'; /** One decomposed X.509 certificate, mirroring `pdfcore.CertInfo`. */ @@ -64,6 +65,8 @@ export interface SignaturesViewProps { * fetch of its own; when omitted the view self-fetches on tabId change. */ data?: SignatureEntryData[] | null; + /** True when the parent's fetch was refused because the file changed on disk. */ + fileChanged?: boolean; } /** Extracts the CN component from an X.509 DN string, falling back to the @@ -266,7 +269,7 @@ function SignatureCard({ entry, onNavigate }: { entry: SignatureEntryData; onNav * `data` prop is provided (DetailPanel's one-fetch-per-document contract); * self-fetches on tabId change otherwise. */ -export function SignaturesView({ tabId, active: _active, onNavigate, data }: SignaturesViewProps) { +export function SignaturesView({ tabId, active: _active, onNavigate, data, fileChanged }: SignaturesViewProps) { const [entries, setEntries] = useState(data ?? null); const [error, setError] = useState(null); @@ -303,6 +306,13 @@ export function SignaturesView({ tabId, active: _active, onNavigate, data }: Sig
); } + if (fileChanged) { + return ( +
+ {FILE_CHANGED_MESSAGE} +
+ ); + } if (error) { return (
diff --git a/frontend/src/components/StaleNote.tsx b/frontend/src/components/StaleNote.tsx new file mode 100644 index 00000000..b6207c79 --- /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 00000000..2269cbe8 --- /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 d99fd4e3..538a77ac 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 00000000..57d12cc7 --- /dev/null +++ b/frontend/src/hooks/useDocumentState.reload.test.tsx @@ -0,0 +1,201 @@ +/** + * 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('activates the reloaded tab when another tab became active meanwhile', () => { + mount(); + open('a1', '/docs/a.pdf'); + open('b1', '/docs/b.pdf'); + + reload('a2', '/docs/a.pdf'); + + expect(state.activeTabId).toBe('a2'); + expect(state.tabs.map((t) => t.tabId)).toEqual(['a2', 'b1']); + }); +}); + +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 reload outside any batch keeps an unrelated 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).toBe('This PDF has structural errors. Some objects may display incorrectly.'); + }); + + 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 775ab4c4..b383e814 100644 --- a/frontend/src/hooks/useDocumentState.tsx +++ b/frontend/src/hooks/useDocumentState.tsx @@ -142,7 +142,8 @@ 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 keeps the warning. + | { type: 'OPEN_DOCUMENT'; payload: { tabId: string; fileName: string; filePath: string; pageCount?: number; rootNode: TreeNode | null; rootChildren: TreeNode[] | null; reload?: boolean } } | { 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 } } @@ -211,7 +212,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, the cancel + // flag and the warning text exactly as they are. + const isReload = action.payload.reload === true; + const inBatch = !isReload && state.batchOpenTotal > 0; const batchOpenCompleted = inBatch ? state.batchOpenCompleted + 1 : state.batchOpenCompleted; @@ -226,7 +230,7 @@ function appReducer(state: AppState, action: AppAction): AppState { // (after BATCH_OPEN_COMPLETE, due to Wails' goroutine race) update the // displayed count. When not cancelled, OPEN_DOCUMENT clears the warning // as before. - const documentWarning = cancelDidNothing + const documentWarning = isReload ? state.documentWarning : cancelDidNothing ? null : state.batchOpenCancelled ? (inBatch diff --git a/frontend/src/lib/diskCheck.ts b/frontend/src/lib/diskCheck.ts new file mode 100644 index 00000000..4a32edf7 --- /dev/null +++ b/frontend/src/lib/diskCheck.ts @@ -0,0 +1,18 @@ +/** + * @file On-demand disk check that marks a tab at once. + */ +import { GetDiskStatus } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; +import { 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). + * Failures are ignored: the watcher reports the change later. + */ +export function markTabFromDiskCheck(tabId: string): void { + Promise.resolve(GetDiskStatus(tabId)) + .then((st: unknown) => { + if (st) setDiskStatusFromCheck(tabId, st as DiskStatusInfo); + }) + .catch(() => {}); +} diff --git a/frontend/src/lib/diskStatusStore.test.ts b/frontend/src/lib/diskStatusStore.test.ts new file mode 100644 index 00000000..ccb67357 --- /dev/null +++ b/frontend/src/lib/diskStatusStore.test.ts @@ -0,0 +1,117 @@ +/** + * 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}) + * - 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, + 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 }); + expect(readDiskStatus('t1')?.state).toBe('changed'); + setDiskStatusFromCheck('t1', { state: 'current', loadedModTime: loaded, diskModTime: loaded, diskSize: 10 }); + 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 }); + applyDiskStatusEvent(ev('t1', 4, 'current')); + 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 00000000..e0f862e8 --- /dev/null +++ b/frontend/src/lib/diskStatusStore.ts @@ -0,0 +1,120 @@ +/** + * @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(); + +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 { + 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; +} + +/** Writes a GetDiskStatus result for tabId; `current` removes the entry. */ +export function setDiskStatusFromCheck(tabId: string, info: DiskStatusInfo): void { + write(tabId, lastSeq.get(tabId) ?? 0, info); +} + +/** Drops entries (and seq history) of every tab not in tabIds. */ +export function retainDiskStatus(tabIds: readonly string[]): void { + const keep = new Set(tabIds); + 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(); + notify(); +} diff --git a/frontend/src/lib/fileChange.ts b/frontend/src/lib/fileChange.ts new file mode 100644 index 00000000..5ff8354e --- /dev/null +++ b/frontend/src/lib/fileChange.ts @@ -0,0 +1,26 @@ +/** + * @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 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 00000000..701af6db --- /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 00000000..e5167197 --- /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 00000000..5ce164e0 --- /dev/null +++ b/frontend/src/lib/reloadDocument.ts @@ -0,0 +1,80 @@ +/** + * @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 { openPDFFile } from '../hooks/usePDFService'; +import { dispatchOpenedDocument } from './openedDocuments'; +import { extractErrorMessage } from './extractErrorMessage'; +import { isFileBusyMessage } from './fileChange'; +import { showReloadToast } from '../components/ReloadToast'; + +/** 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 { + if (/document not found/i.test(message)) return 'file not found'; + if (/encrypted|password/i.test(message)) return 'file is encrypted'; + if (/malformed/i.test(message)) return 'file is damaged or not a valid PDF'; + return 'unexpected error'; +} + +/** 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 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)}` } }); + 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, + }); + if (result.warning) { + dispatch({ type: 'SET_DOCUMENT_WARNING', payload: { message: result.warning } }); + } + showReloadToast(reloadedMessage(result.fileName || tab.fileName)); + } finally { + reloading.delete(tabId); + } +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 1eade1b7..6d7e11ff 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 00000000..0ee0437c --- /dev/null +++ b/internal/filewatch/watcher.go @@ -0,0 +1,296 @@ +// 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 pdfcore.DiskStatus + streak int + 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 + 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. Entering focused mode checks every tab immediately. +func (w *Watcher) SetFocused(focused bool) { + w.mu.Lock() + w.focused = focused + w.focusDirty = true + w.mu.Unlock() + w.signal() +} + +// RecordReported sets the status last reported for tabID, so a later return +// to the Open fingerprint emits current even when the watcher never reported +// the change itself. +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 := w.focused, w.focusDirty + w.focusDirty = false + w.mu.Unlock() + + for id, st := range records { + ts := tabs[id] + if ts == nil { + ts = &tabState{} + tabs[id] = ts + } + ts.lastReported = st + } + if dirty && newFocus != focused { + entering := newFocus + focused = newFocus + tick = w.clk.After(intervalFor(focused)) + if entering { + 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.streak = 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 one observation and returns the event to emit when two +// consecutive identical observations differ from the last reported status. +// A tab that stays current never emits. +func settle(ts *tabState, obs pdfcore.DiskStatus) (Event, bool) { + if obs == ts.candidate && ts.streak > 0 { + ts.streak++ + } else { + ts.candidate = obs + ts.streak = 1 + } + if ts.streak < 2 { + return Event{}, false + } + last := ts.lastReported + if obs.State == pdfcore.DiskStateCurrent && last.State == pdfcore.DiskStateCurrent { + return Event{}, false + } + if obs == last { + return Event{}, 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 00000000..67ccfe90 --- /dev/null +++ b/internal/filewatch/watcher_test.go @@ -0,0 +1,573 @@ +// 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) + } +} + +func TestWatcherSettleIgnoresFileStillBeingWritten(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", 1) + if len(evs) != 1 || evs[0].ev.DiskSize != 105 { + t.Fatalf("events %+v, want one changed event for the final size 105", evs) + } + if evs[0].callsAtEmit != base+6 { + t.Errorf("emitted after check %d, want 6 (nothing while the observation kept changing)", evs[0].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) + } +} + +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 }) +} + +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 adc73c67..59ede89c 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 (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 00000000..543a557f --- /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 00000000..4c88b1df --- /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 00000000..243cc22e --- /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 00000000..d24b14ce --- /dev/null +++ b/internal/pdfcore/filebusy_windows_test.go @@ -0,0 +1,93 @@ +//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/fs" + "strings" + "syscall" + "testing" +) + +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 process holds without read sharing returns +// ErrFileBusy, not ErrMalformedPDF. +func TestFileBusyOpenOfExclusivelyLockedFile(t *testing.T) { + path := copyFixtureToTemp(t, "minimal.pdf") + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(p, syscall.GENERIC_READ, 0, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("lock file: %v", err) + } + defer func() { _ = syscall.CloseHandle(h) }() + + 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 process holds without read sharing +// returns ErrFileBusy, not ErrMalformedPDF. +func TestFileBusyPlainTextReadOfExclusivelyLockedFile(t *testing.T) { + ins, path := openTempCopy(t, "minimal.pdf", "tab-busy-read") + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(p, syscall.GENERIC_READ, 0, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("lock file: %v", err) + } + defer func() { _ = syscall.CloseHandle(h) }() + + _, 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) + } +} diff --git a/internal/pdfcore/filechange.go b/internal/pdfcore/filechange.go new file mode 100644 index 00000000..dd3ddb96 --- /dev/null +++ b/internal/pdfcore/filechange.go @@ -0,0 +1,143 @@ +package pdfcore + +import ( + "errors" + "fmt" + "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 +} + +// verifyPath stats path against fp: ErrFileChanged on a mismatch or a missing +// file (wrapping fs.ErrNotExist), nil on a match, other stat errors as is. +func verifyPath(path string, fp FileFingerprint) error { + fi, err := os.Stat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("%w: %w", ErrFileChanged, err) + } + 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 := os.Stat(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 +} diff --git a/internal/pdfcore/filechange_test.go b/internal/pdfcore/filechange_test.go new file mode 100644 index 00000000..1087589f --- /dev/null +++ b/internal/pdfcore/filechange_test.go @@ -0,0 +1,517 @@ +// 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 ( + "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") + } +} + +func TestFileChangeSignaturesRefuseChangedFile(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 !errors.Is(err, ErrFileChanged) { + t.Fatalf("err = %v, want ErrFileChanged instead of a hole mismatch report", err) + } + if list != nil { + t.Errorf("a changed file must return no signature list, got %+v", list) + } +} + +func TestFileChangeSignaturesRefuseDeletedFile(t *testing.T) { + ins, path := openTempCopy(t, "signed.pdf", "tab-sig-deleted") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + _, err := ins.GetSignatures("tab-sig-deleted") + if !errors.Is(err, ErrFileChanged) || !errors.Is(err, fs.ErrNotExist) { + t.Errorf("err = %v, want ErrFileChanged wrapping fs.ErrNotExist", err) + } +} + +// beforeFileSpanRead runs right before readFileSpan opens the file, after the +// pre-walk check passed; it rewrites the file to simulate a save landing +// during the walk. +func TestFileChangeSignaturesRefuseChangeDuringWalk(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 !errors.Is(err, ErrFileChanged) { + t.Fatalf("change during the walk: err = %v, want ErrFileChanged", err) + } + if list != nil { + t.Errorf("a change during the walk must return no list") + } + + // The refusal is per call: once the Open-time mtime is back, the next walk + // reports the signatures 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 { + t.Errorf("restored file must report a matching hole again: %+v", list.Signatures) + } +} + +// An unsigned file has no hole to read, so only the check before the walk can +// refuse it. +func TestFileChangeSignaturesRefuseChangedUnsignedFile(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 !errors.Is(err, ErrFileChanged) { + t.Fatalf("err = %v, want ErrFileChanged for a changed file without signatures", err) + } + if list != nil { + t.Errorf("a changed file must return no 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) + } + } +} diff --git a/internal/pdfcore/inspector.go b/internal/pdfcore/inspector.go index 22b21dac..38f2bcb1 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,15 @@ 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 + + // sigReadErr carries ErrFileChanged out of a GetSignatures walk when a + // hole read finds the file changed. Guarded by pdfMu; reset per call. + sigReadErr error + // 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, @@ -146,6 +157,7 @@ func (ins *Inspector) Open(tabID, filePath string) (*DocumentInfo, error) { 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,6 +166,14 @@ 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) } @@ -181,6 +201,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 2b376d5e..d55f4b67 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) @@ -110,7 +109,12 @@ func (ins *Inspector) GetPlainTextSize(tabID string) (int64, error) { // 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) are never returned; a mismatch or a missing file returns +// ErrFileChanged. +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 +130,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 } @@ -161,6 +165,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 24c3d9cf..3980ffa6 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 ff925970..abc30115 100644 --- a/internal/pdfcore/signature.go +++ b/internal/pdfcore/signature.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "math/big" - "os" "strings" "time" @@ -116,18 +115,24 @@ const maxHoleProbeBytes = 4 * 1024 * 1024 // /Fields array (walking /Kids hierarchies with inherited /FT), decomposes // 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. +// per-entry error fields; the call itself fails only for unknown tabs, a +// document-level pdfcpu failure, or ErrFileChanged when the file on disk no +// longer matches the Open fingerprint (checked before the walk and on every +// hole read during it). Runs under doc.pdfMu and inside safeCall. func (ins *Inspector) GetSignatures(tabID string) (*SignatureList, error) { doc, err := ins.GetDocument(tabID) if err != nil { return nil, err } + if err := verifyPath(doc.FilePath, doc.fingerprint); errors.Is(err, ErrFileChanged) { + return nil, err + } // Serialize pdfcpu access: the walk dereferences the AcroForm, fields, // kids, and each /V dict, all of which touch pdfcpu's XRefTable. doc.pdfMu.Lock() defer doc.pdfMu.Unlock() + doc.sigReadErr = nil list := &SignatureList{Signatures: []SignatureField{}} err = safeCall(func() error { list.Signatures = collectSignatures(doc) @@ -136,6 +141,9 @@ func (ins *Inspector) GetSignatures(tabID string) (*SignatureList, error) { if err != nil { return nil, wrapPDFError(err) } + if doc.sigReadErr != nil { + return nil, doc.sigReadErr + } return list, nil } @@ -621,7 +629,11 @@ func holeMatchesContents(doc *DocumentState, sig pdfcpu_types.Dict, holeStart, h if holeEnd <= holeStart || holeEnd-holeStart > maxHoleProbeBytes { return false } - span, err := readFileSpan(doc.FilePath, holeStart, holeEnd) + span, err := readFileSpan(doc.FilePath, doc.fingerprint, holeStart, holeEnd) + if errors.Is(err, ErrFileChanged) { + doc.sigReadErr = err + return false + } if err != nil || len(span) < 2 { return false } @@ -633,9 +645,18 @@ func holeMatchesContents(doc *DocumentState, sig pdfcpu_types.Dict, holeStart, h return strings.EqualFold(inner, want) } -// 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. +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 } @@ -644,5 +665,8 @@ func readFileSpan(path string, start, end int64) ([]byte, error) { if _, err := fh.ReadAt(buf, start); err != nil { 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 ccc682c1..1dc2667a 100644 --- a/internal/pdfservice/service.go +++ b/internal/pdfservice/service.go @@ -12,6 +12,7 @@ import ( "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 +57,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 +75,10 @@ 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) } // NewPDFService creates a PDFService backed by a fresh Inspector. @@ -530,6 +537,37 @@ 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 +} + +// WatchDiskStatus builds a disk watcher over every document open in s 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 { + w := filewatch.New(s.inspector, 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 00000000..2ca65768 --- /dev/null +++ b/internal/pdfservice/service_diskstatus_test.go @@ -0,0 +1,144 @@ +// 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) + 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") + } + } +} diff --git a/main.go b/main.go index 73201971..9f3effe2 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 00000000..35c98be7 --- /dev/null +++ b/tests/file-change-detection/file_change_detection_test.go @@ -0,0 +1,182 @@ +// 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 refuses a changed file, before the walk and during +// it, instead of reporting a hole mismatch; an unchanged file reports as +// before. +func TestSignaturesRefuseChangedFile(t *testing.T) { + runColocated(t, "./internal/pdfcore/", true, + "TestFileChangeSignaturesRefuseChangedFile", + "TestFileChangeSignaturesRefuseDeletedFile", + "TestFileChangeSignaturesRefuseChangeDuringWalk", + "TestFileChangeSignaturesRefuseChangedUnsignedFile", + "TestFileChangeSignaturesUnchangedFileReportsAsBefore", + ) +} + +// dump bytes --json and dump signatures on a file rewritten after the CLI +// parsed it exit 2 with the file-changed message on stderr. +func TestCLIReportsChangedFileAsViewError(t *testing.T) { + runColocated(t, "./cmd/cli/", false, + "TestCLIFileChangedBytesJSON", + "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", + "TestDiskStatusUnknownTab", + "TestDiskStatusJSONShape", + ) + runColocated(t, "./internal/pdfservice/", true, + "TestGetDiskStatusReportsCurrentThenChanged", + "TestGetDiskStatusUnknownTab", + "TestGetDiskStatusResultIsRecordedInTheWatcher", + ) +} + +// 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", + "TestFileBusyOpenOfExclusivelyLockedFile", + "TestFileBusyPlainTextReadOfExclusivelyLockedFile", + ) + 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", + "TestWatcherSettleIgnoresFileStillBeingWritten", + "TestWatcherSettleReportsMissingAfterTwoChecks", + "TestWatcherStatErrorResetsSettleStreak", + "TestWatcherRecordedStatusLetsReturnToCurrentEmit", + "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", + "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 00000000..a594dcf8 --- /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 85913637..9b970f19 100644 --- a/tests/wails-alpha2-103-upgrade/bindings_contract_test.go +++ b/tests/wails-alpha2-103-upgrade/bindings_contract_test.go @@ -23,7 +23,7 @@ import ( // App.jsx : ConsumePendingOpenFiles // ObjectInfoPanel.tsx : GetObjectSource // DetailPanel.tsx : GetObjectDetail, GetContentStream, GetImageData, -// GetReverseRefs, GetFontView +// GetReverseRefs, GetFontView, GetDiskStatus // XRefTableView.tsx : GetXRefTable // CommandPalette.tsx : GetAncestorPath // TreePanel.tsx : GetChildren, GetAncestorPath @@ -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,7 @@ var consumerBoundMethods = []string{ "GetImageIndex", "GetImagePages", "GetImagePageGroups", + "GetDiskStatus", } // 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 f9c14711..8e03fc87 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 eed3c684..50c05b92 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,7 @@ var consumerBoundMethods = []string{ "GetImageIndex", "GetImagePages", "GetImagePageGroups", + "GetDiskStatus", } // bindingRelPath is the regenerated Wails JS binding for PDFService. From 1c4be12cf8ceaba51fd5453295c6afed7d91c996 Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Wed, 14 Oct 2026 23:02:04 +0700 Subject: [PATCH 02/10] test: temporary Windows diagnostic for the busy-file lock --- .../pdfcore/filebusy_diag_windows_test.go | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 internal/pdfcore/filebusy_diag_windows_test.go diff --git a/internal/pdfcore/filebusy_diag_windows_test.go b/internal/pdfcore/filebusy_diag_windows_test.go new file mode 100644 index 00000000..ef8c48a7 --- /dev/null +++ b/internal/pdfcore/filebusy_diag_windows_test.go @@ -0,0 +1,98 @@ +//go:build windows + +package pdfcore + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "unsafe" +) + +var ( + diagKernel32 = syscall.NewLazyDLL("kernel32.dll") + diagGetVolumePathNameW = diagKernel32.NewProc("GetVolumePathNameW") + diagGetVolumeInformationW = diagKernel32.NewProc("GetVolumeInformationW") + diagGetFinalPathNameByHandle = diagKernel32.NewProc("GetFinalPathNameByHandleW") +) + +func diagVolume(path string) string { + p, _ := syscall.UTF16PtrFromString(path) + vol := make([]uint16, 300) + r, _, e := diagGetVolumePathNameW.Call(uintptr(unsafe.Pointer(p)), uintptr(unsafe.Pointer(&vol[0])), uintptr(len(vol))) + if r == 0 { + return fmt.Sprintf("GetVolumePathName failed: %v", e) + } + fs := make([]uint16, 64) + var flags uint32 + r, _, e = diagGetVolumeInformationW.Call(uintptr(unsafe.Pointer(&vol[0])), 0, 0, 0, 0, + uintptr(unsafe.Pointer(&flags)), uintptr(unsafe.Pointer(&fs[0])), uintptr(len(fs))) + if r == 0 { + return fmt.Sprintf("volume %s, GetVolumeInformation failed: %v", syscall.UTF16ToString(vol), e) + } + return fmt.Sprintf("volume %s fs %s flags 0x%x", syscall.UTF16ToString(vol), syscall.UTF16ToString(fs), flags) +} + +func diagFinalPath(h syscall.Handle) string { + buf := make([]uint16, 600) + r, _, e := diagGetFinalPathNameByHandle.Call(uintptr(h), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)), 0) + if r == 0 { + return fmt.Sprintf("GetFinalPathNameByHandle failed: %v", e) + } + return syscall.UTF16ToString(buf) +} + +func diagLockAndOpen(t *testing.T, path string) string { + var b strings.Builder + fmt.Fprintf(&b, "path %s\n %s\n", path, diagVolume(path)) + p, _ := syscall.UTF16PtrFromString(path) + h, err := syscall.CreateFile(p, syscall.GENERIC_READ, 0, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + fmt.Fprintf(&b, " lock CreateFile(share=0): handle=%v err=%v\n", h, err) + if err != nil { + return b.String() + } + defer func() { _ = syscall.CloseHandle(h) }() + fmt.Fprintf(&b, " lock handle final path: %s\n", diagFinalPath(h)) + + f, err := os.Open(path) + fmt.Fprintf(&b, " os.Open: err=%v (%T)\n", err, err) + if err == nil { + fmt.Fprintf(&b, " os.Open handle final path: %s\n", diagFinalPath(syscall.Handle(f.Fd()))) + _ = f.Close() + } + h2, err := syscall.CreateFile(p, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + fmt.Fprintf(&b, " second CreateFile(GENERIC_READ, share=R|W): handle=%v err=%v\n", h2, err) + if err == nil { + _ = syscall.CloseHandle(h2) + } + h3, err := syscall.CreateFile(p, syscall.GENERIC_READ, 0, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + fmt.Fprintf(&b, " second CreateFile(GENERIC_READ, share=0): handle=%v err=%v\n", h3, err) + if err == nil { + _ = syscall.CloseHandle(h3) + } + _, err = NewInspector().Open("tab-diag", path) + fmt.Fprintf(&b, " Inspector.Open: err=%v\n", err) + return b.String() +} + +// Temporary: reports why a share-mode-0 lock does not block os.Open on the CI runner. +func TestFileBusyDiagnostic(t *testing.T) { + report := "TEMP=" + os.Getenv("TEMP") + "\n" + report += diagLockAndOpen(t, copyFixtureToTemp(t, "minimal.pdf")) + + wd, _ := os.Getwd() + local := filepath.Join(wd, "filebusy-diag.pdf") + data, err := os.ReadFile(filepath.Join(testdataDir(t), "minimal.pdf")) + if err == nil { + if err := os.WriteFile(local, data, 0o644); err == nil { + defer func() { _ = os.Remove(local) }() + report += diagLockAndOpen(t, local) + } else { + report += fmt.Sprintf("write %s: %v\n", local, err) + } + } + t.Errorf("diagnostic report:\n%s", report) +} From 59491c39c5ddbc1272439bf115caa36db2bf95a8 Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Wed, 14 Oct 2026 23:11:31 +0700 Subject: [PATCH 03/10] test: check backup semantics in the Windows lock diagnostic --- internal/pdfcore/filebusy_diag_windows_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/pdfcore/filebusy_diag_windows_test.go b/internal/pdfcore/filebusy_diag_windows_test.go index ef8c48a7..04ae8ff3 100644 --- a/internal/pdfcore/filebusy_diag_windows_test.go +++ b/internal/pdfcore/filebusy_diag_windows_test.go @@ -5,6 +5,7 @@ package pdfcore import ( "fmt" "os" + "os/exec" "path/filepath" "strings" "syscall" @@ -73,6 +74,11 @@ func diagLockAndOpen(t *testing.T, path string) string { if err == nil { _ = syscall.CloseHandle(h3) } + h4, err := syscall.CreateFile(p, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL|syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) + fmt.Fprintf(&b, " second CreateFile(GENERIC_READ, share=R|W, BACKUP_SEMANTICS): handle=%v err=%v\n", h4, err) + if err == nil { + _ = syscall.CloseHandle(h4) + } _, err = NewInspector().Open("tab-diag", path) fmt.Fprintf(&b, " Inspector.Open: err=%v\n", err) return b.String() @@ -94,5 +100,7 @@ func TestFileBusyDiagnostic(t *testing.T) { report += fmt.Sprintf("write %s: %v\n", local, err) } } + out, err := exec.Command("whoami", "/priv").CombinedOutput() + report += fmt.Sprintf("whoami /priv (err=%v):\n%s", err, out) t.Errorf("diagnostic report:\n%s", report) } From 12729ec7e1a186224b25e6c7b29c64b8b20a914b Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Thu, 15 Oct 2026 10:48:52 +0700 Subject: [PATCH 04/10] fix: report a byte-range-locked file as busy on read; lock test files with LockFileEx --- internal/pdfcore/errors.go | 4 +- .../pdfcore/filebusy_diag_windows_test.go | 106 ------------------ internal/pdfcore/filebusy_windows_test.go | 56 +++++---- internal/pdfcore/plaintext.go | 3 + 4 files changed, 38 insertions(+), 131 deletions(-) delete mode 100644 internal/pdfcore/filebusy_diag_windows_test.go diff --git a/internal/pdfcore/errors.go b/internal/pdfcore/errors.go index 59ede89c..763a81ff 100644 --- a/internal/pdfcore/errors.go +++ b/internal/pdfcore/errors.go @@ -32,8 +32,8 @@ var ( // 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 (Windows sharing or lock violation). The text carries the marker - // the frontend matches. + // 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") ) diff --git a/internal/pdfcore/filebusy_diag_windows_test.go b/internal/pdfcore/filebusy_diag_windows_test.go deleted file mode 100644 index 04ae8ff3..00000000 --- a/internal/pdfcore/filebusy_diag_windows_test.go +++ /dev/null @@ -1,106 +0,0 @@ -//go:build windows - -package pdfcore - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "testing" - "unsafe" -) - -var ( - diagKernel32 = syscall.NewLazyDLL("kernel32.dll") - diagGetVolumePathNameW = diagKernel32.NewProc("GetVolumePathNameW") - diagGetVolumeInformationW = diagKernel32.NewProc("GetVolumeInformationW") - diagGetFinalPathNameByHandle = diagKernel32.NewProc("GetFinalPathNameByHandleW") -) - -func diagVolume(path string) string { - p, _ := syscall.UTF16PtrFromString(path) - vol := make([]uint16, 300) - r, _, e := diagGetVolumePathNameW.Call(uintptr(unsafe.Pointer(p)), uintptr(unsafe.Pointer(&vol[0])), uintptr(len(vol))) - if r == 0 { - return fmt.Sprintf("GetVolumePathName failed: %v", e) - } - fs := make([]uint16, 64) - var flags uint32 - r, _, e = diagGetVolumeInformationW.Call(uintptr(unsafe.Pointer(&vol[0])), 0, 0, 0, 0, - uintptr(unsafe.Pointer(&flags)), uintptr(unsafe.Pointer(&fs[0])), uintptr(len(fs))) - if r == 0 { - return fmt.Sprintf("volume %s, GetVolumeInformation failed: %v", syscall.UTF16ToString(vol), e) - } - return fmt.Sprintf("volume %s fs %s flags 0x%x", syscall.UTF16ToString(vol), syscall.UTF16ToString(fs), flags) -} - -func diagFinalPath(h syscall.Handle) string { - buf := make([]uint16, 600) - r, _, e := diagGetFinalPathNameByHandle.Call(uintptr(h), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)), 0) - if r == 0 { - return fmt.Sprintf("GetFinalPathNameByHandle failed: %v", e) - } - return syscall.UTF16ToString(buf) -} - -func diagLockAndOpen(t *testing.T, path string) string { - var b strings.Builder - fmt.Fprintf(&b, "path %s\n %s\n", path, diagVolume(path)) - p, _ := syscall.UTF16PtrFromString(path) - h, err := syscall.CreateFile(p, syscall.GENERIC_READ, 0, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) - fmt.Fprintf(&b, " lock CreateFile(share=0): handle=%v err=%v\n", h, err) - if err != nil { - return b.String() - } - defer func() { _ = syscall.CloseHandle(h) }() - fmt.Fprintf(&b, " lock handle final path: %s\n", diagFinalPath(h)) - - f, err := os.Open(path) - fmt.Fprintf(&b, " os.Open: err=%v (%T)\n", err, err) - if err == nil { - fmt.Fprintf(&b, " os.Open handle final path: %s\n", diagFinalPath(syscall.Handle(f.Fd()))) - _ = f.Close() - } - h2, err := syscall.CreateFile(p, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) - fmt.Fprintf(&b, " second CreateFile(GENERIC_READ, share=R|W): handle=%v err=%v\n", h2, err) - if err == nil { - _ = syscall.CloseHandle(h2) - } - h3, err := syscall.CreateFile(p, syscall.GENERIC_READ, 0, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) - fmt.Fprintf(&b, " second CreateFile(GENERIC_READ, share=0): handle=%v err=%v\n", h3, err) - if err == nil { - _ = syscall.CloseHandle(h3) - } - h4, err := syscall.CreateFile(p, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL|syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) - fmt.Fprintf(&b, " second CreateFile(GENERIC_READ, share=R|W, BACKUP_SEMANTICS): handle=%v err=%v\n", h4, err) - if err == nil { - _ = syscall.CloseHandle(h4) - } - _, err = NewInspector().Open("tab-diag", path) - fmt.Fprintf(&b, " Inspector.Open: err=%v\n", err) - return b.String() -} - -// Temporary: reports why a share-mode-0 lock does not block os.Open on the CI runner. -func TestFileBusyDiagnostic(t *testing.T) { - report := "TEMP=" + os.Getenv("TEMP") + "\n" - report += diagLockAndOpen(t, copyFixtureToTemp(t, "minimal.pdf")) - - wd, _ := os.Getwd() - local := filepath.Join(wd, "filebusy-diag.pdf") - data, err := os.ReadFile(filepath.Join(testdataDir(t), "minimal.pdf")) - if err == nil { - if err := os.WriteFile(local, data, 0o644); err == nil { - defer func() { _ = os.Remove(local) }() - report += diagLockAndOpen(t, local) - } else { - report += fmt.Sprintf("write %s: %v\n", local, err) - } - } - out, err := exec.Command("whoami", "/priv").CombinedOutput() - report += fmt.Sprintf("whoami /priv (err=%v):\n%s", err, out) - t.Errorf("diagnostic report:\n%s", report) -} diff --git a/internal/pdfcore/filebusy_windows_test.go b/internal/pdfcore/filebusy_windows_test.go index d24b14ce..2676dd71 100644 --- a/internal/pdfcore/filebusy_windows_test.go +++ b/internal/pdfcore/filebusy_windows_test.go @@ -14,6 +14,7 @@ import ( "strings" "syscall" "testing" + "unsafe" ) const ( @@ -42,22 +43,14 @@ func TestFileBusyRecognisesWindowsErrnos(t *testing.T) { } } -// Opening a file another process holds without read sharing returns +// 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") - p, err := syscall.UTF16PtrFromString(path) - if err != nil { - t.Fatal(err) - } - h, err := syscall.CreateFile(p, syscall.GENERIC_READ, 0, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) - if err != nil { - t.Fatalf("lock file: %v", err) - } - defer func() { _ = syscall.CloseHandle(h) }() + lockWholeFile(t, path) ins := NewInspector() - _, err = ins.Open("tab-busy", path) + _, err := ins.Open("tab-busy", path) if !errors.Is(err, ErrFileBusy) { t.Fatalf("Open of a locked file: err = %v, want ErrFileBusy", err) } @@ -69,21 +62,13 @@ func TestFileBusyOpenOfExclusivelyLockedFile(t *testing.T) { } } -// A plain-text read of a file another process holds without read sharing -// returns ErrFileBusy, not ErrMalformedPDF. +// 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") - p, err := syscall.UTF16PtrFromString(path) - if err != nil { - t.Fatal(err) - } - h, err := syscall.CreateFile(p, syscall.GENERIC_READ, 0, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) - if err != nil { - t.Fatalf("lock file: %v", err) - } - defer func() { _ = syscall.CloseHandle(h) }() + lockWholeFile(t, path) - _, err = ins.GetPlainText(context.Background(), "tab-busy-read") + _, 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) } @@ -91,3 +76,28 @@ func TestFileBusyPlainTextReadOfExclusivelyLockedFile(t *testing.T) { t.Errorf("a busy file must not read as malformed: %v", 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/plaintext.go b/internal/pdfcore/plaintext.go index d55f4b67..8adb0e2a 100644 --- a/internal/pdfcore/plaintext.go +++ b/internal/pdfcore/plaintext.go @@ -156,6 +156,9 @@ func readPlainText(ctx context.Context, path string, size int64, fp FileFingerpr 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 From 98e2ff89900613c95bcdae2ac7431c7074aea896 Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Thu, 15 Oct 2026 11:34:18 +0700 Subject: [PATCH 05/10] fix: signatures read the file only for signed fields, stop at the first refused read and report a locked file as busy --- .../components/DetailPanel.staleNote.test.tsx | 12 ++ frontend/src/components/DetailPanel.tsx | 20 ++- frontend/src/components/SignaturesView.tsx | 13 +- frontend/src/lib/fileChange.ts | 3 + internal/pdfcore/filebusy_windows_test.go | 12 ++ internal/pdfcore/filechange.go | 16 --- internal/pdfcore/filechange_test.go | 14 +- internal/pdfcore/inspector.go | 4 - internal/pdfcore/signature.go | 127 ++++++++++-------- 9 files changed, 133 insertions(+), 88 deletions(-) diff --git a/frontend/src/components/DetailPanel.staleNote.test.tsx b/frontend/src/components/DetailPanel.staleNote.test.tsx index 44865a34..14490248 100644 --- a/frontend/src/components/DetailPanel.staleNote.test.tsx +++ b/frontend/src/components/DetailPanel.staleNote.test.tsx @@ -282,6 +282,18 @@ describe('signatures refused because the file changed', () => { expect(await screen.findByText('File changed on disk. Reload to view.')).toBeInTheDocument(); }); + test('a file locked by another app keeps the Signatures tab with the busy hint', async () => { + const p = paths(); + b.GetSignatures.mockRejectedValue(envelope('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('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')); diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index 7a36111b..6e212581 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -12,7 +12,7 @@ import { GetObjectDetail, GetContentStream, GetImageData, DescribeImage, SaveIma import { ContentStreamData, 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 { FILE_MISSING_MESSAGE, isFileBusyMessage, isFileChangedMessage } from '../lib/fileChange'; import { markTabFromDiskCheck } from '../lib/diskCheck'; import { setDiskStatusFromCheck, type DiskStatusInfo } from '../lib/diskStatusStore'; import { formatTimestamp } from '../lib/formatTimestamp'; @@ -198,9 +198,12 @@ function DetailPanelInner() { // refetch per tab switch); the tab is simply absent until the fetch // resolves with >= 1 signature field. null = not yet resolved. const [signatures, setSignatures] = useState(null); - // True when GetSignatures was refused because the file changed on disk: the - // Signatures tab stays visible and shows the reload hint. + // True when GetSignatures was refused because the file changed on disk, or + // because another app has it locked: the Signatures tab stays visible and + // shows the reload hint. Only a document with signed fields reads the file, + // so neither is ever set for an unsigned document. const [signaturesFileChanged, setSignaturesFileChanged] = useState(false); + const [signaturesFileBusy, setSignaturesFileBusy] = useState(false); // The second (comparison) document's tab ID for the Diff tab, and // any picker error. Reset on document switch so the diff never carries over a // stale comparison from a previously-active tab. @@ -253,6 +256,7 @@ function DetailPanelInner() { setEmbeddedCount(null); setSignatures(null); setSignaturesFileChanged(false); + setSignaturesFileBusy(false); setDiffRightTabId(null); setDiffError(null); setCompareConfirm(null); @@ -296,11 +300,16 @@ function DetailPanelInner() { setSignatures([]); // A read refused because the file changed keeps the tab visible with // the reload hint and marks the tab from a fresh disk check. - if (isFileChangedMessage(extractErrorMessage(err))) { + const message = extractErrorMessage(err); + if (isFileChangedMessage(message)) { setSignaturesFileChanged(true); markTabFromDiskCheck(activeTabId); return; } + if (isFileBusyMessage(message)) { + setSignaturesFileBusy(true); + return; + } // eslint-disable-next-line no-console console.warn('GetSignatures failed:', err); }); @@ -932,7 +941,7 @@ function DetailPanelInner() { // The Signatures tab exists only when the document has >= 1 signature // field (hidden while unresolved or empty -- a deliberate departure from // the always-visible tabs, avoiding a permanently empty tab). - const showSignaturesTab = (signatures?.length ?? 0) > 0 || signaturesFileChanged; + const showSignaturesTab = (signatures?.length ?? 0) > 0 || signaturesFileChanged || signaturesFileBusy; // FontPreview is active when iconHint='font', detail is a dict, and the // fetch resolved to a detail payload (not fallback / error). The header @@ -1441,6 +1450,7 @@ function DetailPanelInner() { onNavigate={handleSignaturesNavigate} data={signatures} fileChanged={signaturesFileChanged} + fileBusy={signaturesFileBusy} /> )} diff --git a/frontend/src/components/SignaturesView.tsx b/frontend/src/components/SignaturesView.tsx index f7775728..8053176a 100644 --- a/frontend/src/components/SignaturesView.tsx +++ b/frontend/src/components/SignaturesView.tsx @@ -10,7 +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_CHANGED_MESSAGE } from '../lib/fileChange'; +import { FILE_BUSY_MESSAGE, FILE_CHANGED_MESSAGE } from '../lib/fileChange'; import { SMALL_BUTTON } from './buttonStyles'; /** One decomposed X.509 certificate, mirroring `pdfcore.CertInfo`. */ @@ -67,6 +67,8 @@ export interface SignaturesViewProps { data?: SignatureEntryData[] | null; /** True when the parent's fetch was refused because the file changed on disk. */ fileChanged?: boolean; + /** True when the parent's fetch was refused because another app has the file locked. */ + fileBusy?: boolean; } /** Extracts the CN component from an X.509 DN string, falling back to the @@ -269,7 +271,7 @@ function SignatureCard({ entry, onNavigate }: { entry: SignatureEntryData; onNav * `data` prop is provided (DetailPanel's one-fetch-per-document contract); * self-fetches on tabId change otherwise. */ -export function SignaturesView({ tabId, active: _active, onNavigate, data, fileChanged }: SignaturesViewProps) { +export function SignaturesView({ tabId, active: _active, onNavigate, data, fileChanged, fileBusy }: SignaturesViewProps) { const [entries, setEntries] = useState(data ?? null); const [error, setError] = useState(null); @@ -313,6 +315,13 @@ export function SignaturesView({ tabId, active: _active, onNavigate, data, fileC
); } + if (fileBusy) { + return ( +
+ {FILE_BUSY_MESSAGE} +
+ ); + } if (error) { return (
diff --git a/frontend/src/lib/fileChange.ts b/frontend/src/lib/fileChange.ts index 5ff8354e..4fe6a58c 100644 --- a/frontend/src/lib/fileChange.ts +++ b/frontend/src/lib/fileChange.ts @@ -12,6 +12,9 @@ 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'; diff --git a/internal/pdfcore/filebusy_windows_test.go b/internal/pdfcore/filebusy_windows_test.go index 2676dd71..e3c328f7 100644 --- a/internal/pdfcore/filebusy_windows_test.go +++ b/internal/pdfcore/filebusy_windows_test.go @@ -77,6 +77,18 @@ func TestFileBusyPlainTextReadOfExclusivelyLockedFile(t *testing.T) { } } +// A signature hole read of a file another handle holds an exclusive +// byte-range lock on returns ErrFileBusy, 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 !errors.Is(err, ErrFileBusy) { + t.Fatalf("GetSignatures of a locked file: err = %v, list = %+v, want ErrFileBusy", err, list) + } +} + var procLockFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("LockFileEx") // lockWholeFile takes an exclusive byte-range lock on all of path through a diff --git a/internal/pdfcore/filechange.go b/internal/pdfcore/filechange.go index dd3ddb96..7bb6efb0 100644 --- a/internal/pdfcore/filechange.go +++ b/internal/pdfcore/filechange.go @@ -87,22 +87,6 @@ func verifyHandle(f *os.File, fp FileFingerprint) error { return nil } -// verifyPath stats path against fp: ErrFileChanged on a mismatch or a missing -// file (wrapping fs.ErrNotExist), nil on a match, other stat errors as is. -func verifyPath(path string, fp FileFingerprint) error { - fi, err := os.Stat(path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("%w: %w", ErrFileChanged, err) - } - 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() diff --git a/internal/pdfcore/filechange_test.go b/internal/pdfcore/filechange_test.go index 1087589f..f8146c6c 100644 --- a/internal/pdfcore/filechange_test.go +++ b/internal/pdfcore/filechange_test.go @@ -261,17 +261,17 @@ func TestFileChangeSignaturesRefuseChangeDuringWalk(t *testing.T) { } } -// An unsigned file has no hole to read, so only the check before the walk can -// refuse it. -func TestFileChangeSignaturesRefuseChangedUnsignedFile(t *testing.T) { +// 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 !errors.Is(err, ErrFileChanged) { - t.Fatalf("err = %v, want ErrFileChanged for a changed file without signatures", err) + if err != nil { + t.Fatalf("err = %v, want nil for a changed file without signatures", err) } - if list != nil { - t.Errorf("a changed file must return no signature list, got %+v", list) + if list == nil || len(list.Signatures) != 0 { + t.Errorf("want an empty signature list, got %+v", list) } } diff --git a/internal/pdfcore/inspector.go b/internal/pdfcore/inspector.go index 38f2bcb1..b9858f9d 100644 --- a/internal/pdfcore/inspector.go +++ b/internal/pdfcore/inspector.go @@ -61,10 +61,6 @@ type DocumentState struct { // against it and refuse bytes from a file that changed. fingerprint FileFingerprint - // sigReadErr carries ErrFileChanged out of a GetSignatures walk when a - // hole read finds the file changed. Guarded by pdfMu; reset per call. - sigReadErr error - // 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, diff --git a/internal/pdfcore/signature.go b/internal/pdfcore/signature.go index abc30115..64593981 100644 --- a/internal/pdfcore/signature.go +++ b/internal/pdfcore/signature.go @@ -116,62 +116,64 @@ 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, a -// document-level pdfcpu failure, or ErrFileChanged when the file on disk no -// longer matches the Open fingerprint (checked before the walk and on every -// hole read during it). Runs under doc.pdfMu and inside safeCall. +// document-level pdfcpu failure, or a hole read that finds the file changed +// (ErrFileChanged) or locked by another process (ErrFileBusy); the walk stops +// at that read. A document without signed fields never reads the file. Runs +// under doc.pdfMu and inside safeCall. func (ins *Inspector) GetSignatures(tabID string) (*SignatureList, error) { doc, err := ins.GetDocument(tabID) if err != nil { return nil, err } - if err := verifyPath(doc.FilePath, doc.fingerprint); errors.Is(err, ErrFileChanged) { - return nil, err - } // Serialize pdfcpu access: the walk dereferences the AcroForm, fields, // kids, and each /V dict, all of which touch pdfcpu's XRefTable. doc.pdfMu.Lock() defer doc.pdfMu.Unlock() - doc.sigReadErr = nil list := &SignatureList{Signatures: []SignatureField{}} + var readErr error err = safeCall(func() error { - list.Signatures = collectSignatures(doc) + list.Signatures, readErr = collectSignatures(doc) return nil }) if err != nil { return nil, wrapPDFError(err) } - if doc.sigReadErr != nil { - return nil, doc.sigReadErr + if readErr != nil { + return nil, readErr } return list, nil } -// collectSignatures walks /AcroForm /Fields in document order. Caller MUST -// hold doc.pdfMu and run inside safeCall. -func collectSignatures(doc *DocumentState) []SignatureField { +// collectSignatures walks /AcroForm /Fields in document order. It stops at the +// first hole read that returns ErrFileChanged or ErrFileBusy and returns that +// error. Caller MUST hold doc.pdfMu and run inside safeCall. +func collectSignatures(doc *DocumentState) ([]SignatureField, error) { sigs := []SignatureField{} cat, err := doc.PDFContext.Catalog() if err != nil { - return sigs + return sigs, nil } acro := asDict(dereference(doc, cat["AcroForm"])) if acro == nil { - return sigs + return sigs, nil } for _, elem := range dereferenceArray(doc, acro["Fields"]) { - walkSignatureField(doc, elem, "", "", 0, &sigs) + if err := walkSignatureField(doc, elem, "", "", 0, &sigs); err != nil { + return nil, err + } } - return sigs + return sigs, nil } // walkSignatureField recurses one field-tree node. /FT is inheritable via the // 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) { +// Terminal fields whose effective /FT is Sig are appended to out. A file read +// error from buildSignatureField stops the walk. +func walkSignatureField(doc *DocumentState, obj pdfcpu_types.Object, parentName, inheritedFT string, depth int, out *[]SignatureField) error { if depth > maxNodeIDDepth { - return + return nil } fieldNodeID := "" if ref, ok := obj.(pdfcpu_types.IndirectRef); ok { @@ -179,7 +181,7 @@ func walkSignatureField(doc *DocumentState, obj pdfcpu_types.Object, parentName, } d := asDict(dereference(doc, obj)) if d == nil { - return + return nil } name := parentName @@ -202,20 +204,28 @@ func walkSignatureField(doc *DocumentState, obj pdfcpu_types.Object, parentName, continue } if _, hasT := kd["T"]; hasT { - walkSignatureField(doc, kid, name, ft, depth+1, out) + if err := walkSignatureField(doc, kid, name, ft, depth+1, out); err != nil { + return err + } recursed = true } } if recursed || ft != "Sig" { - return + return nil } - *out = append(*out, buildSignatureField(doc, d, name, fieldNodeID)) + f, err := buildSignatureField(doc, d, name, fieldNodeID) + if err != nil { + return err + } + *out = append(*out, f) + return nil } // 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 { +// and per-SubFilter decomposition routing. The error is ErrFileChanged or +// ErrFileBusy from the hole read; every other problem is a per-entry field. +func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fieldNodeID string) (SignatureField, error) { f := SignatureField{ FieldName: name, FieldNodeID: fieldNodeID, @@ -227,13 +237,13 @@ func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fiel if !hasV { // An unsigned placeholder is listed with no decomposition and no // error. - return f + return f, nil } var sig pdfcpu_types.Dict if ref, isRef := vObj.(pdfcpu_types.IndirectRef); isRef { sig = asDict(dereference(doc, vObj)) if sig == nil { - return f + return f, nil } f.SignatureRef = refString(ref) f.SignatureNodeID = nodeIDFromRef(ref) @@ -242,7 +252,7 @@ func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fiel // the ref fields empty. sig = asDict(vObj) if sig == nil { - return f + return f, nil } } @@ -264,7 +274,9 @@ 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) + if err := computeByteRangeCoverage(doc, sig, &f); err != nil { + return f, err + } switch { case isCMSSubFilter(f.SubFilter): @@ -280,7 +292,7 @@ func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fiel default: f.Notes = append(f.Notes, "subFilter not decomposed") } - return f + return f, nil } // isCMSSubFilter reports whether sub names a CMS-based signature whose @@ -558,41 +570,42 @@ func stripPDFWhitespace(s string) string { // computeByteRangeCoverage measures /ByteRange against the file: whole-file // 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) { +// is explicitly a measurement, never a validity verdict. The error is +// ErrFileChanged or ErrFileBusy from the hole read. +func computeByteRangeCoverage(doc *DocumentState, sig pdfcpu_types.Dict, f *SignatureField) error { brObj, ok := sig["ByteRange"] if !ok { f.CoverageError = "/ByteRange missing" - return + return nil } arr := dereferenceArray(doc, brObj) if arr == nil { f.CoverageError = "/ByteRange is not an array" - return + return nil } ints := make([]int64, 0, len(arr)) for _, e := range arr { n, isInt := dereference(doc, e).(pdfcpu_types.Integer) if !isInt { f.CoverageError = "/ByteRange contains a non-integer entry" - return + return nil } ints = append(ints, int64(n)) } f.ByteRange = ints if len(ints)%2 == 1 { f.CoverageError = fmt.Sprintf("/ByteRange has an odd number of entries (%d)", len(ints)) - return + return nil } if len(ints) != 4 { f.CoverageError = fmt.Sprintf("/ByteRange has %d entries; expected 4 (offset length offset length)", len(ints)) - return + return nil } size := doc.FileSize for _, n := range ints { if n < 0 { f.CoverageError = "/ByteRange contains a negative value" - return + return nil } // Bound every entry by the file size BEFORE summing. A crafted value // (e.g. 2^62) would otherwise overflow the start+len sums below to a @@ -601,48 +614,51 @@ func computeByteRangeCoverage(doc *DocumentState, sig pdfcpu_types.Dict, f *Sign // failure. Bounding each entry keeps every sum in [0, 2*size]. if n > size { f.CoverageError = "/ByteRange value exceeds the file size" - return + return nil } } start1, len1, start2, len2 := ints[0], ints[1], ints[2], ints[3] if start1+len1 > size || start2+len2 > size { f.CoverageError = "/ByteRange extends past the end of the file" - return + return nil } holeStart := start1 + len1 if start2 < holeStart { f.CoverageError = "/ByteRange ranges overlap" - return + return nil } f.TrailingGap = size - (start2 + len2) f.CoversWholeFile = start1 == 0 && f.TrailingGap == 0 - f.HoleMatchesContents = holeMatchesContents(doc, sig, holeStart, start2) + match, err := holeMatchesContents(doc, sig, holeStart, start2) + f.HoleMatchesContents = match + return err } // 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 (ErrFileChanged) or is locked by another process +// (ErrFileBusy) is returned as an error; any other read failure is a mismatch. +func holeMatchesContents(doc *DocumentState, 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 } span, err := readFileSpan(doc.FilePath, doc.fingerprint, holeStart, holeEnd) - if errors.Is(err, ErrFileChanged) { - doc.sigReadErr = err - return false + if errors.Is(err, ErrFileChanged) || errors.Is(err, ErrFileBusy) { + return false, err } 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 } // beforeFileSpanRead, when set, runs right before readFileSpan opens the file. @@ -651,7 +667,7 @@ 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. +// 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) @@ -663,6 +679,9 @@ func readFileSpan(path string, fp FileFingerprint, start, end int64) ([]byte, er 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 { From 7055508ae8ac4e3578233fbccc9b83df1611d6bd Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Thu, 15 Oct 2026 11:34:18 +0700 Subject: [PATCH 06/10] fix: reload keeps focus on the tab you switched to, re-checks the disk, handles Cmd/Ctrl+R itself; watcher polls only strip tabs --- CHANGELOG.md | 4 +- frontend/src/App.drain.test.tsx | 1 + frontend/src/App.geometry.test.tsx | 1 + frontend/src/App.jsx | 33 ++++---- frontend/src/App.menuNavigation.test.tsx | 1 + frontend/src/App.pagesNavigator.test.tsx | 1 + frontend/src/App.reload.test.tsx | 75 ++++++++++++++++++- frontend/src/App.test.tsx | 1 + .../hooks/useDocumentState.reload.test.tsx | 65 +++++++++++++++- frontend/src/hooks/useDocumentState.tsx | 46 ++++++++++-- frontend/src/hooks/useFindBar.ts | 3 +- frontend/src/lib/reloadDocument.ts | 17 +++-- internal/pdfservice/service.go | 67 +++++++++++++++-- .../pdfservice/service_diskstatus_test.go | 61 +++++++++++++++ .../file_change_detection_test.go | 4 +- .../bindings_contract_test.go | 3 +- .../bindings_contract_test.go | 1 + 17 files changed, 342 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7314f65..cd85eadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ All notable changes to UniDoc PDF Debugger are recorded here. Format follows Kee ### 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. 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 +- 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 settles while the window has focus, 10 to 20 seconds otherwise) 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, Signatures and the CLI's `dump bytes --json` and `dump signatures` 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 used to report a hole mismatch in that case +- Plain Text, Signatures and the CLI's `dump bytes --json` and `dump signatures` 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 used to report a hole mismatch in that case; a document without signed fields never reads the file and is not affected. On Windows a signed file another app has locked shows "File is in use by another app" in the Signatures tab instead of a hole mismatch ## [0.5.0] - 2026-10-14 diff --git a/frontend/src/App.drain.test.tsx b/frontend/src/App.drain.test.tsx index 41be8438..c4871395 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 09de5681..3a1d3ff7 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 e24ee825..70e7b4ea 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,10 +4,11 @@ */ import { useEffect, 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' @@ -54,29 +55,34 @@ function AppContent() { 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. + // 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. useEffect(() => { - retainDiskStatus(tabs.map((t) => t.tabId)) + const ids = tabs.map((t) => t.tabId) + retainDiskStatus(ids) + Promise.resolve(SetWatchedTabs(ids)).catch(() => {}) }, [tabs]) // Reload from disk (File > Reload from Disk, its Cmd/Ctrl+R accelerator, and - // F5) always acts on the active tab, stale or not. + // 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; reloadTab ignores the second trigger when + // the menu accelerator fires too. useEffect(() => { const reloadActive = () => { const id = activeTabIdRef.current if (id) void reloadTab(dispatch, () => tabsRef.current, id) } - /** @param {EventTarget | null} target */ - function isInTextField(target) { - if (!(target instanceof HTMLElement)) return false - const tag = target.tagName - return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable - } + const wantsMeta = getPlatformModifier() === 'Cmd' /** @param {KeyboardEvent} e */ function onKeyDown(e) { - if (e.key !== 'F5' || e.repeat) return - if (isInTextField(e.target)) return + const mod = wantsMeta ? e.metaKey : e.ctrlKey + const isReloadKey = e.key === 'F5' || (mod && !e.shiftKey && !e.altKey && (e.key === 'r' || e.key === 'R')) + if (!isReloadKey) return + // Cmd/Ctrl+R never reaches the webview reload, even in a text field. e.preventDefault() + if (e.repeat || isInTextField(e.target)) return reloadActive() } const offReload = Events.On('document:reload-active', reloadActive) @@ -89,7 +95,8 @@ function AppContent() { // 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 (a Diff right side) are ignored. + // 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 diff --git a/frontend/src/App.menuNavigation.test.tsx b/frontend/src/App.menuNavigation.test.tsx index 745dfd6d..40681d9f 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 072aa8ea..84b9914c 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 index d10012e8..c3e2421d 100644 --- a/frontend/src/App.reload.test.tsx +++ b/frontend/src/App.reload.test.tsx @@ -16,6 +16,8 @@ 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 = {}; @@ -48,6 +50,7 @@ vi.mock('../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () 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(), @@ -264,7 +267,50 @@ describe('reload triggers', () => { mockOpenResult(t.fresh, t.pathA); act(() => emitEvent('document:reload-active')); await waitFor(() => expect(bindings.OpenFile).toHaveBeenCalledWith(t.pathA)); - expect(bindings.GetDiskStatus).not.toHaveBeenCalled(); + 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); }); }); @@ -364,9 +410,36 @@ describe('reload outcome', () => { 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 changed event marks the tab and announces it once', () => { const t = ids(); render(); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e52294c2..593eff49 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/hooks/useDocumentState.reload.test.tsx b/frontend/src/hooks/useDocumentState.reload.test.tsx index 57d12cc7..358e3c9b 100644 --- a/frontend/src/hooks/useDocumentState.reload.test.tsx +++ b/frontend/src/hooks/useDocumentState.reload.test.tsx @@ -133,15 +133,28 @@ describe('reload replaces the tab like a new open', () => { expect(state.tabActivationVersion).toBe(version + 1); }); - test('activates the reloaded tab when another tab became active meanwhile', () => { + 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('a2'); + 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'); }); }); @@ -179,14 +192,60 @@ describe('reload leaves batch-open bookkeeping alone', () => { expect(state.batchOpenCancelled).toBe(true); }); - test('a reload outside any batch keeps an unrelated warning', () => { + 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', () => { diff --git a/frontend/src/hooks/useDocumentState.tsx b/frontend/src/hooks/useDocumentState.tsx index b383e814..5ee4c332 100644 --- a/frontend/src/hooks/useDocumentState.tsx +++ b/frontend/src/hooks/useDocumentState.tsx @@ -142,12 +142,15 @@ export interface AppState { /** Union of all actions the app reducer handles. */ export type AppAction = - // reload: a reload of an open tab; it skips the batch-open bookkeeping and keeps the warning. - | { type: 'OPEN_DOCUMENT'; payload: { tabId: string; fileName: string; filePath: string; pageCount?: number; rootNode: TreeNode | null; rootChildren: TreeNode[] | null; reload?: boolean } } + // 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' } @@ -212,8 +215,8 @@ 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. - // A reload is not part of any batch: it leaves the counters, the cancel - // flag and the warning text exactly as they are. + // 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 @@ -230,7 +233,7 @@ function appReducer(state: AppState, action: AppAction): AppState { // (after BATCH_OPEN_COMPLETE, due to Wails' goroutine race) update the // displayed count. When not cancelled, OPEN_DOCUMENT clears the warning // as before. - const documentWarning = isReload ? state.documentWarning : cancelDidNothing + const documentWarning = cancelDidNothing ? null : state.batchOpenCancelled ? (inBatch @@ -265,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 @@ -273,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, @@ -374,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 aaef1587..40e79e9a 100644 --- a/frontend/src/hooks/useFindBar.ts +++ b/frontend/src/hooks/useFindBar.ts @@ -47,7 +47,8 @@ export interface UseFindBarReturn { } /** 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/lib/reloadDocument.ts b/frontend/src/lib/reloadDocument.ts index 5ce164e0..0501fdb7 100644 --- a/frontend/src/lib/reloadDocument.ts +++ b/frontend/src/lib/reloadDocument.ts @@ -10,6 +10,7 @@ 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(); @@ -30,9 +31,10 @@ export function reloadedMessage(fileName: string): string { /** * 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 busy file - * shows the pill; any other failure sets the document error and keeps the old - * tab untouched. + * 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, @@ -54,7 +56,7 @@ export async function reloadTab( 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)}` } }); + dispatch({ type: 'SET_DOCUMENT_ERROR', payload: { message: `Could not reload ${tab.fileName}: ${reloadFailureReason(msg)}`, reload: true } }); return; } if (!getTabs().some((t) => t.tabId === tabId)) { @@ -69,10 +71,11 @@ export async function reloadTab( rootNode: result.rootNode, rootChildren: result.rootChildren, reload: true, + warning: result.warning ?? null, }); - if (result.warning) { - dispatch({ type: 'SET_DOCUMENT_WARNING', payload: { message: result.warning } }); - } + // 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/internal/pdfservice/service.go b/internal/pdfservice/service.go index 1dc2667a..f3aa484c 100644 --- a/internal/pdfservice/service.go +++ b/internal/pdfservice/service.go @@ -8,6 +8,7 @@ import ( "runtime" "runtime/debug" "strings" + "sync" "github.com/google/uuid" "github.com/wailsapp/wails/v3/pkg/application" @@ -79,6 +80,42 @@ type PDFService struct { // 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. @@ -86,6 +123,7 @@ func NewPDFService(app *application.App) PDFService { return PDFService{ inspector: pdfcore.NewInspector(), app: app, + watched: &watchedTabs{ids: map[string]bool{}}, } } @@ -558,12 +596,31 @@ func (s *PDFService) GetDiskStatus(tabID string) (*pdfcore.DiskStatus, error) { return result, err } -// WatchDiskStatus builds a disk watcher over every document open in s 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. +// 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 { - w := filewatch.New(s.inspector, emit, clk) + 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 } diff --git a/internal/pdfservice/service_diskstatus_test.go b/internal/pdfservice/service_diskstatus_test.go index 2ca65768..a01b8129 100644 --- a/internal/pdfservice/service_diskstatus_test.go +++ b/internal/pdfservice/service_diskstatus_test.go @@ -104,6 +104,7 @@ func TestGetDiskStatusResultIsRecordedInTheWatcher(t *testing.T) { 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) @@ -142,3 +143,63 @@ func TestGetDiskStatusResultIsRecordedInTheWatcher(t *testing.T) { } } } + +// 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/tests/file-change-detection/file_change_detection_test.go b/tests/file-change-detection/file_change_detection_test.go index 35c98be7..a4ba7379 100644 --- a/tests/file-change-detection/file_change_detection_test.go +++ b/tests/file-change-detection/file_change_detection_test.go @@ -99,7 +99,7 @@ func TestSignaturesRefuseChangedFile(t *testing.T) { "TestFileChangeSignaturesRefuseChangedFile", "TestFileChangeSignaturesRefuseDeletedFile", "TestFileChangeSignaturesRefuseChangeDuringWalk", - "TestFileChangeSignaturesRefuseChangedUnsignedFile", + "TestFileChangeSignaturesIgnoreChangeOnUnsignedFile", "TestFileChangeSignaturesUnchangedFileReportsAsBefore", ) } @@ -130,6 +130,7 @@ func TestDiskStatusAgainstOpenFingerprint(t *testing.T) { "TestGetDiskStatusReportsCurrentThenChanged", "TestGetDiskStatusUnknownTab", "TestGetDiskStatusResultIsRecordedInTheWatcher", + "TestWatcherPollsOnlyWatchedTabs", ) } @@ -141,6 +142,7 @@ func TestBusyFileIsNotAParseFailure(t *testing.T) { "TestFileBusyRecognisesWindowsErrnos", "TestFileBusyOpenOfExclusivelyLockedFile", "TestFileBusyPlainTextReadOfExclusivelyLockedFile", + "TestFileBusySignaturesHoleReadOfLockedFile", ) return } diff --git a/tests/wails-alpha2-103-upgrade/bindings_contract_test.go b/tests/wails-alpha2-103-upgrade/bindings_contract_test.go index 9b970f19..df8cfffc 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, GetDiskStatus @@ -60,6 +60,7 @@ var consumerBoundMethods = []string{ "GetImagePages", "GetImagePageGroups", "GetDiskStatus", + "SetWatchedTabs", } // bindingRelPath is the regenerated Wails JS binding for PDFService. diff --git a/tests/wails-alpha2-117-upgrade/bindings_contract_test.go b/tests/wails-alpha2-117-upgrade/bindings_contract_test.go index 50c05b92..320ec6f2 100644 --- a/tests/wails-alpha2-117-upgrade/bindings_contract_test.go +++ b/tests/wails-alpha2-117-upgrade/bindings_contract_test.go @@ -53,6 +53,7 @@ var consumerBoundMethods = []string{ "GetImagePages", "GetImagePageGroups", "GetDiskStatus", + "SetWatchedTabs", } // bindingRelPath is the regenerated Wails JS binding for PDFService. From 4c77613e3c66a5738060479b57870c4af1ebb96d Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Thu, 15 Oct 2026 13:04:42 +0700 Subject: [PATCH 07/10] fix: Open parses again when the file changes mid-parse; a recorded disk status restarts the watcher's settle streak --- internal/filewatch/watcher.go | 4 + internal/filewatch/watcher_test.go | 17 ++++ internal/pdfcore/filechange_test.go | 75 ++++++++++++++++++ internal/pdfcore/inspector.go | 78 ++++++++++++------- internal/pdfcore/plaintext.go | 10 +-- .../file_change_detection_test.go | 3 + 6 files changed, 154 insertions(+), 33 deletions(-) diff --git a/internal/filewatch/watcher.go b/internal/filewatch/watcher.go index 0ee0437c..3a71aefc 100644 --- a/internal/filewatch/watcher.go +++ b/internal/filewatch/watcher.go @@ -183,7 +183,11 @@ func (w *Watcher) run() { 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.streak = 0 } if dirty && newFocus != focused { entering := newFocus diff --git a/internal/filewatch/watcher_test.go b/internal/filewatch/watcher_test.go index 67ccfe90..ab161754 100644 --- a/internal/filewatch/watcher_test.go +++ b/internal/filewatch/watcher_test.go @@ -403,6 +403,23 @@ func TestWatcherRecordedStatusLetsReturnToCurrentEmit(t *testing.T) { } } +// A status recorded from GetDiskStatus restarts the settle streak: an older +// observation still counting toward a change cannot complete against it. +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") + if evs := h.em.forTab("a"); len(evs) != 0 { + t.Errorf("events %+v, want none: the change never settled after the recorded current", evs) + } +} + func TestWatcherEventJSONShape(t *testing.T) { h := start(t, "a") h.warm("a") diff --git a/internal/pdfcore/filechange_test.go b/internal/pdfcore/filechange_test.go index f8146c6c..99f5dde4 100644 --- a/internal/pdfcore/filechange_test.go +++ b/internal/pdfcore/filechange_test.go @@ -515,3 +515,78 @@ func TestDiskStatusJSONShape(t *testing.T) { } } } + +// A save that lands while Open parses makes Open parse again, so the document +// and its fingerprint describe the same file and it opens as current. +func TestOpenParsesAgainWhenTheFileChangesDuringTheParse(t *testing.T) { + path := copyFixtureToTemp(t, "minimal.pdf") + calls := 0 + afterOpenParse = func(p string) { + calls++ + if calls == 1 { + overwriteWithFixture(t, p, "multipage.pdf") + } + } + t.Cleanup(func() { afterOpenParse = nil }) + + ins := NewInspector() + info, err := ins.Open("tab-open-race", path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = ins.Close("tab-open-race") }) + if calls != 2 { + t.Errorf("parses = %d, want 2 (one more after the change)", calls) + } + + ref := NewInspector() + want, err := ref.Open("tab-ref", filepath.Join(testdataDir(t), "multipage.pdf")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ref.Close("tab-ref") }) + if info.PageCount != want.PageCount { + t.Errorf("page count %d, want %d from the file saved during the parse", info.PageCount, want.PageCount) + } + st, err := ins.DiskStatus("tab-open-race") + if err != nil || st.State != DiskStateCurrent { + t.Errorf("DiskStatus after Open = %+v, %v; want current", st, err) + } +} + +// A file that keeps changing through every parse opens after openAttempts +// parses with the stat taken before the last one, so it reads as changed. +func TestOpenOfAFileStillBeingWrittenOpensAsChanged(t *testing.T) { + path := copyFixtureToTemp(t, "minimal.pdf") + calls := 0 + afterOpenParse = func(p string) { + calls++ + f, err := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString("\n") + _ = f.Close() + if err != nil { + t.Fatal(err) + } + later := time.Now().Add(time.Duration(calls) * time.Hour) + if err := os.Chtimes(p, later, later); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { afterOpenParse = nil }) + + ins := NewInspector() + if _, err := ins.Open("tab-open-busy-write", path); err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = ins.Close("tab-open-busy-write") }) + if calls != openAttempts { + t.Errorf("parses = %d, want %d", calls, openAttempts) + } + st, err := ins.DiskStatus("tab-open-busy-write") + if err != nil || st.State != DiskStateChanged { + t.Errorf("DiskStatus after Open = %+v, %v; want changed", st, err) + } +} diff --git a/internal/pdfcore/inspector.go b/internal/pdfcore/inspector.go index b9858f9d..425e3320 100644 --- a/internal/pdfcore/inspector.go +++ b/internal/pdfcore/inspector.go @@ -57,7 +57,7 @@ type DocumentState struct { 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 + // pre-parse stat of the parse Open kept. Reads of the file after Open check their handle // against it and refuse bytes from a file that changed. fingerprint FileFingerprint @@ -132,6 +132,14 @@ func NewInspector() *Inspector { } } +// openAttempts bounds how often Open parses a file that changed during the +// parse. +const openAttempts = 3 + +// afterOpenParse, when set, runs after each parse in Open, before the file is +// stat'ed again. Nil in production; tests use it to change the file mid-open. +var afterOpenParse func(path string) + // Open parses a PDF file and registers it under the given tab ID. Returns // document metadata including page count and file size. func (ins *Inspector) Open(tabID, filePath string) (*DocumentInfo, error) { @@ -142,36 +150,54 @@ func (ins *Inspector) Open(tabID, filePath string) (*DocumentInfo, error) { return nil, fmt.Errorf("%w: empty file path", ErrDocumentNotFound) } - fi, err := os.Stat(filePath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("%w: %v", ErrDocumentNotFound, err) + // A save that lands while the file is parsed would pair the new bytes with + // the old stat. The file is stat'ed again after the parse and parsed again + // on a mismatch, up to openAttempts times; the last attempt keeps its + // pre-parse stat, so a file still being written opens as changed. + var ( + fi os.FileInfo + fingerprint FileFingerprint + ctx *pdfcpu_model.Context + err error + ) + for attempt := 1; ; attempt++ { + fi, err = os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %v", ErrDocumentNotFound, err) + } + return nil, err } - return nil, err - } - if fi.IsDir() { - return nil, fmt.Errorf("%w: path is a directory", ErrDocumentNotFound) - } - fileSize := fi.Size() - fingerprint := newFileFingerprint(fi) + if fi.IsDir() { + return nil, fmt.Errorf("%w: path is a directory", ErrDocumentNotFound) + } + fingerprint = newFileFingerprint(fi) - var ctx *pdfcpu_model.Context - err = safeCall(func() error { - var e error - ctx, e = pdfcpu_api.ReadContextFile(filePath) - 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) + err = safeCall(func() error { + var e error + ctx, e = pdfcpu_api.ReadContextFile(filePath) + 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) } - if isFileBusy(err) { - return nil, fmt.Errorf("%w: %w", ErrFileBusy, err) + if afterOpenParse != nil { + afterOpenParse(filePath) + } + after, statErr := os.Stat(filePath) + if statErr != nil || fingerprint.Matches(after) || attempt == openAttempts { + break } - return nil, wrapPDFError(err) } + fileSize := fi.Size() var warning string pageCount := 0 diff --git a/internal/pdfcore/plaintext.go b/internal/pdfcore/plaintext.go index 8adb0e2a..729d6669 100644 --- a/internal/pdfcore/plaintext.go +++ b/internal/pdfcore/plaintext.go @@ -105,15 +105,11 @@ 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). -// // 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) are never returned; a mismatch or a missing file returns -// ErrFileChanged. +// 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 diff --git a/tests/file-change-detection/file_change_detection_test.go b/tests/file-change-detection/file_change_detection_test.go index a4ba7379..96db5991 100644 --- a/tests/file-change-detection/file_change_detection_test.go +++ b/tests/file-change-detection/file_change_detection_test.go @@ -123,6 +123,8 @@ func TestDiskStatusAgainstOpenFingerprint(t *testing.T) { "TestDiskStatusCurrentChangedMissing", "TestDiskStatusDetectsRenameOver", "TestFileChangeSizeOnlyChangeIsDetected", + "TestOpenParsesAgainWhenTheFileChangesDuringTheParse", + "TestOpenOfAFileStillBeingWrittenOpensAsChanged", "TestDiskStatusUnknownTab", "TestDiskStatusJSONShape", ) @@ -158,6 +160,7 @@ func TestWatcherSettleRules(t *testing.T) { "TestWatcherSettleReportsMissingAfterTwoChecks", "TestWatcherStatErrorResetsSettleStreak", "TestWatcherRecordedStatusLetsReturnToCurrentEmit", + "TestWatcherRecordedStatusRestartsSettleStreak", "TestWatcherEventJSONShape", "TestWatcherNewTabStartsAtBaselineWithoutEvent", ) From 356c073fab94fb2e6887bec55b019488d8663f10 Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Thu, 15 Oct 2026 13:04:42 +0700 Subject: [PATCH 08/10] fix: Diff-header reload errors and warnings follow the reload path; dedupe Cmd/Ctrl+R echoes; drop stale disk checks; busy hint in Plain Text --- CHANGELOG.md | 2 +- frontend/src/App.jsx | 43 ++++++++++-------- frontend/src/App.reload.test.tsx | 45 +++++++++++++++++++ .../DetailPanel.compareDisk.test.tsx | 35 +++++++++++++++ frontend/src/components/DetailPanel.tsx | 25 ++++------- .../PlainTextView.fileChanged.test.tsx | 10 +++++ frontend/src/components/PlainTextView.tsx | 7 ++- frontend/src/hooks/useFindBar.ts | 1 - frontend/src/lib/diskCheck.ts | 8 ++-- frontend/src/lib/diskStatusStore.test.ts | 20 +++++++-- frontend/src/lib/diskStatusStore.ts | 19 ++++++-- 11 files changed, 167 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd85eadc..c32b23da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to UniDoc PDF Debugger are recorded here. Format follows Kee - 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 settles while the window has focus, 10 to 20 seconds otherwise) 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, Signatures and the CLI's `dump bytes --json` and `dump signatures` 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 used to report a hole mismatch in that case; a document without signed fields never reads the file and is not affected. On Windows a signed file another app has locked shows "File is in use by another app" in the Signatures tab instead of a hole mismatch +- Plain Text, Signatures and the CLI's `dump bytes --json` and `dump signatures` 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 used to report a hole mismatch in that case; a document without signed fields never reads the file and is not affected. On Windows a file another app has locked shows "File is in use by another app" in Plain Text and, for a signed file, in the Signatures tab instead of a hole mismatch ## [0.5.0] - 2026-10-14 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 70e7b4ea..9dbc2ec1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,7 +2,7 @@ * @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, SetWatchedTabs } from '../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js' import { AppProvider, useAppState, useAppDispatch } from './hooks/useDocumentState' @@ -25,6 +25,9 @@ import { DiskChangeAnnouncer, announceDiskChange } from './components/DiskChange import { applyDiskStatusEvent, retainDiskStatus } from './lib/diskStatusStore' import { reloadTab } 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 * document open/error handling to the app reducer. @@ -57,35 +60,47 @@ function AppContent() { // 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. + // 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 = tabs.map((t) => t.tabId) + const ids = tabIdsKey === '' ? [] : tabIdsKey.split('\n') retainDiskStatus(ids) Promise.resolve(SetWatchedTabs(ids)).catch(() => {}) - }, [tabs]) + }, [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; reloadTab ignores the second trigger when - // the menu accelerator fires too. + // 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 = () => { const id = activeTabIdRef.current if (id) void reloadTab(dispatch, () => tabsRef.current, id) } + let lastShortcut = { source: '', at: -Infinity } + /** @param {'key' | 'menu'} source */ + const reloadFromShortcut = (source) => { + const now = performance.now() + if (lastShortcut.source !== source && now - lastShortcut.at < SHORTCUT_ECHO_MS) return + lastShortcut = { source, at: now } + reloadActive() + } const wantsMeta = getPlatformModifier() === 'Cmd' /** @param {KeyboardEvent} e */ function onKeyDown(e) { const mod = wantsMeta ? e.metaKey : e.ctrlKey - const isReloadKey = e.key === 'F5' || (mod && !e.shiftKey && !e.altKey && (e.key === 'r' || e.key === 'R')) - if (!isReloadKey) return + 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() if (e.repeat || isInTextField(e.target)) return - reloadActive() + if (isModR) reloadFromShortcut('key') + else reloadActive() } - const offReload = Events.On('document:reload-active', reloadActive) + const offReload = Events.On('document:reload-active', () => reloadFromShortcut('menu')) window.addEventListener('keydown', onKeyDown) return () => { offReload() @@ -377,14 +392,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) { diff --git a/frontend/src/App.reload.test.tsx b/frontend/src/App.reload.test.tsx index c3e2421d..eef3434a 100644 --- a/frontend/src/App.reload.test.tsx +++ b/frontend/src/App.reload.test.tsx @@ -312,6 +312,38 @@ describe('reload triggers', () => { 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('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', () => { @@ -440,6 +472,19 @@ describe('on-disk status events', () => { 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(); diff --git a/frontend/src/components/DetailPanel.compareDisk.test.tsx b/frontend/src/components/DetailPanel.compareDisk.test.tsx index 255db534..17c416b5 100644 --- a/frontend/src/components/DetailPanel.compareDisk.test.tsx +++ b/frontend/src/components/DetailPanel.compareDisk.test.tsx @@ -436,6 +436,41 @@ describe('reload from the Diff header', () => { 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(); diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index 6e212581..c6506f27 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -9,12 +9,12 @@ import * as Tabs from '@radix-ui/react-tabs'; 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, GetDiskStatus, GetTreeRoot, GetChildren } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; -import { ContentStreamData, ImageData as PdfImageData } from '../../bindings/unidoc-pdf-debugger/internal/pdfcore/models.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, isFileBusyMessage, isFileChangedMessage } from '../lib/fileChange'; import { markTabFromDiskCheck } from '../lib/diskCheck'; -import { setDiskStatusFromCheck, type DiskStatusInfo } from '../lib/diskStatusStore'; +import { diskStatusSeq, setDiskStatusFromCheck, type DiskStatusInfo } from '../lib/diskStatusStore'; import { formatTimestamp } from '../lib/formatTimestamp'; import { dispatchOpenedDocument } from '../lib/openedDocuments'; import { reloadTab, reloadedMessage, reloadFailureReason } from '../lib/reloadDocument'; @@ -63,20 +63,10 @@ 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; -/** Document metadata returned by OpenFile. */ -interface OpenedDocumentInfo { - tabId: string; - fileName: string; - filePath: string; - pageCount: number; - fileSize: number; - error: string; -} - /** A Diff right side parsed by Compare from the active tab's own file. */ interface DiskCopy { tabId: string; - info: OpenedDocumentInfo; + info: DocumentInfo; diskModTime: number; } @@ -732,7 +722,7 @@ function DetailPanelInner() { setDiffError(null); OpenFile(req.filePath) .then((info: unknown) => { - const di = info as OpenedDocumentInfo | null; + 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 }); @@ -762,13 +752,14 @@ function DetailPanelInner() { const tabId = activeTabIdRef.current; const tab = tabsRef.current.find((t) => t.tabId === tabId); if (!tabId || !tab?.filePath) return; + const seqAtRequest = diskStatusSeq(tabId); GetDiskStatus(tabId) .then((res: unknown) => { if (!res) return; const st = res as DiskStatusInfo; // The backend recorded this result as reported, so it is stored even // when the compare itself is dropped below; the watcher will not send it. - if (tabsRef.current.some((t) => t.tabId === tabId)) setDiskStatusFromCheck(tabId, st); + if (tabsRef.current.some((t) => t.tabId === tabId)) setDiskStatusFromCheck(tabId, st, seqAtRequest); if (!mountedRef.current || activeTabIdRef.current !== tabId) return; if (st.state === 'missing') { setDiffRightTabId(null); @@ -823,14 +814,14 @@ function DetailPanelInner() { rootNode, rootChildren: rootChildren as TreeNode[] | null, reload: true, + warning: info.error || null, }); - if (info.error) dispatch({ type: 'SET_DOCUMENT_WARNING', payload: { message: info.error } }); 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))}` } }); + dispatch({ type: 'SET_DOCUMENT_ERROR', payload: { message: `Could not reload ${info.fileName}: ${reloadFailureReason(extractErrorMessage(err))}`, reload: true } }); }) .finally(() => { promotingRef.current = false; diff --git a/frontend/src/components/PlainTextView.fileChanged.test.tsx b/frontend/src/components/PlainTextView.fileChanged.test.tsx index 4eb11459..d1515a62 100644 --- a/frontend/src/components/PlainTextView.fileChanged.test.tsx +++ b/frontend/src/components/PlainTextView.fileChanged.test.tsx @@ -61,6 +61,16 @@ describe('Plain Text on a file changed on disk', () => { 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')); diff --git a/frontend/src/components/PlainTextView.tsx b/frontend/src/components/PlainTextView.tsx index d583f8b3..a913d3ac 100644 --- a/frontend/src/components/PlainTextView.tsx +++ b/frontend/src/components/PlainTextView.tsx @@ -21,7 +21,7 @@ import { GetPlainTextSize, } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; import { extractErrorMessage } from '../lib/extractErrorMessage'; -import { FILE_CHANGED_MESSAGE, isFileChangedMessage } from '../lib/fileChange'; +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'; @@ -199,6 +199,11 @@ export function PlainTextView({ tabId, active }: PlainTextViewProps) { 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/hooks/useFindBar.ts b/frontend/src/hooks/useFindBar.ts index 40e79e9a..a19fcf72 100644 --- a/frontend/src/hooks/useFindBar.ts +++ b/frontend/src/hooks/useFindBar.ts @@ -46,7 +46,6 @@ export interface UseFindBarReturn { prev: () => void; } -/** True when the event target is an unrelated text input (mirror App.jsx). */ /** 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; diff --git a/frontend/src/lib/diskCheck.ts b/frontend/src/lib/diskCheck.ts index 4a32edf7..a2dc2bf3 100644 --- a/frontend/src/lib/diskCheck.ts +++ b/frontend/src/lib/diskCheck.ts @@ -2,17 +2,19 @@ * @file On-demand disk check that marks a tab at once. */ import { GetDiskStatus } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; -import { setDiskStatusFromCheck, type DiskStatusInfo } from './diskStatusStore'; +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). - * Failures are ignored: the watcher reports the change later. + * A watcher event that arrives first wins over the result. Failures are + * ignored: the watcher reports the change later. */ export function markTabFromDiskCheck(tabId: string): void { + const seqAtRequest = diskStatusSeq(tabId); Promise.resolve(GetDiskStatus(tabId)) .then((st: unknown) => { - if (st) setDiskStatusFromCheck(tabId, st as DiskStatusInfo); + if (st) setDiskStatusFromCheck(tabId, st as DiskStatusInfo, seqAtRequest); }) .catch(() => {}); } diff --git a/frontend/src/lib/diskStatusStore.test.ts b/frontend/src/lib/diskStatusStore.test.ts index ccb67357..3bdd94db 100644 --- a/frontend/src/lib/diskStatusStore.test.ts +++ b/frontend/src/lib/diskStatusStore.test.ts @@ -7,7 +7,7 @@ * - 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}) + * 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 @@ -20,6 +20,7 @@ import { describe, test, expect, beforeEach } from 'vitest'; import { applyDiskStatusEvent, setDiskStatusFromCheck, + diskStatusSeq, retainDiskStatus, readDiskStatus, useDiskStatus, @@ -69,19 +70,30 @@ describe('diskStatusStore', () => { }); test('a GetDiskStatus result marks or clears the tab', () => { - setDiskStatusFromCheck('t1', { state: 'changed', loadedModTime: loaded, diskModTime: loaded + 1, diskSize: 10 }); + 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 }); + 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 }); + 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('retain drops entries of tabs that left the tab list', () => { applyDiskStatusEvent(ev('t1', 1, 'changed')); applyDiskStatusEvent(ev('t2', 1, 'changed')); diff --git a/frontend/src/lib/diskStatusStore.ts b/frontend/src/lib/diskStatusStore.ts index e0f862e8..1e298bc4 100644 --- a/frontend/src/lib/diskStatusStore.ts +++ b/frontend/src/lib/diskStatusStore.ts @@ -77,9 +77,22 @@ export function applyDiskStatusEvent(ev: DiskStatusEvent): boolean { return true; } -/** Writes a GetDiskStatus result for tabId; `current` removes the entry. */ -export function setDiskStatusFromCheck(tabId: string, info: DiskStatusInfo): void { - write(tabId, lastSeq.get(tabId) ?? 0, info); +/** 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. Returns whether the result was written. + */ +export function setDiskStatusFromCheck(tabId: string, info: DiskStatusInfo, seqAtRequest: number): boolean { + 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. */ From d4132b742dc5e38e06235fa9908ce66b3fb095fa Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Thu, 15 Oct 2026 17:37:08 +0700 Subject: [PATCH 09/10] fix: watcher settles on state, resyncs after disk checks and keeps merged focus gains; signatures note unread holes; raw dump bytes is guarded; Open parses once --- cmd/cli/cmd_bytes.go | 22 +-- cmd/cli/cmd_signatures.go | 7 +- cmd/cli/file_change_test.go | 44 ++++-- internal/filewatch/watcher.go | 92 +++++++----- internal/filewatch/watcher_test.go | 49 +++++-- internal/pdfcore/filebusy_windows_test.go | 21 ++- internal/pdfcore/filechange.go | 36 ++++- internal/pdfcore/filechange_test.go | 135 +++++++----------- internal/pdfcore/filestat_other.go | 11 ++ internal/pdfcore/filestat_windows.go | 32 +++++ internal/pdfcore/filestat_windows_test.go | 38 +++++ internal/pdfcore/inspector.go | 79 ++++------ internal/pdfcore/signature.go | 132 ++++++++--------- .../file_change_detection_test.go | 28 ++-- .../bindings_contract_test.go | 2 +- 15 files changed, 448 insertions(+), 280 deletions(-) create mode 100644 internal/pdfcore/filestat_other.go create mode 100644 internal/pdfcore/filestat_windows.go create mode 100644 internal/pdfcore/filestat_windows_test.go diff --git a/cmd/cli/cmd_bytes.go b/cmd/cli/cmd_bytes.go index 18915822..f8dc3778 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 { @@ -73,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 e7b99904..a6e49b31 100644 --- a/cmd/cli/cmd_signatures.go +++ b/cmd/cli/cmd_signatures.go @@ -154,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 index a462c3d8..9460b688 100644 --- a/cmd/cli/file_change_test.go +++ b/cmd/cli/file_change_test.go @@ -1,6 +1,8 @@ -// A file rewritten after the CLI parsed it is reported as a view error -// (stderr JSON, exit 2) instead of mixing the old parse with the new bytes. -// afterCLIOpen runs right after openForCLI succeeds and rewrites the file. +// 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/ @@ -71,18 +73,18 @@ func TestCLIFileChangedBytesJSON(t *testing.T) { } } -func TestCLIFileChangedSignatures(t *testing.T) { +func TestCLIFileChangedBytesRaw(t *testing.T) { path := signedCopy(t) called := rewriteAfterOpen(t) var code int - var stderr string - captureStdout(t, func() { + var stdout, stderr string + stdout = captureStdout(t, func() { stderr = captureStderr(t, func() { - code = execSignaturesDump(path, docViewFlags{}) + code = execBytesDump(path, docViewFlags{}) }) }) if !*called { - t.Fatalf("afterCLIOpen was not called by dump signatures") + t.Fatalf("afterCLIOpen was not called by dump bytes") } if code != 2 { t.Errorf("exit %d, want 2", code) @@ -90,6 +92,32 @@ func TestCLIFileChangedSignatures(t *testing.T) { 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) { diff --git a/internal/filewatch/watcher.go b/internal/filewatch/watcher.go index 3a71aefc..bf942641 100644 --- a/internal/filewatch/watcher.go +++ b/internal/filewatch/watcher.go @@ -54,9 +54,17 @@ type Event struct { // tabState is the scheduler-owned settle state of one tab. type tabState struct { lastReported pdfcore.DiskStatus - candidate pdfcore.DiskStatus - streak int - inFlight bool + // 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 { @@ -78,12 +86,13 @@ type Watcher struct { stop chan struct{} done chan struct{} - mu sync.Mutex - focused bool - focusDirty bool - records map[string]pdfcore.DiskStatus - started bool - stopOnce sync.Once + 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. @@ -127,18 +136,23 @@ func (w *Watcher) Stop() { // 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. Entering focused mode checks every tab immediately. +// 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, so a later return -// to the Open fingerprint emits current even when the watcher never reported -// the change itself. +// 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 @@ -173,8 +187,8 @@ func (w *Watcher) run() { w.mu.Lock() records := w.records w.records = map[string]pdfcore.DiskStatus{} - newFocus, dirty := w.focused, w.focusDirty - w.focusDirty = false + newFocus, dirty, gained := w.focused, w.focusDirty, w.focusGained + w.focusDirty, w.focusGained = false, false w.mu.Unlock() for id, st := range records { @@ -187,15 +201,17 @@ func (w *Watcher) run() { // a stat already in flight cannot settle against it. ts.lastReported = st ts.candidate = pdfcore.DiskStatus{} - ts.streak = 0 + ts.stateStreak, ts.sameStreak = 0, 0 + ts.resync = true } if dirty && newFocus != focused { - entering := newFocus focused = newFocus tick = w.clk.After(intervalFor(focused)) - if entering { - w.checkAll(tabs) - } + } + // Focus gained and lost again before this wake still counts as a + // focus event. + if gained { + w.checkAll(tabs) } } @@ -221,7 +237,7 @@ func (w *Watcher) run() { continue } if r.err != nil { - ts.streak = 0 + ts.stateStreak, ts.sameStreak = 0, 0 ts.candidate = pdfcore.DiskStatus{} continue } @@ -270,26 +286,36 @@ func (w *Watcher) stat(tabID string) { } } -// settle records one observation and returns the event to emit when two -// consecutive identical observations differ from the last reported status. -// A tab that stays current never emits. +// 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 obs == ts.candidate && ts.streak > 0 { - ts.streak++ + if ts.stateStreak > 0 && obs.State == ts.candidate.State { + ts.stateStreak++ + if obs == ts.candidate { + ts.sameStreak++ + } else { + ts.sameStreak = 1 + } } else { - ts.candidate = obs - ts.streak = 1 + ts.stateStreak, ts.sameStreak = 1, 1 } - if ts.streak < 2 { + ts.candidate = obs + if ts.stateStreak < 2 { return Event{}, false } last := ts.lastReported - if obs.State == pdfcore.DiskStateCurrent && last.State == pdfcore.DiskStateCurrent { - return Event{}, false - } - if obs == last { + 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, diff --git a/internal/filewatch/watcher_test.go b/internal/filewatch/watcher_test.go index ab161754..70ce13e9 100644 --- a/internal/filewatch/watcher_test.go +++ b/internal/filewatch/watcher_test.go @@ -343,7 +343,10 @@ func TestWatcherSettleReportsChangeAfterTwoIdenticalChecks(t *testing.T) { } } -func TestWatcherSettleIgnoresFileStillBeingWritten(t *testing.T) { +// 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++ { @@ -351,12 +354,15 @@ func TestWatcherSettleIgnoresFileStillBeingWritten(t *testing.T) { h.check("a") } h.check("a") // second identical observation of size 105 - evs := h.waitEvents("a", 1) - if len(evs) != 1 || evs[0].ev.DiskSize != 105 { - t.Fatalf("events %+v, want one changed event for the final size 105", evs) + evs := h.waitEvents("a", 2) + if len(evs) != 2 { + t.Fatalf("events %+v, want a changed event and one refresh", evs) } - if evs[0].callsAtEmit != base+6 { - t.Errorf("emitted after check %d, want 6 (nothing while the observation kept changing)", evs[0].callsAtEmit-base) + 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) } } @@ -404,7 +410,9 @@ func TestWatcherRecordedStatusLetsReturnToCurrentEmit(t *testing.T) { } // A status recorded from GetDiskStatus restarts the settle streak: an older -// observation still counting toward a change cannot complete against it. +// 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") @@ -415,8 +423,14 @@ func TestWatcherRecordedStatusRestartsSettleStreak(t *testing.T) { h.src.set("a", current(), nil) h.check("a") h.check("a") - if evs := h.em.forTab("a"); len(evs) != 0 { - t.Errorf("events %+v, want none: the change never settled after the recorded current", evs) + 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) } } @@ -480,6 +494,23 @@ func TestWatcherFocusTakesCallerValueRegardlessOfOrder(t *testing.T) { 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") diff --git a/internal/pdfcore/filebusy_windows_test.go b/internal/pdfcore/filebusy_windows_test.go index e3c328f7..309533cc 100644 --- a/internal/pdfcore/filebusy_windows_test.go +++ b/internal/pdfcore/filebusy_windows_test.go @@ -10,6 +10,7 @@ package pdfcore import ( "context" "errors" + "io" "io/fs" "strings" "syscall" @@ -78,14 +79,28 @@ func TestFileBusyPlainTextReadOfExclusivelyLockedFile(t *testing.T) { } // A signature hole read of a file another handle holds an exclusive -// byte-range lock on returns ErrFileBusy, not a hole mismatch. +// 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 !errors.Is(err, ErrFileBusy) { - t.Fatalf("GetSignatures of a locked file: err = %v, list = %+v, want ErrFileBusy", err, list) + 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) } } diff --git a/internal/pdfcore/filechange.go b/internal/pdfcore/filechange.go index 7bb6efb0..6ce2b4b5 100644 --- a/internal/pdfcore/filechange.go +++ b/internal/pdfcore/filechange.go @@ -3,6 +3,7 @@ package pdfcore import ( "errors" "fmt" + "io" "io/fs" "os" "time" @@ -108,7 +109,7 @@ func (ins *Inspector) DiskStatus(tabID string) (DiskStatus, error) { return DiskStatus{}, err } st := DiskStatus{LoadedModTime: doc.fingerprint.ModTime.UnixMilli()} - fi, err := os.Stat(doc.FilePath) + fi, err := statWithIdentity(doc.FilePath) if err != nil { if errors.Is(err, fs.ErrNotExist) { st.State = DiskStateMissing @@ -125,3 +126,36 @@ func (ins *Inspector) DiskStatus(tabID string) (DiskStatus, error) { } 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 index 99f5dde4..b6102ee3 100644 --- a/internal/pdfcore/filechange_test.go +++ b/internal/pdfcore/filechange_test.go @@ -7,6 +7,7 @@ package pdfcore import ( + "bytes" "context" "encoding/json" "errors" @@ -190,33 +191,47 @@ func TestFileChangePlainTextReturnsCachedResultAfterLaterChange(t *testing.T) { } } -func TestFileChangeSignaturesRefuseChangedFile(t *testing.T) { +// 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 !errors.Is(err, ErrFileChanged) { - t.Fatalf("err = %v, want ErrFileChanged instead of a hole mismatch report", err) + 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 list != nil { - t.Errorf("a changed file must return no signature list, got %+v", list) + if !sig.Signed || sig.SubFilter == "" || len(sig.ByteRange) != 4 { + t.Errorf("parsed details must stay: %+v", sig) } } -func TestFileChangeSignaturesRefuseDeletedFile(t *testing.T) { +func TestFileChangeSignaturesNoteDeletedFile(t *testing.T) { ins, path := openTempCopy(t, "signed.pdf", "tab-sig-deleted") if err := os.Remove(path); err != nil { t.Fatal(err) } - _, err := ins.GetSignatures("tab-sig-deleted") - if !errors.Is(err, ErrFileChanged) || !errors.Is(err, fs.ErrNotExist) { - t.Errorf("err = %v, want ErrFileChanged wrapping fs.ErrNotExist", 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, after the -// pre-walk check passed; it rewrites the file to simulate a save landing -// during the walk. -func TestFileChangeSignaturesRefuseChangeDuringWalk(t *testing.T) { +// 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) { @@ -240,15 +255,15 @@ func TestFileChangeSignaturesRefuseChangeDuringWalk(t *testing.T) { if !called { t.Fatalf("the span-read hook never ran; signed.pdf must reach the hole read") } - if !errors.Is(err, ErrFileChanged) { - t.Fatalf("change during the walk: err = %v, want ErrFileChanged", err) + if err != nil { + t.Fatalf("change during the walk: err = %v, want the list with a note", err) } - if list != nil { - t.Errorf("a change during the walk must return no list") + if len(list.Signatures) == 0 || list.Signatures[0].HoleCheckError != ErrFileChanged.Error() { + t.Fatalf("signatures %+v, want holeCheckError %q", list.Signatures, ErrFileChanged.Error()) } - // The refusal is per call: once the Open-time mtime is back, the next walk - // reports the signatures again. + // 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) } @@ -256,7 +271,7 @@ func TestFileChangeSignaturesRefuseChangeDuringWalk(t *testing.T) { if err != nil { t.Fatalf("GetSignatures after restoring the mtime: %v", err) } - if len(list.Signatures) == 0 || !list.Signatures[0].HoleMatchesContents { + 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) } } @@ -516,77 +531,25 @@ func TestDiskStatusJSONShape(t *testing.T) { } } -// A save that lands while Open parses makes Open parse again, so the document -// and its fingerprint describe the same file and it opens as current. -func TestOpenParsesAgainWhenTheFileChangesDuringTheParse(t *testing.T) { - path := copyFixtureToTemp(t, "minimal.pdf") - calls := 0 - afterOpenParse = func(p string) { - calls++ - if calls == 1 { - overwriteWithFixture(t, p, "multipage.pdf") - } - } - t.Cleanup(func() { afterOpenParse = nil }) - - ins := NewInspector() - info, err := ins.Open("tab-open-race", path) - if err != nil { - t.Fatalf("Open: %v", err) - } - t.Cleanup(func() { _ = ins.Close("tab-open-race") }) - if calls != 2 { - t.Errorf("parses = %d, want 2 (one more after the change)", calls) - } - - ref := NewInspector() - want, err := ref.Open("tab-ref", filepath.Join(testdataDir(t), "multipage.pdf")) +// 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) } - t.Cleanup(func() { _ = ref.Close("tab-ref") }) - if info.PageCount != want.PageCount { - t.Errorf("page count %d, want %d from the file saved during the parse", info.PageCount, want.PageCount) - } - st, err := ins.DiskStatus("tab-open-race") - if err != nil || st.State != DiskStateCurrent { - t.Errorf("DiskStatus after Open = %+v, %v; want current", st, 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)) } -} -// A file that keeps changing through every parse opens after openAttempts -// parses with the stat taken before the last one, so it reads as changed. -func TestOpenOfAFileStillBeingWrittenOpensAsChanged(t *testing.T) { - path := copyFixtureToTemp(t, "minimal.pdf") - calls := 0 - afterOpenParse = func(p string) { - calls++ - f, err := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0) - if err != nil { - t.Fatal(err) - } - _, err = f.WriteString("\n") - _ = f.Close() - if err != nil { - t.Fatal(err) - } - later := time.Now().Add(time.Duration(calls) * time.Hour) - if err := os.Chtimes(p, later, later); err != nil { - t.Fatal(err) - } - } - t.Cleanup(func() { afterOpenParse = nil }) - - ins := NewInspector() - if _, err := ins.Open("tab-open-busy-write", path); err != nil { - t.Fatalf("Open: %v", err) - } - t.Cleanup(func() { _ = ins.Close("tab-open-busy-write") }) - if calls != openAttempts { - t.Errorf("parses = %d, want %d", calls, openAttempts) + 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) } - st, err := ins.DiskStatus("tab-open-busy-write") - if err != nil || st.State != DiskStateChanged { - t.Errorf("DiskStatus after Open = %+v, %v; want changed", st, 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 00000000..7eb4271c --- /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 00000000..dba64459 --- /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 00000000..964ad2c7 --- /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 425e3320..16f47564 100644 --- a/internal/pdfcore/inspector.go +++ b/internal/pdfcore/inspector.go @@ -57,7 +57,7 @@ type DocumentState struct { FileSize int64 // fingerprint is the size, mtime and file identity of FilePath from the - // pre-parse stat of the parse Open kept. Reads of the file after Open check their handle + // 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 @@ -132,14 +132,6 @@ func NewInspector() *Inspector { } } -// openAttempts bounds how often Open parses a file that changed during the -// parse. -const openAttempts = 3 - -// afterOpenParse, when set, runs after each parse in Open, before the file is -// stat'ed again. Nil in production; tests use it to change the file mid-open. -var afterOpenParse func(path string) - // Open parses a PDF file and registers it under the given tab ID. Returns // document metadata including page count and file size. func (ins *Inspector) Open(tabID, filePath string) (*DocumentInfo, error) { @@ -150,52 +142,37 @@ func (ins *Inspector) Open(tabID, filePath string) (*DocumentInfo, error) { return nil, fmt.Errorf("%w: empty file path", ErrDocumentNotFound) } - // A save that lands while the file is parsed would pair the new bytes with - // the old stat. The file is stat'ed again after the parse and parsed again - // on a mismatch, up to openAttempts times; the last attempt keeps its - // pre-parse stat, so a file still being written opens as changed. - var ( - fi os.FileInfo - fingerprint FileFingerprint - ctx *pdfcpu_model.Context - err error - ) - for attempt := 1; ; attempt++ { - fi, err = os.Stat(filePath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("%w: %v", ErrDocumentNotFound, err) - } - return nil, err - } - if fi.IsDir() { - return nil, fmt.Errorf("%w: path is a directory", 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) { + return nil, fmt.Errorf("%w: %v", ErrDocumentNotFound, err) } - fingerprint = newFileFingerprint(fi) + return nil, err + } + if fi.IsDir() { + return nil, fmt.Errorf("%w: path is a directory", ErrDocumentNotFound) + } + fingerprint := newFileFingerprint(fi) - err = safeCall(func() error { - var e error - ctx, e = pdfcpu_api.ReadContextFile(filePath) - 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) - } - if afterOpenParse != nil { - afterOpenParse(filePath) + var ctx *pdfcpu_model.Context + err = safeCall(func() error { + var e error + ctx, e = pdfcpu_api.ReadContextFile(filePath) + 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) } - after, statErr := os.Stat(filePath) - if statErr != nil || fingerprint.Matches(after) || attempt == openAttempts { - break + if isFileBusy(err) { + return nil, fmt.Errorf("%w: %w", ErrFileBusy, err) } + return nil, wrapPDFError(err) } fileSize := fi.Size() diff --git a/internal/pdfcore/signature.go b/internal/pdfcore/signature.go index 64593981..80ccf23e 100644 --- a/internal/pdfcore/signature.go +++ b/internal/pdfcore/signature.go @@ -93,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 @@ -115,11 +119,12 @@ const maxHoleProbeBytes = 4 * 1024 * 1024 // /Fields array (walking /Kids hierarchies with inherited /FT), decomposes // 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, a -// document-level pdfcpu failure, or a hole read that finds the file changed -// (ErrFileChanged) or locked by another process (ErrFileBusy); the walk stops -// at that read. A document without signed fields never reads the file. Runs -// under doc.pdfMu and inside safeCall. +// per-entry error fields; the call itself fails only for unknown tabs or a +// 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 { @@ -131,49 +136,48 @@ func (ins *Inspector) GetSignatures(tabID string) (*SignatureList, error) { defer doc.pdfMu.Unlock() list := &SignatureList{Signatures: []SignatureField{}} - var readErr error err = safeCall(func() error { - list.Signatures, readErr = collectSignatures(doc) + list.Signatures = collectSignatures(doc) return nil }) if err != nil { return nil, wrapPDFError(err) } - if readErr != nil { - return nil, readErr - } return list, nil } -// collectSignatures walks /AcroForm /Fields in document order. It stops at the -// first hole read that returns ErrFileChanged or ErrFileBusy and returns that -// error. Caller MUST hold doc.pdfMu and run inside safeCall. -func collectSignatures(doc *DocumentState) ([]SignatureField, error) { +// 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 { sigs := []SignatureField{} cat, err := doc.PDFContext.Catalog() if err != nil { - return sigs, nil + return sigs } acro := asDict(dereference(doc, cat["AcroForm"])) if acro == nil { - return sigs, nil + return sigs } + hr := &holeRead{} for _, elem := range dereferenceArray(doc, acro["Fields"]) { - if err := walkSignatureField(doc, elem, "", "", 0, &sigs); err != nil { - return nil, err - } + walkSignatureField(doc, hr, elem, "", "", 0, &sigs) } - return sigs, nil + return sigs } // walkSignatureField recurses one field-tree node. /FT is inheritable via the // 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. A file read -// error from buildSignatureField stops the walk. -func walkSignatureField(doc *DocumentState, obj pdfcpu_types.Object, parentName, inheritedFT string, depth int, out *[]SignatureField) error { +// Terminal fields whose effective /FT is Sig are appended to out. +func walkSignatureField(doc *DocumentState, hr *holeRead, obj pdfcpu_types.Object, parentName, inheritedFT string, depth int, out *[]SignatureField) { if depth > maxNodeIDDepth { - return nil + return } fieldNodeID := "" if ref, ok := obj.(pdfcpu_types.IndirectRef); ok { @@ -181,7 +185,7 @@ func walkSignatureField(doc *DocumentState, obj pdfcpu_types.Object, parentName, } d := asDict(dereference(doc, obj)) if d == nil { - return nil + return } name := parentName @@ -204,28 +208,20 @@ func walkSignatureField(doc *DocumentState, obj pdfcpu_types.Object, parentName, continue } if _, hasT := kd["T"]; hasT { - if err := walkSignatureField(doc, kid, name, ft, depth+1, out); err != nil { - return err - } + walkSignatureField(doc, hr, kid, name, ft, depth+1, out) recursed = true } } if recursed || ft != "Sig" { - return nil - } - f, err := buildSignatureField(doc, d, name, fieldNodeID) - if err != nil { - return err + return } - *out = append(*out, f) - return nil + *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. The error is ErrFileChanged or -// ErrFileBusy from the hole read; every other problem is a per-entry field. -func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fieldNodeID string) (SignatureField, error) { +// and per-SubFilter decomposition routing. +func buildSignatureField(doc *DocumentState, hr *holeRead, field pdfcpu_types.Dict, name, fieldNodeID string) SignatureField { f := SignatureField{ FieldName: name, FieldNodeID: fieldNodeID, @@ -237,13 +233,13 @@ func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fiel if !hasV { // An unsigned placeholder is listed with no decomposition and no // error. - return f, nil + return f } var sig pdfcpu_types.Dict if ref, isRef := vObj.(pdfcpu_types.IndirectRef); isRef { sig = asDict(dereference(doc, vObj)) if sig == nil { - return f, nil + return f } f.SignatureRef = refString(ref) f.SignatureNodeID = nodeIDFromRef(ref) @@ -252,7 +248,7 @@ func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fiel // the ref fields empty. sig = asDict(vObj) if sig == nil { - return f, nil + return f } } @@ -274,9 +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"])) - if err := computeByteRangeCoverage(doc, sig, &f); err != nil { - return f, err - } + computeByteRangeCoverage(doc, hr, sig, &f) switch { case isCMSSubFilter(f.SubFilter): @@ -292,7 +286,7 @@ func buildSignatureField(doc *DocumentState, field pdfcpu_types.Dict, name, fiel default: f.Notes = append(f.Notes, "subFilter not decomposed") } - return f, nil + return f } // isCMSSubFilter reports whether sub names a CMS-based signature whose @@ -570,42 +564,41 @@ func stripPDFWhitespace(s string) string { // computeByteRangeCoverage measures /ByteRange against the file: whole-file // 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. The error is -// ErrFileChanged or ErrFileBusy from the hole read. -func computeByteRangeCoverage(doc *DocumentState, sig pdfcpu_types.Dict, f *SignatureField) error { +// is explicitly a measurement, never a validity verdict. +func computeByteRangeCoverage(doc *DocumentState, hr *holeRead, sig pdfcpu_types.Dict, f *SignatureField) { brObj, ok := sig["ByteRange"] if !ok { f.CoverageError = "/ByteRange missing" - return nil + return } arr := dereferenceArray(doc, brObj) if arr == nil { f.CoverageError = "/ByteRange is not an array" - return nil + return } ints := make([]int64, 0, len(arr)) for _, e := range arr { n, isInt := dereference(doc, e).(pdfcpu_types.Integer) if !isInt { f.CoverageError = "/ByteRange contains a non-integer entry" - return nil + return } ints = append(ints, int64(n)) } f.ByteRange = ints if len(ints)%2 == 1 { f.CoverageError = fmt.Sprintf("/ByteRange has an odd number of entries (%d)", len(ints)) - return nil + return } if len(ints) != 4 { f.CoverageError = fmt.Sprintf("/ByteRange has %d entries; expected 4 (offset length offset length)", len(ints)) - return nil + return } size := doc.FileSize for _, n := range ints { if n < 0 { f.CoverageError = "/ByteRange contains a negative value" - return nil + return } // Bound every entry by the file size BEFORE summing. A crafted value // (e.g. 2^62) would otherwise overflow the start+len sums below to a @@ -614,31 +607,34 @@ func computeByteRangeCoverage(doc *DocumentState, sig pdfcpu_types.Dict, f *Sign // failure. Bounding each entry keeps every sum in [0, 2*size]. if n > size { f.CoverageError = "/ByteRange value exceeds the file size" - return nil + return } } start1, len1, start2, len2 := ints[0], ints[1], ints[2], ints[3] if start1+len1 > size || start2+len2 > size { f.CoverageError = "/ByteRange extends past the end of the file" - return nil + return } holeStart := start1 + len1 if start2 < holeStart { f.CoverageError = "/ByteRange ranges overlap" - return nil + return } f.TrailingGap = size - (start2 + len2) f.CoversWholeFile = start1 == 0 && f.TrailingGap == 0 - match, err := holeMatchesContents(doc, sig, holeStart, start2) + match, err := holeMatchesContents(doc, hr, sig, holeStart, start2) f.HoleMatchesContents = match - return err + 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 + '>'. A -// file that changed (ErrFileChanged) or is locked by another process -// (ErrFileBusy) is returned as an error; any other read failure is a mismatch. -func holeMatchesContents(doc *DocumentState, sig pdfcpu_types.Dict, holeStart, holeEnd int64) (bool, error) { +// 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, nil @@ -646,9 +642,17 @@ func holeMatchesContents(doc *DocumentState, sig pdfcpu_types.Dict, holeStart, h if holeEnd <= holeStart || holeEnd-holeStart > maxHoleProbeBytes { return false, nil } + if hr.refused != nil { + return false, hr.refused + } span, err := readFileSpan(doc.FilePath, doc.fingerprint, holeStart, holeEnd) - if errors.Is(err, ErrFileChanged) || errors.Is(err, ErrFileBusy) { - return false, err + 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 } if err != nil || len(span) < 2 { return false, nil diff --git a/tests/file-change-detection/file_change_detection_test.go b/tests/file-change-detection/file_change_detection_test.go index 96db5991..b7d31f59 100644 --- a/tests/file-change-detection/file_change_detection_test.go +++ b/tests/file-change-detection/file_change_detection_test.go @@ -91,24 +91,26 @@ func TestPlainTextReadRefusesChangedFile(t *testing.T) { ) } -// Signature decomposition refuses a changed file, before the walk and during -// it, instead of reporting a hole mismatch; an unchanged file reports as -// before. -func TestSignaturesRefuseChangedFile(t *testing.T) { +// 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, - "TestFileChangeSignaturesRefuseChangedFile", - "TestFileChangeSignaturesRefuseDeletedFile", - "TestFileChangeSignaturesRefuseChangeDuringWalk", + "TestFileChangeSignaturesNoteChangedFile", + "TestFileChangeSignaturesNoteDeletedFile", + "TestFileChangeSignaturesNoteChangeDuringWalk", "TestFileChangeSignaturesIgnoreChangeOnUnsignedFile", "TestFileChangeSignaturesUnchangedFileReportsAsBefore", ) } -// dump bytes --json and dump signatures on a file rewritten after the CLI -// parsed it exit 2 with the file-changed message on stderr. +// 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", ) @@ -123,8 +125,7 @@ func TestDiskStatusAgainstOpenFingerprint(t *testing.T) { "TestDiskStatusCurrentChangedMissing", "TestDiskStatusDetectsRenameOver", "TestFileChangeSizeOnlyChangeIsDetected", - "TestOpenParsesAgainWhenTheFileChangesDuringTheParse", - "TestOpenOfAFileStillBeingWrittenOpensAsChanged", + "TestCopyDocumentFileGuardsTheRawBytes", "TestDiskStatusUnknownTab", "TestDiskStatusJSONShape", ) @@ -142,9 +143,11 @@ func TestBusyFileIsNotAParseFailure(t *testing.T) { if runtime.GOOS == "windows" { runColocated(t, "./internal/pdfcore/", false, "TestFileBusyRecognisesWindowsErrnos", + "TestStatWithIdentityCapturesTheFileIDAtStatTime", "TestFileBusyOpenOfExclusivelyLockedFile", "TestFileBusyPlainTextReadOfExclusivelyLockedFile", "TestFileBusySignaturesHoleReadOfLockedFile", + "TestFileBusyCopyOfLockedFile", ) return } @@ -156,7 +159,7 @@ func TestBusyFileIsNotAParseFailure(t *testing.T) { func TestWatcherSettleRules(t *testing.T) { runColocated(t, "./internal/filewatch/", true, "TestWatcherSettleReportsChangeAfterTwoIdenticalChecks", - "TestWatcherSettleIgnoresFileStillBeingWritten", + "TestWatcherSettleReportsFileStillBeingWritten", "TestWatcherSettleReportsMissingAfterTwoChecks", "TestWatcherStatErrorResetsSettleStreak", "TestWatcherRecordedStatusLetsReturnToCurrentEmit", @@ -172,6 +175,7 @@ func TestWatcherCadenceAndFocus(t *testing.T) { runColocated(t, "./internal/filewatch/", true, "TestWatcherCadenceFollowsFocus", "TestWatcherFocusTakesCallerValueRegardlessOfOrder", + "TestWatcherMergedFocusGainStillChecks", "TestWatcherChecksBackgroundTabsAndNeverEmitsForUnchangedOnes", ) } diff --git a/tests/wails-alpha2-103-upgrade/bindings_contract_test.go b/tests/wails-alpha2-103-upgrade/bindings_contract_test.go index df8cfffc..5b91749a 100644 --- a/tests/wails-alpha2-103-upgrade/bindings_contract_test.go +++ b/tests/wails-alpha2-103-upgrade/bindings_contract_test.go @@ -23,7 +23,7 @@ import ( // App.jsx : ConsumePendingOpenFiles, SetWatchedTabs // ObjectInfoPanel.tsx : GetObjectSource // DetailPanel.tsx : GetObjectDetail, GetContentStream, GetImageData, -// GetReverseRefs, GetFontView, GetDiskStatus +// GetReverseRefs, GetFontView // XRefTableView.tsx : GetXRefTable // CommandPalette.tsx : GetAncestorPath // TreePanel.tsx : GetChildren, GetAncestorPath From 7ac6281634283e5f693f8c8fc50c2356432c17b3 Mon Sep 17 00:00:00 2001 From: Ade Anom A Date: Thu, 15 Oct 2026 17:37:09 +0700 Subject: [PATCH 10/10] fix: swallowed Cmd/Ctrl+R presses drop their menu echo; per-signature hole notes; one disk-check and reload path --- CHANGELOG.md | 4 +- frontend/src/App.jsx | 39 ++++++++----- frontend/src/App.reload.test.tsx | 52 +++++++++++++++++ .../components/DetailPanel.staleNote.test.tsx | 33 ++++++++--- frontend/src/components/DetailPanel.tsx | 57 ++++++------------- frontend/src/components/SignaturesView.tsx | 34 +++++------ frontend/src/hooks/usePDFService.ts | 38 +++++++++---- frontend/src/lib/diskCheck.ts | 21 ++++--- frontend/src/lib/diskStatusStore.test.ts | 10 ++++ frontend/src/lib/diskStatusStore.ts | 21 ++++++- frontend/src/lib/reloadDocument.ts | 25 ++++++-- 11 files changed, 222 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c32b23da..e0a99617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,9 @@ All notable changes to UniDoc PDF Debugger are recorded here. Format follows Kee ### 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 settles while the window has focus, 10 to 20 seconds otherwise) 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 +- 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, Signatures and the CLI's `dump bytes --json` and `dump signatures` 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 used to report a hole mismatch in that case; a document without signed fields never reads the file and is not affected. On Windows a file another app has locked shows "File is in use by another app" in Plain Text and, for a signed file, in the Signatures tab instead of a hole mismatch +- 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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9dbc2ec1..9fb34264 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -22,8 +22,8 @@ import { UpdateNotifier } from './components/UpdateNotifier' import { openPalette, useCommandPalette } from './hooks/useCommandPalette' import { ReloadToast } from './components/ReloadToast' import { DiskChangeAnnouncer, announceDiskChange } from './components/DiskChangeAnnouncer' -import { applyDiskStatusEvent, retainDiskStatus } from './lib/diskStatusStore' -import { reloadTab } from './lib/reloadDocument' +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 @@ -76,17 +76,18 @@ function AppContent() { // 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 = () => { - const id = activeTabIdRef.current - if (id) void reloadTab(dispatch, () => tabsRef.current, id) - } + const reloadActive = () => reloadActiveTab(dispatch, () => tabsRef.current, activeTabIdRef.current) let lastShortcut = { source: '', at: -Infinity } - /** @param {'key' | 'menu'} source */ - const reloadFromShortcut = (source) => { + /** + * 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 + if (lastShortcut.source !== source && now - lastShortcut.at < SHORTCUT_ECHO_MS) return false lastShortcut = { source, at: now } - reloadActive() + return true } const wantsMeta = getPlatformModifier() === 'Cmd' /** @param {KeyboardEvent} e */ @@ -96,11 +97,17 @@ function AppContent() { if (e.key !== 'F5' && !isModR) return // Cmd/Ctrl+R never reaches the webview reload, even in a text field. e.preventDefault() - if (e.repeat || isInTextField(e.target)) return - if (isModR) reloadFromShortcut('key') - else reloadActive() + 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', () => reloadFromShortcut('menu')) + const offReload = Events.On('document:reload-active', () => { + if (notePress('menu')) reloadActive() + }) window.addEventListener('keydown', onKeyDown) return () => { offReload() @@ -118,6 +125,7 @@ function AppContent() { 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, @@ -126,7 +134,8 @@ function AppContent() { diskModTime: Number(data.diskModTime) || 0, diskSize: Number(data.diskSize) || 0, }) - if (applied && data.state === 'changed') announceDiskChange(`${tab.fileName} changed on disk`) + // 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`) }) }, []) diff --git a/frontend/src/App.reload.test.tsx b/frontend/src/App.reload.test.tsx index eef3434a..ca8a7ca3 100644 --- a/frontend/src/App.reload.test.tsx +++ b/frontend/src/App.reload.test.tsx @@ -330,6 +330,46 @@ describe('reload triggers', () => { 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(); @@ -498,6 +538,18 @@ describe('on-disk status events', () => { 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(); diff --git a/frontend/src/components/DetailPanel.staleNote.test.tsx b/frontend/src/components/DetailPanel.staleNote.test.tsx index 14490248..ba8cfce5 100644 --- a/frontend/src/components/DetailPanel.staleNote.test.tsx +++ b/frontend/src/components/DetailPanel.staleNote.test.tsx @@ -265,10 +265,28 @@ describe('stale note', () => { }); }); -describe('signatures refused because the file changed', () => { - test('keeps the Signatures tab, shows the reload hint and marks the tab', async () => { +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.mockRejectedValue(envelope('file changed on disk since it was opened')); + 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); @@ -279,18 +297,19 @@ describe('signatures refused because the file changed', () => { expect(screen.getByTestId('stale-note')).toBeInTheDocument(); fireEvent.click(sigTab); - expect(await screen.findByText('File changed on disk. Reload to view.')).toBeInTheDocument(); + 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 keeps the Signatures tab with the busy hint', async () => { + test('a file locked by another app notes the hole as busy and checks nothing', async () => { const p = paths(); - b.GetSignatures.mockRejectedValue(envelope('file is busy: another process has it open')); + 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('File is in use by another app. Reload to try again.')).toBeInTheDocument(); + expect(await screen.findByText('not checked: File is in use by another app. Reload to try again.')).toBeInTheDocument(); expect(b.GetDiskStatus).not.toHaveBeenCalled(); }); diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index c6506f27..ee692020 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -8,16 +8,15 @@ import { Fragment, useState, useEffect, useCallback, useMemo, useRef, memo } fro import * as Tabs from '@radix-ui/react-tabs'; 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, GetDiskStatus, GetTreeRoot, GetChildren } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; +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, isFileBusyMessage, isFileChangedMessage } from '../lib/fileChange'; -import { markTabFromDiskCheck } from '../lib/diskCheck'; -import { diskStatusSeq, setDiskStatusFromCheck, type DiskStatusInfo } from '../lib/diskStatusStore'; +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 { reloadTab, reloadedMessage, reloadFailureReason } from '../lib/reloadDocument'; +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'; @@ -188,12 +187,6 @@ function DetailPanelInner() { // refetch per tab switch); the tab is simply absent until the fetch // resolves with >= 1 signature field. null = not yet resolved. const [signatures, setSignatures] = useState(null); - // True when GetSignatures was refused because the file changed on disk, or - // because another app has it locked: the Signatures tab stays visible and - // shows the reload hint. Only a document with signed fields reads the file, - // so neither is ever set for an unsigned document. - const [signaturesFileChanged, setSignaturesFileChanged] = useState(false); - const [signaturesFileBusy, setSignaturesFileBusy] = useState(false); // The second (comparison) document's tab ID for the Diff tab, and // any picker error. Reset on document switch so the diff never carries over a // stale comparison from a previously-active tab. @@ -245,8 +238,6 @@ function DetailPanelInner() { setXrefEntryCount(null); setEmbeddedCount(null); setSignatures(null); - setSignaturesFileChanged(false); - setSignaturesFileBusy(false); setDiffRightTabId(null); setDiffError(null); setCompareConfirm(null); @@ -276,30 +267,22 @@ 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; setSignatures([]); - // A read refused because the file changed keeps the tab visible with - // the reload hint and marks the tab from a fresh disk check. - const message = extractErrorMessage(err); - if (isFileChangedMessage(message)) { - setSignaturesFileChanged(true); - markTabFromDiskCheck(activeTabId); - return; - } - if (isFileBusyMessage(message)) { - setSignaturesFileBusy(true); - return; - } // eslint-disable-next-line no-console console.warn('GetSignatures failed:', err); }); @@ -707,8 +690,7 @@ function DetailPanelInner() { * reload flow with the menu item and F5. */ const handleReload = useCallback(() => { - const id = activeTabIdRef.current; - if (id) void reloadTab(dispatch, () => tabsRef.current, id); + reloadActiveTab(dispatch, () => tabsRef.current, activeTabIdRef.current); }, [dispatch]); /** @@ -752,14 +734,11 @@ function DetailPanelInner() { const tabId = activeTabIdRef.current; const tab = tabsRef.current.find((t) => t.tabId === tabId); if (!tabId || !tab?.filePath) return; - const seqAtRequest = diskStatusSeq(tabId); - GetDiskStatus(tabId) - .then((res: unknown) => { - if (!res) return; - const st = res as DiskStatusInfo; - // The backend recorded this result as reported, so it is stored even - // when the compare itself is dropped below; the watcher will not send it. - if (tabsRef.current.some((t) => t.tabId === tabId)) setDiskStatusFromCheck(tabId, st, seqAtRequest); + // 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); @@ -932,7 +911,7 @@ function DetailPanelInner() { // The Signatures tab exists only when the document has >= 1 signature // field (hidden while unresolved or empty -- a deliberate departure from // the always-visible tabs, avoiding a permanently empty tab). - const showSignaturesTab = (signatures?.length ?? 0) > 0 || signaturesFileChanged || signaturesFileBusy; + const showSignaturesTab = (signatures?.length ?? 0) > 0; // FontPreview is active when iconHint='font', detail is a dict, and the // fetch resolved to a detail payload (not fallback / error). The header @@ -1440,8 +1419,6 @@ function DetailPanelInner() { active={detailView === 'signatures'} onNavigate={handleSignaturesNavigate} data={signatures} - fileChanged={signaturesFileChanged} - fileBusy={signaturesFileBusy} /> )} diff --git a/frontend/src/components/SignaturesView.tsx b/frontend/src/components/SignaturesView.tsx index 8053176a..3062b9cd 100644 --- a/frontend/src/components/SignaturesView.tsx +++ b/frontend/src/components/SignaturesView.tsx @@ -10,7 +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 } from '../lib/fileChange'; +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`. */ @@ -49,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}. */ @@ -65,10 +67,13 @@ export interface SignaturesViewProps { * fetch of its own; when omitted the view self-fetches on tabId change. */ data?: SignatureEntryData[] | null; - /** True when the parent's fetch was refused because the file changed on disk. */ - fileChanged?: boolean; - /** True when the parent's fetch was refused because another app has the file locked. */ - fileBusy?: boolean; +} + +/** 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 @@ -204,7 +209,8 @@ function SignatureCard({ entry, onNavigate }: { entry: SignatureEntryData; onNav /> ) : null} @@ -271,7 +277,7 @@ function SignatureCard({ entry, onNavigate }: { entry: SignatureEntryData; onNav * `data` prop is provided (DetailPanel's one-fetch-per-document contract); * self-fetches on tabId change otherwise. */ -export function SignaturesView({ tabId, active: _active, onNavigate, data, fileChanged, fileBusy }: SignaturesViewProps) { +export function SignaturesView({ tabId, active: _active, onNavigate, data }: SignaturesViewProps) { const [entries, setEntries] = useState(data ?? null); const [error, setError] = useState(null); @@ -308,20 +314,6 @@ export function SignaturesView({ tabId, active: _active, onNavigate, data, fileC
); } - if (fileChanged) { - return ( -
- {FILE_CHANGED_MESSAGE} -
- ); - } - if (fileBusy) { - return ( -
- {FILE_BUSY_MESSAGE} -
- ); - } if (error) { return (
diff --git a/frontend/src/hooks/usePDFService.ts b/frontend/src/hooks/usePDFService.ts index 53fb76b7..9686cfdb 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 index a2dc2bf3..678c8ab1 100644 --- a/frontend/src/lib/diskCheck.ts +++ b/frontend/src/lib/diskCheck.ts @@ -7,14 +7,19 @@ import { diskStatusSeq, setDiskStatusFromCheck, type DiskStatusInfo } from './di /** * 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. Failures are - * ignored: the watcher reports the change later. + * A watcher event that arrives first wins over the result. Resolves with the + * status, or rejects with the binding's error. */ -export function markTabFromDiskCheck(tabId: string): void { +export function checkDiskStatus(tabId: string): Promise { const seqAtRequest = diskStatusSeq(tabId); - Promise.resolve(GetDiskStatus(tabId)) - .then((st: unknown) => { - if (st) setDiskStatusFromCheck(tabId, st as DiskStatusInfo, seqAtRequest); - }) - .catch(() => {}); + 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 index 3bdd94db..4e731932 100644 --- a/frontend/src/lib/diskStatusStore.test.ts +++ b/frontend/src/lib/diskStatusStore.test.ts @@ -94,6 +94,16 @@ describe('diskStatusStore', () => { 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')); diff --git a/frontend/src/lib/diskStatusStore.ts b/frontend/src/lib/diskStatusStore.ts index 1e298bc4..f21b613e 100644 --- a/frontend/src/lib/diskStatusStore.ts +++ b/frontend/src/lib/diskStatusStore.ts @@ -35,6 +35,11 @@ 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(); @@ -70,6 +75,7 @@ function write(tabId: string, seq: number, info: DiskStatusInfo) { * 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); @@ -86,18 +92,27 @@ export function diskStatusSeq(tabId: string): number { * 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. Returns whether the result was written. + * 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. */ +/** + * 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); } @@ -129,5 +144,7 @@ export function useDiskStatus(tabId: string | null | undefined): DiskStatusEntry export function resetDiskStatusStore(): void { entries = new Map(); lastSeq.clear(); + retained = new Set(); + released.clear(); notify(); } diff --git a/frontend/src/lib/reloadDocument.ts b/frontend/src/lib/reloadDocument.ts index 0501fdb7..b6a8a410 100644 --- a/frontend/src/lib/reloadDocument.ts +++ b/frontend/src/lib/reloadDocument.ts @@ -5,7 +5,7 @@ import type { Dispatch } from 'react'; import { CloseDocument } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'; import type { AppAction, TabState } from '../hooks/useDocumentState'; -import { openPDFFile } from '../hooks/usePDFService'; +import { openErrorKind, openPDFFile } from '../hooks/usePDFService'; import { dispatchOpenedDocument } from './openedDocuments'; import { extractErrorMessage } from './extractErrorMessage'; import { isFileBusyMessage } from './fileChange'; @@ -17,10 +17,25 @@ const reloading = new Set(); /** Short reason for a failed reload, from the extracted backend message. */ export function reloadFailureReason(message: string): string { - if (/document not found/i.test(message)) return 'file not found'; - if (/encrypted|password/i.test(message)) return 'file is encrypted'; - if (/malformed/i.test(message)) return 'file is damaged or not a valid PDF'; - return 'unexpected error'; + 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. */