diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a39fb85..4cb4571 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -137,6 +137,7 @@ jobs:
cli-tree-dump
cli-views
update-notice
+ file-change-detection
)
# A win_subset entry that names no suite does not fail to match, it
# silently fails in_subset and the Windows leg prints a skip line and
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b47f065..e0a9961 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
All notable changes to UniDoc PDF Debugger are recorded here. Format follows Keep a Changelog with an added Refactored section; versions use semantic versioning.
+## [Unreleased]
+
+### Added
+
+- File > "Reload from Disk" (Cmd+R on macOS, Ctrl+R on Windows and Linux, and F5 everywhere) opens the active tab's file again in place. The tab keeps its position and its left-rail view; the tree, selection, detail panel, history and recent jumps reset as for a fresh open. A short "reloaded - view reset" note appears at the top of the window. A reload that finishes after you switched to another tab replaces that tab without taking focus. If the file is gone or no longer parses, the old tab stays as it was and the error banner says why; on Windows a file another app has locked shows "in use by another app - try again" instead
+- Open files are watched for changes on disk, background tabs included. When a file is rewritten, replaced or deleted, its tab gets a small amber dot (2 to 4 seconds after the write while the window has focus, 10 to 20 seconds otherwise, also for a file a generator keeps rewriting) and a tooltip with the time it changed. The detail panel shows "Document updated" with Reload and Compare buttons, or "File not found". Nothing reloads by itself, and the marker never moves focus or shifts the layout. Detection compares size, modification time and file identity, so an in-place rewrite that keeps all three is not seen
+- File > "Compare with File on Disk" diffs the open version against the file as it is now on disk, in the Diff panel, labelled "On disk (14:05)". Files over 500 MB ask first. Reload in the Diff header then makes the compared copy the tab's document without parsing it again
+- Plain Text and the CLI's `dump bytes` (raw and `--json`) refuse to read a file that changed since it was parsed ("file changed on disk") instead of mixing the old structure with the new bytes. Signatures and `dump signatures` keep every signature's details and show the /Contents hole check as "not checked" for such a file; they used to report a hole mismatch. On Windows a file another app has locked shows "File is in use by another app" in Plain Text and in the hole check instead
+
## [0.5.0] - 2026-10-14
Left-rail navigators for structure, pages and images, an image preview, update notices in the app and the CLI, and a CLI that says what it checked and rejects invocations it would have misread. Moves the app runtime to Wails v3 beta.
diff --git a/cmd/cli/cli_open.go b/cmd/cli/cli_open.go
index 1d1e791..ad04212 100644
--- a/cmd/cli/cli_open.go
+++ b/cmd/cli/cli_open.go
@@ -6,6 +6,11 @@ import (
"unidoc-pdf-debugger/internal/pdfcore"
)
+// afterCLIOpen, when set, runs right after openForCLI succeeds in the dump
+// bytes and dump signatures commands. Nil in production; tests use it to
+// change the file between the parse and the view read.
+var afterCLIOpen func(path string)
+
// openForCLI creates an Inspector, opens the file under the "cli" tab, and
// emits the non-fatal structural warning. On failure it classifies the error
// via handleOpenError and returns a non-zero exit code; the inspector is nil in
diff --git a/cmd/cli/cmd_bytes.go b/cmd/cli/cmd_bytes.go
index 81307a0..f8dc377 100644
--- a/cmd/cli/cmd_bytes.go
+++ b/cmd/cli/cmd_bytes.go
@@ -2,9 +2,11 @@ package main
import (
"context"
+ "errors"
"fmt"
- "io"
"os"
+
+ "unidoc-pdf-debugger/internal/pdfcore"
)
// runBytesDump parses flags and dispatches the document-level byte dump.
@@ -33,6 +35,8 @@ func runBytesDump(args []string) int {
// substitution is lossy and cannot be reversed, so re-encoding the decoded
// string would corrupt every control byte. Reading the file directly keeps the
// raw dump truly byte-for-byte without adding any PDF semantics (thin presenter).
+// Both paths refuse a file that changed since the parse ("file changed on
+// disk", exit 2).
func execBytesDump(filePath string, f docViewFlags) (exitCode int) {
defer func() {
if r := recover(); r != nil {
@@ -46,6 +50,9 @@ func execBytesDump(filePath string, f docViewFlags) (exitCode int) {
return code
}
defer func() { _ = ins.Close("cli") }()
+ if afterCLIOpen != nil {
+ afterCLIOpen(filePath)
+ }
if f.json {
// Only the --json path needs the decoded payload. GetPlainText performs a
@@ -70,15 +77,13 @@ func execBytesDump(filePath string, f docViewFlags) (exitCode int) {
}
// Verbatim source bytes: stream the file directly (the decoded Content is
- // lossy for control bytes and cannot reproduce the source). io.Copy streams
- // in fixed-size chunks rather than allocating the whole file like os.ReadFile.
- src, err := os.Open(filePath)
- if err != nil {
- writeJSONError(os.Stderr, err.Error())
- return 2
- }
- defer func() { _ = src.Close() }()
- if _, err := io.Copy(os.Stdout, src); err != nil {
+ // lossy for control bytes and cannot reproduce the source), in fixed-size
+ // chunks rather than allocating the whole file like os.ReadFile.
+ if _, err := ins.CopyDocumentFile("cli", os.Stdout); err != nil {
+ if errors.Is(err, pdfcore.ErrFileChanged) || errors.Is(err, pdfcore.ErrFileBusy) {
+ writeJSONError(os.Stderr, err.Error())
+ return 2
+ }
fmt.Fprintf(os.Stderr, "failed to write output: %v\n", err)
return 2
}
diff --git a/cmd/cli/cmd_signatures.go b/cmd/cli/cmd_signatures.go
index 56b6950..a6e49b3 100644
--- a/cmd/cli/cmd_signatures.go
+++ b/cmd/cli/cmd_signatures.go
@@ -36,6 +36,9 @@ func execSignaturesDump(filePath string, flags docViewFlags) (exitCode int) {
return code
}
defer func() { _ = ins.Close("cli") }()
+ if afterCLIOpen != nil {
+ afterCLIOpen(filePath)
+ }
list, err := ins.GetSignatures("cli")
if err != nil {
@@ -151,9 +154,12 @@ func printSignaturesPlain(out io.Writer, sigs []pdfcore.SignatureField) error {
w.Addf("Coverage note", "signed range does not start at byte 0 (starts at %d)", s.ByteRange[0])
}
}
- if s.HoleMatchesContents {
+ switch {
+ case s.HoleCheckError != "":
+ w.Add("Hole matches /Contents", "not checked: "+s.HoleCheckError)
+ case s.HoleMatchesContents:
w.Add("Hole matches /Contents", "yes")
- } else {
+ default:
w.Add("Hole matches /Contents", "no (excluded span differs from the /Contents extent)")
}
}
diff --git a/cmd/cli/file_change_test.go b/cmd/cli/file_change_test.go
new file mode 100644
index 0000000..9460b68
--- /dev/null
+++ b/cmd/cli/file_change_test.go
@@ -0,0 +1,138 @@
+// A file rewritten after the CLI parsed it is reported instead of mixing the
+// old parse with the new bytes: dump bytes (raw and --json) exits 2 with a
+// stderr JSON error; dump signatures keeps the parsed details and prints the
+// hole check as not checked. afterCLIOpen runs right after openForCLI
+// succeeds and rewrites the file.
+//
+// Run: cd code && go test -run TestCLIFileChanged ./cmd/cli/
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// signedCopy copies testdata/signed.pdf into a temp dir.
+func signedCopy(t *testing.T) string {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "signed.pdf"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "signed.pdf")
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+// rewriteAfterOpen installs an afterCLIOpen hook that overwrites the opened
+// file with minimal.pdf and moves its mtime ahead.
+func rewriteAfterOpen(t *testing.T) *bool {
+ t.Helper()
+ called := new(bool)
+ afterCLIOpen = func(path string) {
+ *called = true
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "minimal.pdf"))
+ if err != nil {
+ t.Errorf("read minimal.pdf: %v", err)
+ return
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Errorf("rewrite: %v", err)
+ }
+ later := time.Now().Add(time.Hour)
+ _ = os.Chtimes(path, later, later)
+ }
+ t.Cleanup(func() { afterCLIOpen = nil })
+ return called
+}
+
+func TestCLIFileChangedBytesJSON(t *testing.T) {
+ path := signedCopy(t)
+ called := rewriteAfterOpen(t)
+ var code int
+ var stderr string
+ captureStdout(t, func() {
+ stderr = captureStderr(t, func() {
+ code = execBytesDump(path, docViewFlags{json: true})
+ })
+ })
+ if !*called {
+ t.Fatalf("afterCLIOpen was not called by dump bytes --json")
+ }
+ if code != 2 {
+ t.Errorf("exit %d, want 2", code)
+ }
+ if !strings.Contains(stderr, "file changed on disk") {
+ t.Errorf("stderr %q must contain %q", stderr, "file changed on disk")
+ }
+}
+
+func TestCLIFileChangedBytesRaw(t *testing.T) {
+ path := signedCopy(t)
+ called := rewriteAfterOpen(t)
+ var code int
+ var stdout, stderr string
+ stdout = captureStdout(t, func() {
+ stderr = captureStderr(t, func() {
+ code = execBytesDump(path, docViewFlags{})
+ })
+ })
+ if !*called {
+ t.Fatalf("afterCLIOpen was not called by dump bytes")
+ }
+ if code != 2 {
+ t.Errorf("exit %d, want 2", code)
+ }
+ if !strings.Contains(stderr, "file changed on disk") {
+ t.Errorf("stderr %q must contain %q", stderr, "file changed on disk")
+ }
+ if stdout != "" {
+ t.Errorf("no bytes of the rewritten file may reach stdout, got %d bytes", len(stdout))
+ }
+}
+
+func TestCLIFileChangedSignatures(t *testing.T) {
+ path := signedCopy(t)
+ called := rewriteAfterOpen(t)
+ var code int
+ stdout := captureStdout(t, func() {
+ captureStderr(t, func() {
+ code = execSignaturesDump(path, docViewFlags{})
+ })
+ })
+ if !*called {
+ t.Fatalf("afterCLIOpen was not called by dump signatures")
+ }
+ if code != 0 {
+ t.Errorf("exit %d, want 0", code)
+ }
+ if !strings.Contains(stdout, "not checked: file changed on disk") {
+ t.Errorf("stdout must note the hole as not checked, got:\n%s", stdout)
+ }
+ if !strings.Contains(stdout, "ByteRange") {
+ t.Errorf("stdout must keep the parsed signature details, got:\n%s", stdout)
+ }
+}
+
+func TestCLIFileChangedHookUnsetLeavesRunsUnaffected(t *testing.T) {
+ path := signedCopy(t)
+ if afterCLIOpen != nil {
+ t.Fatalf("afterCLIOpen must be nil outside tests")
+ }
+ var code int
+ var stderr string
+ captureStdout(t, func() {
+ stderr = captureStderr(t, func() {
+ code = execSignaturesDump(path, docViewFlags{json: true})
+ })
+ })
+ if code != 0 || stderr != "" {
+ t.Errorf("unchanged file: exit %d stderr %q, want 0 and nothing", code, stderr)
+ }
+}
diff --git a/frontend/src/App.drain.test.tsx b/frontend/src/App.drain.test.tsx
index 41be843..c487139 100644
--- a/frontend/src/App.drain.test.tsx
+++ b/frontend/src/App.drain.test.tsx
@@ -54,6 +54,7 @@ const mockCloseDocument = vi.fn().mockResolvedValue(undefined);
vi.mock('../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({
OpenFile: (...a: unknown[]) => mockOpenFile(...a),
+ SetWatchedTabs: vi.fn().mockResolvedValue(undefined),
GetTreeRoot: (...a: unknown[]) => mockGetTreeRoot(...a),
GetChildren: (...a: unknown[]) => mockGetChildren(...a),
CloseDocument: (...a: unknown[]) => mockCloseDocument(...a),
diff --git a/frontend/src/App.geometry.test.tsx b/frontend/src/App.geometry.test.tsx
index 09de568..3a1d3ff 100644
--- a/frontend/src/App.geometry.test.tsx
+++ b/frontend/src/App.geometry.test.tsx
@@ -76,6 +76,7 @@ vi.mock(
'../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js',
() => ({
OpenFile: vi.fn(),
+ SetWatchedTabs: vi.fn().mockResolvedValue(undefined),
GetTreeRoot: vi.fn(),
GetChildren: vi.fn(),
CloseDocument: vi.fn().mockResolvedValue(undefined),
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c2bf9d6..9fb3426 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -2,12 +2,13 @@
* @file Root application component. Wires Wails runtime events to app state
* and renders either the empty state or the main three-panel layout.
*/
-import { useEffect, useRef } from 'react'
+import { useEffect, useMemo, useRef } from 'react'
import { Events, Screens, Window } from '@wailsio/runtime'
-import { ConsumePendingOpenFiles } from '../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'
+import { ConsumePendingOpenFiles, SetWatchedTabs } from '../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js'
import { AppProvider, useAppState, useAppDispatch } from './hooks/useDocumentState'
import { mapErrorMessage, openPDFFile } from './hooks/usePDFService'
import { useWindowPersistence } from './hooks/useWindowPersistence'
+import { isInTextField } from './hooks/useFindBar'
import { computeRestorePlan } from './lib/windowGeometryGuard'
import { dispatchOpenedDocument } from './lib/openedDocuments'
import { getPlatformModifier } from './lib/platform'
@@ -19,6 +20,13 @@ import { BatchOpenDialog } from './components/BatchOpenDialog'
import { CommandPalette } from './components/CommandPalette/CommandPalette'
import { UpdateNotifier } from './components/UpdateNotifier'
import { openPalette, useCommandPalette } from './hooks/useCommandPalette'
+import { ReloadToast } from './components/ReloadToast'
+import { DiskChangeAnnouncer, announceDiskChange } from './components/DiskChangeAnnouncer'
+import { applyDiskStatusEvent, readDiskStatus, retainDiskStatus } from './lib/diskStatusStore'
+import { reloadActiveTab } from './lib/reloadDocument'
+
+/** Window in which a Cmd/Ctrl+R keydown and the menu event count as one press. */
+const SHORTCUT_ECHO_MS = 1000
/**
* Inner shell that subscribes to Wails backend events and delegates
@@ -46,6 +54,90 @@ function AppContent() {
// Ref mirrors tabs for use inside event handler closures without stale captures
const tabsRef = useRef(tabs)
useEffect(() => { tabsRef.current = tabs }, [tabs])
+ const activeTabIdRef = useRef(activeTabId)
+ useEffect(() => { activeTabIdRef.current = activeTabId }, [activeTabId])
+
+ // On-disk status entries live only as long as their tab: a closed or
+ // replaced tab id drops its entry, which clears its marker. The backend
+ // watcher polls only the tab ids in the strip, never a backend-only Diff
+ // right side. Keyed on the ids so per-tab state changes (selection,
+ // history) do not call the backend.
+ const tabIdsKey = useMemo(() => tabs.map((t) => t.tabId).join('\n'), [tabs])
+ useEffect(() => {
+ const ids = tabIdsKey === '' ? [] : tabIdsKey.split('\n')
+ retainDiskStatus(ids)
+ Promise.resolve(SetWatchedTabs(ids)).catch(() => {})
+ }, [tabIdsKey])
+
+ // Reload from disk (File > Reload from Disk, its Cmd/Ctrl+R accelerator, and
+ // F5) always acts on the active tab, stale or not. Cmd/Ctrl+R is also
+ // handled here so it works before the native menu is live and never reaches
+ // the webview's own page reload. One press can arrive both as the keydown
+ // and as the menu event, in either order: a trigger from the other source
+ // within SHORTCUT_ECHO_MS of the last one is that echo and is ignored.
+ useEffect(() => {
+ const reloadActive = () => reloadActiveTab(dispatch, () => tabsRef.current, activeTabIdRef.current)
+ let lastShortcut = { source: '', at: -Infinity }
+ /**
+ * Records a press and reports whether it is new rather than the echo of
+ * one from the other source.
+ * @param {'key' | 'menu'} source
+ */
+ const notePress = (source) => {
+ const now = performance.now()
+ if (lastShortcut.source !== source && now - lastShortcut.at < SHORTCUT_ECHO_MS) return false
+ lastShortcut = { source, at: now }
+ return true
+ }
+ const wantsMeta = getPlatformModifier() === 'Cmd'
+ /** @param {KeyboardEvent} e */
+ function onKeyDown(e) {
+ const mod = wantsMeta ? e.metaKey : e.ctrlKey
+ const isModR = mod && !e.shiftKey && !e.altKey && (e.key === 'r' || e.key === 'R')
+ if (e.key !== 'F5' && !isModR) return
+ // Cmd/Ctrl+R never reaches the webview reload, even in a text field.
+ e.preventDefault()
+ const swallowed = e.repeat || isInTextField(e.target)
+ if (isModR) {
+ // A swallowed press is still recorded, so its menu echo is dropped too.
+ if (notePress('key') && !swallowed) reloadActive()
+ return
+ }
+ if (!swallowed) reloadActive()
+ }
+ const offReload = Events.On('document:reload-active', () => {
+ if (notePress('menu')) reloadActive()
+ })
+ window.addEventListener('keydown', onKeyDown)
+ return () => {
+ offReload()
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [dispatch])
+
+ // Watcher events go to the disk-status store, never to AppState, so a status
+ // update re-renders only the subscribed tab item, note and announcer.
+ // Events for tab ids not in the tab list yet (a reload still fetching its
+ // tree) are ignored; reloadTab checks the new id once its tab exists.
+ useEffect(() => {
+ return Events.On('document:disk-status', (event) => {
+ const data = event?.data
+ if (!data || typeof data.tabId !== 'string') return
+ const tab = tabsRef.current.find((t) => t.tabId === data.tabId)
+ if (!tab) return
+ const wasChanged = readDiskStatus(data.tabId)?.state === 'changed'
+ const applied = applyDiskStatusEvent({
+ tabId: data.tabId,
+ seq: Number(data.seq) || 0,
+ state: String(data.state ?? ''),
+ loadedModTime: Number(data.loadedModTime) || 0,
+ diskModTime: Number(data.diskModTime) || 0,
+ diskSize: Number(data.diskSize) || 0,
+ })
+ // A refresh of an already changed tab (new times) is not announced again.
+ if (applied && data.state === 'changed' && !wasChanged) announceDiskChange(`${tab.fileName} changed on disk`)
+ })
+ }, [])
// Sync navigation menu enabled state with backend
const prevNavState = useRef({ canGoBack: false, canGoForward: false })
@@ -309,14 +401,6 @@ function AppContent() {
// native menu item in main.go also emits navigate:goToPage; this listener
// exists so the shortcut works even before the menu is opened.
useEffect(() => {
- /** @param {EventTarget | null} target */
- function isInTextField(target) {
- if (!(target instanceof HTMLElement)) return false
- const tag = target.tagName
- if (tag === 'INPUT' || tag === 'TEXTAREA') return true
- if (target.isContentEditable) return true
- return false
- }
const wantsMeta = getPlatformModifier() === 'Cmd'
/** @param {KeyboardEvent} e */
function handler(e) {
@@ -355,6 +439,8 @@ function AppContent() {
+
+
)
}
diff --git a/frontend/src/App.menuNavigation.test.tsx b/frontend/src/App.menuNavigation.test.tsx
index 745dfd6..40681d9 100644
--- a/frontend/src/App.menuNavigation.test.tsx
+++ b/frontend/src/App.menuNavigation.test.tsx
@@ -42,6 +42,7 @@ vi.mock('allotment/dist/style.css', () => ({}));
vi.mock('../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({
OpenFile: vi.fn(),
+ SetWatchedTabs: vi.fn().mockResolvedValue(undefined),
GetTreeRoot: vi.fn(),
GetChildren: vi.fn().mockResolvedValue([]),
CloseDocument: vi.fn().mockResolvedValue(undefined),
diff --git a/frontend/src/App.pagesNavigator.test.tsx b/frontend/src/App.pagesNavigator.test.tsx
index 072aa8e..84b9914 100644
--- a/frontend/src/App.pagesNavigator.test.tsx
+++ b/frontend/src/App.pagesNavigator.test.tsx
@@ -55,6 +55,7 @@ const mockGetPageIndex = vi.fn();
vi.mock('../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({
OpenFile: vi.fn(),
+ SetWatchedTabs: vi.fn().mockResolvedValue(undefined),
GetTreeRoot: vi.fn(),
GetChildren: vi.fn().mockResolvedValue([]),
CloseDocument: vi.fn().mockResolvedValue(undefined),
diff --git a/frontend/src/App.reload.test.tsx b/frontend/src/App.reload.test.tsx
new file mode 100644
index 0000000..ca8a7ca
--- /dev/null
+++ b/frontend/src/App.reload.test.tsx
@@ -0,0 +1,611 @@
+/**
+ * Reload from disk at the app level: the document:reload-active menu event and
+ * F5 reload the active tab under a new tab id in the same strip position,
+ * failures keep the old tab, a busy file only shows the pill, and on-disk
+ * status events mark tabs without disturbing the active view.
+ *
+ * The real TabBar and ReloadToast render; MainLayout is replaced by a
+ * useAppState consumer that counts its renders, standing in for TreePanel and
+ * DetailPanel.
+ *
+ * Run: cd frontend && npx vitest run src/App.reload.test.tsx
+ */
+import { render, screen, act, waitFor, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, test, expect, vi, beforeEach } from 'vitest';
+import type { AppState } from './hooks/useDocumentState';
+import { resetDiskStatusStore } from './lib/diskStatusStore';
+import App from './App';
+import { getPlatformModifier } from './lib/platform';
+import { SetWatchedTabs } from '../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js';
+
+type EventHandler = (event: { data: Record }) => void;
+const eventHandlers: Record = {};
+
+vi.mock('@wailsio/runtime', () => ({
+ Events: {
+ On: (name: string, handler: EventHandler) => {
+ if (!eventHandlers[name]) eventHandlers[name] = [];
+ eventHandlers[name].push(handler);
+ return () => {
+ const idx = eventHandlers[name]?.indexOf(handler) ?? -1;
+ if (idx >= 0) eventHandlers[name].splice(idx, 1);
+ };
+ },
+ Emit: vi.fn(),
+ },
+}));
+
+const bindings = vi.hoisted(() => ({
+ OpenFile: vi.fn(),
+ GetTreeRoot: vi.fn(),
+ GetChildren: vi.fn(),
+ CloseDocument: vi.fn(),
+ GetDiskStatus: vi.fn(),
+}));
+
+vi.mock('../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({
+ OpenFile: (...a: unknown[]) => bindings.OpenFile(...a),
+ GetTreeRoot: (...a: unknown[]) => bindings.GetTreeRoot(...a),
+ GetChildren: (...a: unknown[]) => bindings.GetChildren(...a),
+ CloseDocument: (...a: unknown[]) => bindings.CloseDocument(...a),
+ GetDiskStatus: (...a: unknown[]) => bindings.GetDiskStatus(...a),
+ SetWatchedTabs: vi.fn().mockResolvedValue(undefined),
+ OpenFileDialog: vi.fn(),
+ ConsumePendingOpenFiles: vi.fn().mockResolvedValue([]),
+ GetObjectDetail: vi.fn(),
+ GetContentStream: vi.fn(),
+ GetAncestorPath: vi.fn(),
+}));
+
+const probe = vi.hoisted(() => ({ renders: 0, state: null as AppState | null }));
+
+vi.mock('./components/MainLayout', async () => {
+ const { useAppState } = await import('./hooks/useDocumentState');
+ return {
+ MainLayout: () => {
+ const s = useAppState();
+ probe.renders += 1;
+ probe.state = s;
+ return
;
+ }
+ function Allotment({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+ }
+ Allotment.Pane = Pane;
+ return { Allotment };
+});
+vi.mock('allotment/dist/style.css', () => ({}));
+
+vi.mock('@wailsio/runtime', async (importOriginal) => ({
+ ...(await importOriginal()),
+ Events: {
+ On: vi.fn(() => vi.fn()),
+ Emit: vi.fn(),
+ },
+}));
+
+const b = vi.hoisted(() => ({
+ OpenFile: vi.fn(),
+ GetTreeRoot: vi.fn(),
+ GetChildren: vi.fn(),
+ CloseDocument: vi.fn(),
+ GetSignatures: vi.fn(),
+ GetDiskStatus: vi.fn(),
+ DiffDocuments: vi.fn(),
+ OpenFileDialog: vi.fn(),
+}));
+
+vi.mock('../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({
+ OpenFile: (...a: unknown[]) => b.OpenFile(...a),
+ GetTreeRoot: (...a: unknown[]) => b.GetTreeRoot(...a),
+ GetChildren: (...a: unknown[]) => b.GetChildren(...a),
+ CloseDocument: (...a: unknown[]) => b.CloseDocument(...a),
+ GetSignatures: (...a: unknown[]) => b.GetSignatures(...a),
+ GetDiskStatus: (...a: unknown[]) => b.GetDiskStatus(...a),
+ DiffDocuments: (...a: unknown[]) => b.DiffDocuments(...a),
+ OpenFileDialog: (...a: unknown[]) => b.OpenFileDialog(...a),
+ GetObjectDetail: vi.fn().mockResolvedValue(null),
+ GetContentStream: vi.fn(),
+ GetImageData: vi.fn(),
+ DescribeImage: vi.fn().mockResolvedValue({ width: 0, height: 0, colorSpace: '', estimatedBytes: 0 }),
+ SaveImageToFile: vi.fn().mockResolvedValue(''),
+ GetReverseRefs: vi.fn().mockResolvedValue([]),
+ GetFontView: vi.fn(),
+ GetXRefTable: vi.fn().mockResolvedValue({ tabId: 'x', entries: [] }),
+ GetPlainText: vi.fn().mockResolvedValue({ tabId: 'x', content: '', totalBytes: 0 }),
+ GetPlainTextSize: vi.fn().mockResolvedValue(0),
+ GetEmbeddedFiles: vi.fn().mockResolvedValue({ files: [] }),
+ GetEmbeddedFileBytes: vi.fn(),
+ GetDocumentMetadata: vi.fn().mockResolvedValue({ info: {}, xmp: '' }),
+ SaveBytesToFile: vi.fn(),
+ Validate: vi.fn().mockResolvedValue({ problems: [], rules: [], scope: '' }),
+}));
+
+const catalogNode = {
+ id: 'root',
+ label: 'Catalog',
+ rawKey: '',
+ nodeType: 'dict',
+ valueType: '',
+ hasChildren: true,
+ childCount: 1,
+ iconHint: 'catalog',
+ error: '',
+};
+
+let state: AppState;
+let dispatch: Dispatch;
+function Probe() {
+ state = useAppState();
+ dispatch = useAppDispatch();
+ return null;
+}
+
+function mount() {
+ render(
+
+
+
+ ,
+ );
+}
+
+let n = 0;
+function paths() {
+ n += 1;
+ return { a: `a${n}`, b: `b${n}`, fresh: `fresh${n}`, pathA: `/docs/${n}/a.pdf`, pathB: `/docs/${n}/b.pdf` };
+}
+
+function open(tabId: string, filePath: string) {
+ act(() =>
+ dispatch({
+ type: 'OPEN_DOCUMENT',
+ payload: { tabId, fileName: filePath.split('/').pop() ?? '', filePath, pageCount: 1, rootNode: catalogNode, rootChildren: [] },
+ }),
+ );
+}
+
+const at = (h: number, m: number) => new Date(2026, 0, 12, h, m).getTime();
+
+function mark(tabId: string, seq: number, state: 'changed' | 'missing' | 'current') {
+ act(() =>
+ applyDiskStatusEvent({
+ tabId,
+ seq,
+ state,
+ loadedModTime: at(14, 0),
+ diskModTime: state === 'missing' ? 0 : at(14, 5),
+ diskSize: state === 'missing' ? 0 : 4096,
+ }),
+ );
+}
+
+function envelope(message: string) {
+ return new Error(JSON.stringify({ message, cause: {}, kind: 'RuntimeError' }));
+}
+
+function triggerNames(): string[] {
+ return within(screen.getByTestId('detail-tab-list'))
+ .getAllByRole('tab')
+ .map((el) => el.textContent ?? '');
+}
+
+beforeEach(() => {
+ for (const fn of Object.values(b)) fn.mockReset();
+ b.CloseDocument.mockResolvedValue(undefined);
+ b.GetSignatures.mockResolvedValue([]);
+ b.GetTreeRoot.mockResolvedValue(catalogNode);
+ b.GetChildren.mockResolvedValue([]);
+ resetDiskStatusStore();
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 0, 12, 18, 0));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('stale note', () => {
+ test('changed: shows Document updated with Reload and Compare buttons and the version tooltip', () => {
+ const p = paths();
+ mount();
+ open(p.a, p.pathA);
+ mark(p.a, 1, 'changed');
+
+ const note = screen.getByTestId('stale-note');
+ expect(note).toHaveTextContent('Document updated');
+ const buttons = within(note).getAllByRole('button');
+ expect(buttons.map((x) => x.textContent)).toEqual(['Reload', 'Compare']);
+ for (const btn of buttons) expect(btn.tagName).toBe('BUTTON');
+ const label = within(note).getByText('Document updated');
+ expect(label.closest('[title]')).toHaveAttribute('title', 'Showing version from 14:00. Changed on disk 14:05.');
+ });
+
+ test('missing: shows File not found with no buttons', () => {
+ const p = paths();
+ mount();
+ open(p.a, p.pathA);
+ mark(p.a, 1, 'missing');
+
+ const note = screen.getByTestId('stale-note');
+ expect(note).toHaveTextContent('File not found');
+ expect(within(note).queryAllByRole('button')).toHaveLength(0);
+ expect(within(note).getByText('File not found').closest('[title]')).toHaveAttribute('title', 'File no longer found at this path');
+ });
+
+ test('absent for a current active tab, and for a stale background tab', () => {
+ const p = paths();
+ mount();
+ open(p.a, p.pathA);
+ open(p.b, p.pathB);
+ expect(screen.queryByTestId('stale-note')).not.toBeInTheDocument();
+ mark(p.a, 1, 'changed');
+ expect(screen.queryByTestId('stale-note')).not.toBeInTheDocument();
+ });
+
+ test('sits beside the tab list, outside it, and leaves the tab triggers unchanged', () => {
+ const p = paths();
+ mount();
+ open(p.a, p.pathA);
+ const before = triggerNames();
+
+ mark(p.a, 1, 'changed');
+
+ const note = screen.getByTestId('stale-note');
+ const list = screen.getByTestId('detail-tab-list');
+ expect(list.contains(note)).toBe(false);
+ expect(note.closest('[role="tablist"]')).toBeNull();
+ expect(note.parentElement).toBe(list.parentElement);
+ expect(triggerNames()).toEqual(before);
+ });
+
+ test('buttons keep a 24px minimum hit area and the row keeps its classes', () => {
+ const p = paths();
+ mount();
+ open(p.a, p.pathA);
+ const list = screen.getByTestId('detail-tab-list');
+ const row = list.parentElement as HTMLElement;
+ const rowBefore = row.className;
+ const listBefore = list.className;
+
+ mark(p.a, 1, 'changed');
+
+ const note = screen.getByTestId('stale-note');
+ expect(row.className).toBe(rowBefore);
+ expect(list.className).toBe(listBefore);
+ // jsdom has no layout: the note adds no vertical size of its own and the
+ // buttons carry the 24px minimums.
+ for (const el of [note, ...Array.from(note.querySelectorAll('*'))]) {
+ expect(el.className).not.toMatch(/(^|\s)(py-|pt-|pb-|my-|mt-|mb-|h-|min-h-(?!\[24px\]))/);
+ }
+ for (const btn of within(note).getAllByRole('button')) {
+ expect(btn.className).toMatch(/(^|\s)min-h-\[24px\](\s|$)/);
+ expect(btn.className).toMatch(/(^|\s)min-w-\[24px\](\s|$)/);
+ }
+ });
+
+ test('UI strings are ASCII and use no character separators', () => {
+ const p = paths();
+ mount();
+ open(p.a, p.pathA);
+ mark(p.a, 1, 'changed');
+ const text = screen.getByTestId('stale-note').textContent ?? '';
+ expect(text).toMatch(/^[\x20-\x7e]*$/);
+ expect(text).not.toMatch(/[|/]/);
+ });
+
+ test('Reload reloads the active tab and the note goes away', async () => {
+ const p = paths();
+ mount();
+ open(p.a, p.pathA);
+ mark(p.a, 1, 'changed');
+ b.OpenFile.mockResolvedValue({ tabId: p.fresh, fileName: 'a.pdf', filePath: p.pathA, pageCount: 3, fileSize: 1, error: '' });
+
+ fireEvent.click(within(screen.getByTestId('stale-note')).getByRole('button', { name: 'Reload' }));
+
+ await waitFor(() => expect(state.activeTabId).toBe(p.fresh));
+ expect(b.OpenFile).toHaveBeenCalledWith(p.pathA);
+ expect(b.CloseDocument).toHaveBeenCalledWith(p.a);
+ expect(state.tabs.map((t) => t.tabId)).toEqual([p.fresh]);
+ expect(screen.queryByTestId('stale-note')).not.toBeInTheDocument();
+ });
+});
+
+function signedEntry(holeCheckError: string) {
+ return {
+ fieldName: 'Signature1',
+ fieldNodeId: '4 0 R',
+ signed: true,
+ type: 'signature',
+ subFilter: 'adbe.pkcs7.detached',
+ notes: ['trust not verified - structural decomposition only'],
+ certificates: [],
+ byteRange: [0, 100, 200, 50],
+ coversWholeFile: true,
+ trailingGap: 0,
+ holeMatchesContents: false,
+ coverageError: '',
+ holeCheckError,
+ };
+}
+
+describe('signatures on a file that changed on disk', () => {
+ test('keeps every signature, notes the hole as not checked and marks the tab', async () => {
+ const p = paths();
+ b.GetSignatures.mockResolvedValue([signedEntry('file changed on disk since it was opened')]);
+ b.GetDiskStatus.mockResolvedValue({ state: 'changed', loadedModTime: at(14, 0), diskModTime: at(14, 5), diskSize: 4096 });
+ mount();
+ open(p.a, p.pathA);
+
+ const sigTab = await screen.findByRole('tab', { name: /signatures/i });
+ await waitFor(() => expect(b.GetDiskStatus).toHaveBeenCalledWith(p.a));
+ await waitFor(() => expect(readDiskStatus(p.a)?.state).toBe('changed'));
+ expect(screen.getByTestId('stale-note')).toBeInTheDocument();
+
+ fireEvent.click(sigTab);
+ expect(await screen.findByText('not checked: File changed on disk. Reload to view.')).toBeInTheDocument();
+ expect(screen.getByText('adbe.pkcs7.detached')).toBeInTheDocument();
+ });
+
+ test('a file locked by another app notes the hole as busy and checks nothing', async () => {
+ const p = paths();
+ b.GetSignatures.mockResolvedValue([signedEntry('file is busy: another process has it open')]);
+ mount();
+ open(p.a, p.pathA);
+
+ const sigTab = await screen.findByRole('tab', { name: /signatures/i });
+ fireEvent.click(sigTab);
+ expect(await screen.findByText('not checked: File is in use by another app. Reload to try again.')).toBeInTheDocument();
+ expect(b.GetDiskStatus).not.toHaveBeenCalled();
+ });
+
+ test('another GetSignatures error still hides the tab and checks nothing', async () => {
+ const p = paths();
+ b.GetSignatures.mockRejectedValue(envelope('malformed PDF: bad acroform'));
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ mount();
+ open(p.a, p.pathA);
+
+ await waitFor(() => expect(b.GetSignatures).toHaveBeenCalledWith(p.a));
+ expect(screen.queryByRole('tab', { name: /signatures/i })).not.toBeInTheDocument();
+ expect(b.GetDiskStatus).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx
index c08a0ee..ee69202 100644
--- a/frontend/src/components/DetailPanel.tsx
+++ b/frontend/src/components/DetailPanel.tsx
@@ -6,10 +6,19 @@
*/
import { Fragment, useState, useEffect, useCallback, useMemo, useRef, memo } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
-import { GetObjectDetail, GetContentStream, GetImageData, DescribeImage, SaveImageToFile, GetReverseRefs, GetFontView, GetSignatures, OpenFile, OpenFileDialog, CloseDocument } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js';
-import { ContentStreamData, ImageData as PdfImageData } from '../../bindings/unidoc-pdf-debugger/internal/pdfcore/models.js';
-import { useAppState, useAppDispatch } from '../hooks/useDocumentState';
+import * as Dialog from '@radix-ui/react-dialog';
+import { Events } from '@wailsio/runtime';
+import { GetObjectDetail, GetContentStream, GetImageData, DescribeImage, SaveImageToFile, GetReverseRefs, GetFontView, GetSignatures, OpenFile, OpenFileDialog, CloseDocument, GetTreeRoot, GetChildren } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js';
+import { ContentStreamData, DocumentInfo, ImageData as PdfImageData } from '../../bindings/unidoc-pdf-debugger/internal/pdfcore/models.js';
+import { useAppState, useAppDispatch, type TreeNode } from '../hooks/useDocumentState';
import { extractErrorMessage } from '../lib/extractErrorMessage';
+import { FILE_MISSING_MESSAGE, isFileChangedMessage } from '../lib/fileChange';
+import { checkDiskStatus, markTabFromDiskCheck } from '../lib/diskCheck';
+import { formatTimestamp } from '../lib/formatTimestamp';
+import { dispatchOpenedDocument } from '../lib/openedDocuments';
+import { reloadActiveTab, reloadedMessage, reloadFailureReason } from '../lib/reloadDocument';
+import { showReloadToast } from './ReloadToast';
+import { StaleNote } from './StaleNote';
import { IMAGE_WARN_THRESHOLD_BYTES, IMAGE_SLOW_THRESHOLD_SECONDS } from '../lib/imageConstants';
import { formatBytes } from '../lib/formatBytes';
import { escapeDisplayValue } from '../lib/escapeDisplayValue';
@@ -50,6 +59,25 @@ const INDIRECT_NODE_RE = /^obj:\d+:\d+$/;
*/
const REV_REFS_UNAVAILABLE_MARKER = 'reverse-ref index unavailable';
+/** Disk size above which Compare asks before loading a second copy. */
+const COMPARE_CONFIRM_BYTES = 500 * 1024 * 1024;
+
+/** A Diff right side parsed by Compare from the active tab's own file. */
+interface DiskCopy {
+ tabId: string;
+ info: DocumentInfo;
+ diskModTime: number;
+}
+
+/** A Compare waiting for confirmation because the file is large. */
+interface CompareConfirm {
+ tabId: string;
+ filePath: string;
+ fileName: string;
+ diskSize: number;
+ diskModTime: number;
+}
+
/** Maps PDF object type to a human-readable header label. */
const TYPE_LABEL_MAP: Record = {
dict: 'Properties',
@@ -164,6 +192,19 @@ function DetailPanelInner() {
// stale comparison from a previously-active tab.
const [diffRightTabId, setDiffRightTabId] = useState(null);
const [diffError, setDiffError] = useState(null);
+ // Set when the right side came from Compare with File on Disk. It applies
+ // only while its tabId is the current right side, so every path that
+ // replaces or clears diffRightTabId drops it too.
+ const [diskCopy, setDiskCopy] = useState(null);
+ const rightDiskCopy = diskCopy && diskCopy.tabId === diffRightTabId ? diskCopy : null;
+ const [compareConfirm, setCompareConfirm] = useState(null);
+ // The right-side tab id promoted to the app tab by the Diff header Reload.
+ // The cleanup effect skips closing it: the tab owns it now.
+ const promotedRightRef = useRef(null);
+ const promotingRef = useRef(false);
+ // True while Compare's OpenFile runs, so a second Compare does not parse
+ // another copy whose tab id could be overwritten before it is ever shown.
+ const comparingRef = useRef(false);
// Set by Close diff so the compare button takes focus once the picker
// replaces the unmounted Close diff button. Other paths that clear the
// comparison (document switch) leave focus alone.
@@ -187,6 +228,10 @@ function DetailPanelInner() {
// it against a document that is no longer the left-hand side of the diff.
const activeTabIdRef = useRef(activeTabId);
activeTabIdRef.current = activeTabId;
+ const tabsRef = useRef(tabs);
+ tabsRef.current = tabs;
+ const diffRightTabIdRef = useRef(diffRightTabId);
+ diffRightTabIdRef.current = diffRightTabId;
useEffect(() => {
setDetailView('object');
@@ -195,6 +240,7 @@ function DetailPanelInner() {
setSignatures(null);
setDiffRightTabId(null);
setDiffError(null);
+ setCompareConfirm(null);
}, [activeTabId]);
// The comparison document is opened backend-only (not an app tab),
@@ -203,6 +249,10 @@ function DetailPanelInner() {
useEffect(() => {
if (!diffRightTabId) return;
return () => {
+ if (promotedRightRef.current === diffRightTabId) {
+ promotedRightRef.current = null;
+ return;
+ }
CloseDocument(diffRightTabId).catch(() => {
/* best-effort cleanup; a failed close must not break the UI */
});
@@ -217,14 +267,18 @@ function DetailPanelInner() {
// One signature fetch per document tab. The result is passed
// down to SignaturesView via the data prop so the view never issues a
- // second fetch. A fetch failure hides the tab (empty list) and logs.
+ // second fetch. A fetch failure hides the tab (empty list) and logs. A
+ // signature whose hole could not be checked because the file changed marks
+ // the tab from a fresh disk check.
useEffect(() => {
if (!activeTabId) return;
let cancelled = false;
GetSignatures(activeTabId)
.then((result: unknown) => {
if (cancelled) return;
- setSignatures((Array.isArray(result) ? result : []) as SignatureEntryData[]);
+ const list = (Array.isArray(result) ? result : []) as SignatureEntryData[];
+ setSignatures(list);
+ if (list.some((sig) => isFileChangedMessage(sig.holeCheckError ?? ''))) markTabFromDiskCheck(activeTabId);
})
.catch((err: unknown) => {
if (cancelled) return;
@@ -631,6 +685,128 @@ function DetailPanelInner() {
.catch((err: unknown) => setDiffError(extractErrorMessage(err)));
}, [activeTabId]);
+ /**
+ * Reloads the active tab from disk (the stale note's Reload). Shares the
+ * reload flow with the menu item and F5.
+ */
+ const handleReload = useCallback(() => {
+ reloadActiveTab(dispatch, () => tabsRef.current, activeTabIdRef.current);
+ }, [dispatch]);
+
+ /**
+ * Parses the active tab's file again backend-only and shows it as the Diff
+ * right side. The same discard rule as a picked file applies: if the active
+ * tab changed or the panel unmounted meanwhile, the new document is closed.
+ */
+ const runCompare = useCallback((req: CompareConfirm) => {
+ if (comparingRef.current) return;
+ comparingRef.current = true;
+ setDiffError(null);
+ OpenFile(req.filePath)
+ .then((info: unknown) => {
+ const di = info as DocumentInfo | null;
+ if (!di?.tabId) return;
+ if (mountedRef.current && activeTabIdRef.current === req.tabId) {
+ setDiskCopy({ tabId: di.tabId, info: di, diskModTime: req.diskModTime });
+ setDiffRightTabId(di.tabId);
+ setDetailView('diff');
+ } else {
+ CloseDocument(di.tabId).catch(() => {});
+ }
+ })
+ .catch((err: unknown) => {
+ if (!mountedRef.current || activeTabIdRef.current !== req.tabId) return;
+ setDiffRightTabId(null);
+ setDiffError(extractErrorMessage(err));
+ setDetailView('diff');
+ })
+ .finally(() => {
+ comparingRef.current = false;
+ });
+ }, []);
+
+ /**
+ * Compare with File on Disk for the active tab. Checks the disk first: a
+ * missing file parses nothing, and a file over the size limit asks before a
+ * second copy is loaded.
+ */
+ const handleCompare = useCallback(() => {
+ const tabId = activeTabIdRef.current;
+ const tab = tabsRef.current.find((t) => t.tabId === tabId);
+ if (!tabId || !tab?.filePath) return;
+ // checkDiskStatus stores the result even when the compare itself is
+ // dropped below.
+ checkDiskStatus(tabId)
+ .then((st) => {
+ if (!st) return;
+ if (!mountedRef.current || activeTabIdRef.current !== tabId) return;
+ if (st.state === 'missing') {
+ setDiffRightTabId(null);
+ setDiffError(FILE_MISSING_MESSAGE);
+ setDetailView('diff');
+ return;
+ }
+ const req = { tabId, filePath: tab.filePath, fileName: tab.fileName, diskSize: st.diskSize, diskModTime: st.diskModTime };
+ if (st.diskSize > COMPARE_CONFIRM_BYTES) {
+ setCompareConfirm(req);
+ return;
+ }
+ runCompare(req);
+ })
+ .catch((err: unknown) => {
+ if (!mountedRef.current || activeTabIdRef.current !== tabId) return;
+ setDiffRightTabId(null);
+ setDiffError(extractErrorMessage(err));
+ setDetailView('diff');
+ });
+ }, [runCompare]);
+
+ // File > Compare with File on Disk. DetailPanel owns the Diff state, so it
+ // subscribes here; with no document open the handler does nothing.
+ const handleCompareRef = useRef(handleCompare);
+ handleCompareRef.current = handleCompare;
+ useEffect(() => Events.On('document:compare-active', () => handleCompareRef.current()), []);
+
+ /**
+ * Diff header Reload for a disk copy: promotes the compared document to the
+ * tab without parsing again, so the tab shows exactly the compared version.
+ * The tab's old document is closed by the in-place replace; the promoted id
+ * is skipped by the right-side cleanup and seeded in the disk-status store.
+ */
+ const handlePromote = useCallback(() => {
+ const copy = rightDiskCopy;
+ const leftId = activeTabIdRef.current;
+ if (!copy || !leftId || promotingRef.current) return;
+ promotingRef.current = true;
+ const { info } = copy;
+ Promise.all([GetTreeRoot(copy.tabId), GetChildren(copy.tabId, 'root')])
+ .then(([rootNode, rootChildren]) => {
+ // A right side closed or replaced meanwhile has had its document
+ // closed by the cleanup effect, so it is not promoted.
+ if (!mountedRef.current || activeTabIdRef.current !== leftId || diffRightTabIdRef.current !== copy.tabId) return;
+ promotedRightRef.current = copy.tabId;
+ dispatchOpenedDocument(dispatch, tabsRef.current, {
+ tabId: copy.tabId,
+ fileName: info.fileName,
+ filePath: info.filePath,
+ pageCount: info.pageCount,
+ rootNode,
+ rootChildren: rootChildren as TreeNode[] | null,
+ reload: true,
+ warning: info.error || null,
+ });
+ showReloadToast(reloadedMessage(info.fileName));
+ markTabFromDiskCheck(copy.tabId);
+ })
+ .catch((err: unknown) => {
+ if (!mountedRef.current || diffRightTabIdRef.current !== copy.tabId) return;
+ dispatch({ type: 'SET_DOCUMENT_ERROR', payload: { message: `Could not reload ${info.fileName}: ${reloadFailureReason(extractErrorMessage(err))}`, reload: true } });
+ })
+ .finally(() => {
+ promotingRef.current = false;
+ });
+ }, [rightDiskCopy, dispatch]);
+
// --- Object-tab find (Model B: scoped to the active tab). Derives a linear
// match source from the rendered object text (keys + values) and reuses the
// shared find machinery. The window listener is gated on the Object tab being
@@ -774,9 +950,13 @@ function DetailPanelInner() {
activationMode="manual"
className="h-full flex flex-col"
>
+ {/* The stale note is a sibling of the tab list (a tablist holds only
+ tabs). The row takes the list's border and background; the note
+ fills spare space and truncates first, so the triggers never move. */}
+
)}
+
+
{
focusPickAfterCloseRef.current = true;
setDiffRightTabId(null);
@@ -1239,6 +1423,43 @@ function DetailPanelInner() {
)}
+ { if (!open) setCompareConfirm(null); }}>
+
+
+
+ Compare with File on Disk
+
+ {compareConfirm
+ ? `${compareConfirm.fileName} is ${formatBytes(compareConfirm.diskSize)} on disk. Comparing loads a second copy into memory. Continue?`
+ : ''}
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/components/DiffView.tsx b/frontend/src/components/DiffView.tsx
index ee10e42..398f1ff 100644
--- a/frontend/src/components/DiffView.tsx
+++ b/frontend/src/components/DiffView.tsx
@@ -59,6 +59,10 @@ export interface DiffViewProps {
active: boolean;
/** Ends this comparison so another document can be picked. */
onClose: () => void;
+ /** Label naming the right side, e.g. `On disk (14:05)`. */
+ rightLabel?: string;
+ /** When set, the header shows Reload, which makes the right side the tab's document. */
+ onReload?: () => void;
}
/** Reports whether a node or any descendant is not "unchanged", or is a
@@ -149,7 +153,7 @@ function flatten(node: DiffNodeData, depth: number, expanded: Set, out:
* one selection; the LEFT pane also owns expand/collapse. A Close diff button
* in every state hands control back through onClose.
*/
-export function DiffView({ leftTabId, rightTabId, active, onClose }: DiffViewProps) {
+export function DiffView({ leftTabId, rightTabId, active, onClose, rightLabel, onReload }: DiffViewProps) {
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [expanded, setExpanded] = useState>(new Set());
@@ -409,7 +413,24 @@ export function DiffView({ leftTabId, rightTabId, active, onClose }: DiffViewPro
>
Next change
-
diff --git a/frontend/src/components/DiskChangeAnnouncer.tsx b/frontend/src/components/DiskChangeAnnouncer.tsx
new file mode 100644
index 0000000..9e178d9
--- /dev/null
+++ b/frontend/src/components/DiskChangeAnnouncer.tsx
@@ -0,0 +1,44 @@
+/**
+ * @file Visually hidden polite live region that announces a file changed on
+ * disk once per new change. Driven by its own module store so an
+ * announcement never dispatches to AppState.
+ */
+import { useEffect, useSyncExternalStore } from 'react';
+
+type Listener = () => void;
+const listeners = new Set();
+interface Announcement {
+ id: number;
+ text: string;
+}
+
+let message: Announcement = { id: 0, text: '' };
+
+function subscribe(l: Listener): () => void {
+ listeners.add(l);
+ return () => {
+ listeners.delete(l);
+ };
+}
+
+function getSnapshot(): Announcement {
+ return message;
+}
+
+/** Sets the text the live region announces; the same text again is announced again. */
+export function announceDiskChange(text: string): void {
+ message = { id: message.id + 1, text };
+ for (const l of listeners) l();
+}
+
+/** Mount-once live region for on-disk change announcements. Its text never outlives it. */
+export function DiskChangeAnnouncer() {
+ const announcement = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ useEffect(() => () => announceDiskChange(''), []);
+ return (
+
+ {/* A new node per announcement, so a repeated text is announced again. */}
+ {announcement.text}
+
+ );
+}
diff --git a/frontend/src/components/PlainTextView.fileChanged.test.tsx b/frontend/src/components/PlainTextView.fileChanged.test.tsx
new file mode 100644
index 0000000..d1515a6
--- /dev/null
+++ b/frontend/src/components/PlainTextView.fileChanged.test.tsx
@@ -0,0 +1,83 @@
+/**
+ * Plain Text load refused because the file changed on disk: the view shows a
+ * reload hint instead of its generic error, and asks GetDiskStatus once so the
+ * tab is marked at once (or not, when the file already went back).
+ *
+ * Run: cd frontend && npx vitest run src/components/PlainTextView.fileChanged.test.tsx
+ */
+import { render, screen, waitFor } from '@testing-library/react';
+import { describe, test, expect, vi, beforeEach } from 'vitest';
+import type { ReactNode } from 'react';
+import { PlainTextView } from './PlainTextView';
+import { AppProvider } from '../hooks/useDocumentState';
+import { readDiskStatus, resetDiskStatusStore } from '../lib/diskStatusStore';
+
+function ProviderWrapper({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+const mockGetPlainText = vi.fn();
+const mockGetPlainTextSize = vi.fn();
+const mockGetDiskStatus = vi.fn();
+
+vi.mock('../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({
+ GetPlainText: (...args: unknown[]) => mockGetPlainText(...args),
+ GetPlainTextSize: (...args: unknown[]) => mockGetPlainTextSize(...args),
+ GetDiskStatus: (...args: unknown[]) => mockGetDiskStatus(...args),
+}));
+
+const changedStatus = { state: 'changed', loadedModTime: 1_768_219_200_000, diskModTime: 1_768_219_500_000, diskSize: 4096 };
+
+function envelope(message: string) {
+ return new Error(JSON.stringify({ message, cause: {}, kind: 'RuntimeError' }));
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ resetDiskStatusStore();
+ mockGetPlainTextSize.mockResolvedValue(17);
+});
+
+describe('Plain Text on a file changed on disk', () => {
+ test('shows the reload hint and marks the tab from GetDiskStatus', async () => {
+ mockGetPlainText.mockRejectedValue(envelope('file changed on disk since it was opened'));
+ mockGetDiskStatus.mockResolvedValue(changedStatus);
+
+ render(, { wrapper: ProviderWrapper });
+
+ expect(await screen.findByText('File changed on disk. Reload to view.')).toBeInTheDocument();
+ await waitFor(() => expect(mockGetDiskStatus).toHaveBeenCalledWith('tab-1'));
+ await waitFor(() => expect(readDiskStatus('tab-1')?.state).toBe('changed'));
+ expect(mockGetDiskStatus).toHaveBeenCalledTimes(1);
+ });
+
+ test('a file that already went back is not marked', async () => {
+ mockGetPlainText.mockRejectedValue(envelope('file changed on disk since it was opened: open x: no such file'));
+ mockGetDiskStatus.mockResolvedValue({ ...changedStatus, state: 'current' });
+
+ render(, { wrapper: ProviderWrapper });
+
+ await waitFor(() => expect(mockGetDiskStatus).toHaveBeenCalledWith('tab-1'));
+ expect(readDiskStatus('tab-1')).toBeUndefined();
+ });
+
+ test('a file locked by another app shows the busy hint and does not check the disk', async () => {
+ mockGetPlainText.mockRejectedValue(envelope('file is busy: another process has it open: read x.pdf: lock violation'));
+
+ render(, { wrapper: ProviderWrapper });
+
+ expect(await screen.findByText('File is in use by another app. Reload to try again.')).toBeInTheDocument();
+ expect(screen.queryByText(/lock violation/)).not.toBeInTheDocument();
+ expect(mockGetDiskStatus).not.toHaveBeenCalled();
+ });
+
+ test('other load errors keep the generic error and do not check the disk', async () => {
+ mockGetPlainText.mockRejectedValue(envelope('malformed PDF: read failed'));
+
+ render(, { wrapper: ProviderWrapper });
+
+ await screen.findByTestId('plain-text-error');
+ expect(screen.queryByText('File changed on disk. Reload to view.')).not.toBeInTheDocument();
+ expect(mockGetDiskStatus).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/PlainTextView.tsx b/frontend/src/components/PlainTextView.tsx
index e0c2e11..a913d3a 100644
--- a/frontend/src/components/PlainTextView.tsx
+++ b/frontend/src/components/PlainTextView.tsx
@@ -21,6 +21,8 @@ import {
GetPlainTextSize,
} from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js';
import { extractErrorMessage } from '../lib/extractErrorMessage';
+import { FILE_BUSY_MESSAGE, FILE_CHANGED_MESSAGE, isFileBusyMessage, isFileChangedMessage } from '../lib/fileChange';
+import { markTabFromDiskCheck } from '../lib/diskCheck';
import { useAppDispatch, useAppState } from '../hooks/useDocumentState';
import { useFindBar } from '../hooks/useFindBar';
import { FindBar } from './FindBar';
@@ -190,6 +192,18 @@ export function PlainTextView({ tabId, active }: PlainTextViewProps) {
loadStateRef.current = 'cancelled';
setLoadState('cancelled');
setCancelling(false);
+ } else if (isFileChangedMessage(msg)) {
+ // The backend refused bytes from a file that changed since Open:
+ // show the reload hint and mark the tab from a fresh disk check.
+ loadStateRef.current = 'error';
+ setError(FILE_CHANGED_MESSAGE);
+ setLoadState('error');
+ markTabFromDiskCheck(tabIdAtFetch);
+ } else if (isFileBusyMessage(msg)) {
+ // Another app holds a lock on the file: show the busy hint.
+ loadStateRef.current = 'error';
+ setError(FILE_BUSY_MESSAGE);
+ setLoadState('error');
} else {
loadStateRef.current = 'error';
setError(msg);
diff --git a/frontend/src/components/ReloadToast.test.tsx b/frontend/src/components/ReloadToast.test.tsx
new file mode 100644
index 0000000..04098de
--- /dev/null
+++ b/frontend/src/components/ReloadToast.test.tsx
@@ -0,0 +1,78 @@
+/**
+ * Reload confirmation pill: a fixed, non-interactive status message at
+ * top-center that disappears after about 3 seconds. Driven by its own module
+ * store (showReloadToast), so showing it never dispatches to AppState.
+ *
+ * Run: cd frontend && npx vitest run src/components/ReloadToast.test.tsx
+ */
+import { render, screen, act } from '@testing-library/react';
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
+import { ReloadToast, showReloadToast } from './ReloadToast';
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ act(() => {
+ vi.runOnlyPendingTimers();
+ });
+ vi.useRealTimers();
+});
+
+describe('ReloadToast', () => {
+ test('shows the message in a polite status region', () => {
+ render();
+ act(() => showReloadToast('a.pdf reloaded - view reset'));
+ const status = screen.getByRole('status');
+ expect(status).toHaveTextContent('a.pdf reloaded - view reset');
+ expect(status).not.toHaveAttribute('aria-live', 'assertive');
+ });
+
+ test('is a fixed overlay that ignores the pointer and takes no focus', () => {
+ render();
+ act(() => showReloadToast('a.pdf reloaded - view reset'));
+ const pill = screen.getByText('a.pdf reloaded - view reset');
+ const box = pill.closest('.fixed') as HTMLElement | null;
+ expect(box).not.toBeNull();
+ expect(box?.className).toMatch(/pointer-events-none/);
+ expect(box?.querySelector('button, a, input, [tabindex]')).toBeNull();
+ expect(box?.hasAttribute('tabindex')).toBe(false);
+ });
+
+ test('disappears after about 3 seconds', () => {
+ render();
+ act(() => showReloadToast('a.pdf reloaded - view reset'));
+ act(() => {
+ vi.advanceTimersByTime(2500);
+ });
+ expect(screen.getByText('a.pdf reloaded - view reset')).toBeInTheDocument();
+ act(() => {
+ vi.advanceTimersByTime(1500);
+ });
+ expect(screen.queryByText('a.pdf reloaded - view reset')).not.toBeInTheDocument();
+ });
+
+ test('a second message within the window replaces the text and restarts the timer', () => {
+ render();
+ act(() => showReloadToast('a.pdf reloaded - view reset'));
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ act(() => showReloadToast('b.pdf is in use by another app - try again'));
+ expect(screen.queryByText('a.pdf reloaded - view reset')).not.toBeInTheDocument();
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(screen.getByText('b.pdf is in use by another app - try again')).toBeInTheDocument();
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(screen.queryByText('b.pdf is in use by another app - try again')).not.toBeInTheDocument();
+ });
+
+ test('renders nothing visible before any message', () => {
+ render();
+ expect(screen.queryByText(/reloaded/)).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/ReloadToast.tsx b/frontend/src/components/ReloadToast.tsx
new file mode 100644
index 0000000..5f59e17
--- /dev/null
+++ b/frontend/src/components/ReloadToast.tsx
@@ -0,0 +1,73 @@
+/**
+ * @file Reload confirmation pill: a fixed, non-interactive status message at
+ * top-center that disappears after about 3 seconds. Driven by its own module
+ * store, so showing it never dispatches to AppState.
+ */
+import { useEffect, useSyncExternalStore } from 'react';
+
+const VISIBLE_MS = 3000;
+
+interface Toast {
+ id: number;
+ message: string;
+}
+
+type Listener = () => void;
+const listeners = new Set();
+let current: Toast | null = null;
+let nextId = 1;
+let hideTimer: ReturnType | null = null;
+
+function notify() {
+ for (const l of listeners) l();
+}
+
+function subscribe(l: Listener): () => void {
+ listeners.add(l);
+ return () => {
+ listeners.delete(l);
+ };
+}
+
+function getSnapshot(): Toast | null {
+ return current;
+}
+
+/** Shows message in the pill, replacing any message on screen and restarting the timer. */
+export function showReloadToast(message: string): void {
+ if (hideTimer) clearTimeout(hideTimer);
+ current = { id: nextId++, message };
+ notify();
+ hideTimer = setTimeout(() => {
+ hideTimer = null;
+ current = null;
+ notify();
+ }, VISIBLE_MS);
+}
+
+/** Removes the pill and its timer. */
+function clearToast(): void {
+ if (hideTimer) clearTimeout(hideTimer);
+ hideTimer = null;
+ current = null;
+ notify();
+}
+
+/** Mount-once host for the reload confirmation pill. A message never outlives the host. */
+export function ReloadToast() {
+ const toast = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ useEffect(() => clearToast, []);
+ return (
+
+ {toast && (
+ // key restarts the fade when a new message replaces the old one.
+
+ {toast.message}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/SignaturesView.tsx b/frontend/src/components/SignaturesView.tsx
index c7aca33..3062b9c 100644
--- a/frontend/src/components/SignaturesView.tsx
+++ b/frontend/src/components/SignaturesView.tsx
@@ -10,6 +10,7 @@
import { useEffect, useState } from 'react';
import { GetSignatures } from '../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js';
import { extractErrorMessage } from '../lib/extractErrorMessage';
+import { FILE_BUSY_MESSAGE, FILE_CHANGED_MESSAGE, isFileBusyMessage, isFileChangedMessage } from '../lib/fileChange';
import { SMALL_BUTTON } from './buttonStyles';
/** One decomposed X.509 certificate, mirroring `pdfcore.CertInfo`. */
@@ -48,6 +49,8 @@ export interface SignatureEntryData {
trailingGap?: number;
holeMatchesContents?: boolean;
coverageError?: string;
+ /** Set when the hole could not be read from disk (file changed or busy). */
+ holeCheckError?: string;
}
/** Props for {@link SignaturesView}. */
@@ -66,6 +69,13 @@ export interface SignaturesViewProps {
data?: SignatureEntryData[] | null;
}
+/** UI text for a hole that could not be read from disk. */
+function holeCheckMessage(raw: string): string {
+ if (isFileChangedMessage(raw)) return FILE_CHANGED_MESSAGE;
+ if (isFileBusyMessage(raw)) return FILE_BUSY_MESSAGE;
+ return raw;
+}
+
/** Extracts the CN component from an X.509 DN string, falling back to the
* full DN. Card rows show the CN; the expanded chain shows full DNs. */
function cnOf(dn: string): string {
@@ -199,7 +209,8 @@ function SignatureCard({ entry, onNavigate }: { entry: SignatureEntryData; onNav
/>
>
) : null}
diff --git a/frontend/src/components/StaleNote.tsx b/frontend/src/components/StaleNote.tsx
new file mode 100644
index 0000000..b6207c7
--- /dev/null
+++ b/frontend/src/components/StaleNote.tsx
@@ -0,0 +1,60 @@
+/**
+ * @file Muted note in the detail panel's inner-tab row for an active tab whose
+ * file changed on disk or went missing. The only clickable surface of the
+ * on-disk change indicator. Subscribes to its own tab's disk status, so a
+ * status update re-renders the note and nothing else.
+ */
+import { useDiskStatus } from '../lib/diskStatusStore';
+import { formatTimestamp } from '../lib/formatTimestamp';
+import { FILE_MISSING_MESSAGE } from '../lib/fileChange';
+
+/** Props for {@link StaleNote}. */
+export interface StaleNoteProps {
+ /** Active document tab id. */
+ tabId: string | null;
+ /** Reloads the active tab from disk. */
+ onReload: () => void;
+ /** Compares the active tab with the file on disk. */
+ onCompare: () => void;
+}
+
+const NOTE_BUTTON =
+ 'flex-shrink-0 min-h-[24px] min-w-[24px] px-1.5 text-xs text-text-secondary rounded hover:bg-surface-hover hover:text-text cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-border-focus';
+
+/**
+ * Shows `Document updated` with Reload and Compare for a changed file, or
+ * `File not found` with no buttons for a missing one; renders nothing while
+ * the file is current. The label truncates first; the buttons stay whole.
+ */
+export function StaleNote({ tabId, onReload, onCompare }: StaleNoteProps) {
+ const status = useDiskStatus(tabId);
+ if (!status) return null;
+ if (status.state === 'missing') {
+ return (
+
+
+ File not found
+
+
+ );
+ }
+ // Computed on render so "today" is judged against the clock now.
+ const tooltip = `Showing version from ${formatTimestamp(status.loadedModTime)}. Changed on disk ${formatTimestamp(status.diskModTime)}.`;
+ return (
+
+
+ Document updated
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TabBar.diskStatus.test.tsx b/frontend/src/components/TabBar.diskStatus.test.tsx
new file mode 100644
index 0000000..2269cbe
--- /dev/null
+++ b/frontend/src/components/TabBar.diskStatus.test.tsx
@@ -0,0 +1,221 @@
+/**
+ * Tab dot for a file changed on disk: an indicator only. It sits in a fixed
+ * slot between the file name and the close button that is reserved whether or
+ * not the dot shows, is hidden from assistive tech (the tab's accessible name
+ * and tooltip carry the text), and a click on it activates the tab like a
+ * click anywhere else on the tab.
+ *
+ * Test ids: tab-disk-slot- (always rendered), tab-disk-dot-
+ * (only while the tab is changed or missing).
+ *
+ * Run: cd frontend && npx vitest run src/components/TabBar.diskStatus.test.tsx
+ */
+import { render, screen, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
+import type { Dispatch } from 'react';
+import { AppProvider, useAppDispatch, type AppAction } from '../hooks/useDocumentState';
+import { applyDiskStatusEvent, resetDiskStatusStore } from '../lib/diskStatusStore';
+import { TabBar } from './TabBar';
+
+vi.mock('@wailsio/runtime', () => ({
+ Events: {
+ On: vi.fn(() => vi.fn()),
+ Emit: vi.fn(),
+ },
+}));
+
+vi.mock('../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js', () => ({
+ OpenFile: vi.fn(),
+ GetTreeRoot: vi.fn(),
+ GetChildren: vi.fn(),
+ CloseDocument: vi.fn().mockResolvedValue(undefined),
+ OpenFileDialog: vi.fn(),
+ GetDiskStatus: vi.fn(),
+}));
+
+const catalogNode = {
+ id: 'root',
+ label: 'Catalog',
+ rawKey: '',
+ nodeType: 'dict',
+ valueType: '',
+ hasChildren: true,
+ childCount: 1,
+ iconHint: 'catalog',
+ error: '',
+};
+
+let dispatch: Dispatch;
+function Grab() {
+ dispatch = useAppDispatch();
+ return null;
+}
+
+function mount() {
+ render(
+
+
+
+ ,
+ );
+}
+
+function open(tabId: string, filePath: string) {
+ act(() =>
+ dispatch({
+ type: 'OPEN_DOCUMENT',
+ payload: { tabId, fileName: filePath.split('/').pop() ?? '', filePath, pageCount: 1, rootNode: catalogNode, rootChildren: [] },
+ }),
+ );
+}
+
+// 2026-01-12, local time.
+const today = (h: number, m: number) => new Date(2026, 0, 12, h, m).getTime();
+
+function mark(tabId: string, seq: number, state: 'changed' | 'missing' | 'current') {
+ act(() =>
+ applyDiskStatusEvent({
+ tabId,
+ seq,
+ state,
+ loadedModTime: today(14, 0),
+ diskModTime: state === 'missing' ? 0 : today(14, 5),
+ diskSize: state === 'missing' ? 0 : 2048,
+ }),
+ );
+}
+
+const originalPlatform = Object.getOwnPropertyDescriptor(window.navigator, 'platform');
+
+beforeEach(() => {
+ resetDiskStatusStore();
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 0, 12, 18, 0));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ if (originalPlatform) Object.defineProperty(window.navigator, 'platform', originalPlatform);
+});
+
+describe('tab dot', () => {
+ test('a stale active tab and a stale background tab both show the dot', () => {
+ mount();
+ open('a1', '/docs/a.pdf');
+ open('b1', '/docs/b.pdf');
+ mark('a1', 1, 'changed');
+ mark('b1', 1, 'missing');
+ expect(screen.getByTestId('tab-disk-dot-a1')).toBeInTheDocument();
+ expect(screen.getByTestId('tab-disk-dot-b1')).toBeInTheDocument();
+ });
+
+ test('a tab that is current shows no dot but keeps its slot', () => {
+ mount();
+ open('a1', '/docs/a.pdf');
+ expect(screen.queryByTestId('tab-disk-dot-a1')).not.toBeInTheDocument();
+ expect(screen.getByTestId('tab-disk-slot-a1')).toBeInTheDocument();
+ });
+
+ test('the dot is aria-hidden, not focusable and not a control', () => {
+ mount();
+ open('a1', '/docs/a.pdf');
+ mark('a1', 1, 'changed');
+ const dot = screen.getByTestId('tab-disk-dot-a1');
+ expect(dot).toHaveAttribute('aria-hidden', 'true');
+ expect(dot.tagName).toBe('SPAN');
+ expect(dot).not.toHaveAttribute('tabindex');
+ expect(dot).not.toHaveAttribute('role');
+ expect(dot.querySelector('button, a, [tabindex]')).toBeNull();
+ });
+
+ test('clicking the dot activates its tab like clicking the label', async () => {
+ const user = userEvent.setup();
+ mount();
+ open('a1', '/docs/a.pdf');
+ open('b1', '/docs/b.pdf');
+ mark('a1', 1, 'changed');
+ expect(screen.getByTestId('tab-a1')).toHaveAttribute('aria-selected', 'false');
+
+ await user.click(screen.getByTestId('tab-disk-dot-a1'));
+
+ expect(screen.getByTestId('tab-a1')).toHaveAttribute('aria-selected', 'true');
+ });
+
+ test('the slot sits between the file name and the close button with the same classes stale or not', () => {
+ mount();
+ open('a1', '/docs/a.pdf');
+ const slotBefore = screen.getByTestId('tab-disk-slot-a1').className;
+ const closeBefore = screen.getByTestId('tab-close-a1').className;
+
+ mark('a1', 1, 'changed');
+
+ const slot = screen.getByTestId('tab-disk-slot-a1');
+ const close = screen.getByTestId('tab-close-a1');
+ const name = screen.getByText('a.pdf', { selector: 'span' });
+ expect(slot.className).toBe(slotBefore);
+ expect(close.className).toBe(closeBefore);
+ expect(name.compareDocumentPosition(slot) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(slot.compareDocumentPosition(close) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(slot.contains(close)).toBe(false);
+ expect(slot.contains(screen.getByTestId('tab-disk-dot-a1'))).toBe(true);
+ });
+
+ test('the dot goes away when the tab returns to current', () => {
+ mount();
+ open('a1', '/docs/a.pdf');
+ mark('a1', 1, 'changed');
+ mark('a1', 2, 'current');
+ expect(screen.queryByTestId('tab-disk-dot-a1')).not.toBeInTheDocument();
+ });
+});
+
+describe('tab tooltip and accessible name', () => {
+ test('changed: tooltip adds the disk time and the Ctrl+R hint off macOS', () => {
+ Object.defineProperty(window.navigator, 'platform', { value: 'Win32', configurable: true });
+ mount();
+ open('a1', '/docs/a.pdf');
+ mark('a1', 1, 'changed');
+ expect(screen.getByTestId('tab-a1')).toHaveAttribute(
+ 'title',
+ '/docs/a.pdf\nDocument updated on disk 14:05 - press Ctrl+R to reload',
+ );
+ });
+
+ test('changed: tooltip says Cmd+R on macOS', () => {
+ Object.defineProperty(window.navigator, 'platform', { value: 'MacIntel', configurable: true });
+ mount();
+ open('a1', '/docs/a.pdf');
+ mark('a1', 1, 'changed');
+ expect(screen.getByTestId('tab-a1').getAttribute('title')).toContain('press Cmd+R to reload');
+ });
+
+ test('missing: tooltip says the file is no longer found', () => {
+ mount();
+ open('a1', '/docs/a.pdf');
+ mark('a1', 1, 'missing');
+ expect(screen.getByTestId('tab-a1')).toHaveAttribute('title', '/docs/a.pdf\nFile no longer found at this path');
+ });
+
+ test('current: tooltip is the file path alone', () => {
+ mount();
+ open('a1', '/docs/a.pdf');
+ expect(screen.getByTestId('tab-a1')).toHaveAttribute('title', '/docs/a.pdf');
+ });
+
+ test('accessible name carries the change through visually hidden text', () => {
+ mount();
+ open('a1', '/docs/a.pdf');
+ open('b1', '/docs/b.pdf');
+ mark('a1', 1, 'changed');
+ mark('b1', 1, 'missing');
+ expect(screen.getByRole('tab', { name: /a\.pdf, changed on disk/ })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /b\.pdf, file not found/ })).toBeInTheDocument();
+ });
+
+ test('accessible name has no change text for a current tab', () => {
+ mount();
+ open('a1', '/docs/a.pdf');
+ expect(screen.getByRole('tab', { name: /a\.pdf/ }).textContent).not.toMatch(/changed on disk|file not found/);
+ });
+});
diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx
index d99fd4e..538a77a 100644
--- a/frontend/src/components/TabBar.tsx
+++ b/frontend/src/components/TabBar.tsx
@@ -6,8 +6,12 @@
import { useEffect, useCallback } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { Events } from '@wailsio/runtime';
-import { useAppState, useAppDispatch } from '../hooks/useDocumentState';
+import { useAppState, useAppDispatch, type TabState } from '../hooks/useDocumentState';
import { closeDocumentTab } from '../lib/openedDocuments';
+import { useDiskStatus, type DiskStatusEntry } from '../lib/diskStatusStore';
+import { formatTimestamp } from '../lib/formatTimestamp';
+import { FILE_MISSING_MESSAGE } from '../lib/fileChange';
+import { getShortcutHint } from '../lib/platform';
/** Tab bar displaying all open documents with switch and close controls. */
export function TabBar() {
@@ -99,28 +103,69 @@ export function TabBar() {
data-testid="tab-list"
>
{tabs.map((tab) => (
-
-
- {tab.fileName}
-
-
-
+
))}
);
}
+
+/** Second tooltip line for a tab whose file changed or went missing on disk. */
+function diskStatusLine(status: DiskStatusEntry): string {
+ if (status.state === 'missing') return FILE_MISSING_MESSAGE;
+ return `Document updated on disk ${formatTimestamp(status.diskModTime)} - press ${getShortcutHint('R')} to reload`;
+}
+
+interface TabItemProps {
+ tab: TabState;
+ onClose: (tabId: string, e: React.MouseEvent) => void;
+}
+
+/**
+ * One tab trigger. Subscribes to its own tab's disk status only, so a status
+ * update re-renders this tab and nothing else. The dot is an indicator: it is
+ * aria-hidden, has no handler of its own and sits in a fixed slot that is
+ * reserved whether or not it shows, so tab widths never change.
+ */
+function TabItem({ tab, onClose }: TabItemProps) {
+ const status = useDiskStatus(tab.tabId);
+ const path = tab.filePath || tab.fileName;
+ const title = status ? `${path}\n${diskStatusLine(status)}` : path;
+ const srText = status ? (status.state === 'missing' ? ', file not found' : ', changed on disk') : '';
+ return (
+
+