Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions cmd/cli/cli_open.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 15 additions & 10 deletions cmd/cli/cmd_bytes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -46,6 +50,9 @@ func execBytesDump(filePath string, f docViewFlags) (exitCode int) {
return code
}
defer func() { _ = ins.Close("cli") }()
if afterCLIOpen != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the --json branch goes through a fingerprint-checked read. The default raw path further down (os.Open(filePath) + io.Copy, line 78) streams the file with no check. If the file is rewritten after openForCLI, which is exactly what this hook simulates, execBytesDump(path, docViewFlags{}) exits 0 and prints the new bytes, while --json exits 2 with "file changed on disk". The description says dump bytes passes ErrFileChanged through, but that holds only for --json. TestCLIFileChanged* only covers json: true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d4132b7. The raw path now goes through a new Inspector.CopyDocumentFile, which checks the handle against the Open fingerprint before the first byte and after the last, and maps a lock violation to ErrFileBusy. Raw dump bytes on a rewritten file exits 2 with "file changed on disk" and writes nothing (TestCLIFileChangedBytesRaw).

afterCLIOpen(filePath)
}

if f.json {
// Only the --json path needs the decoded payload. GetPlainText performs a
Expand All @@ -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
}
Expand Down
10 changes: 8 additions & 2 deletions cmd/cli/cmd_signatures.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)")
}
}
Expand Down
138 changes: 138 additions & 0 deletions cmd/cli/file_change_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions frontend/src/App.drain.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions frontend/src/App.geometry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading