Skip to content

New tool: Scratchpad - #14

Merged
havokentity merged 8 commits into
mainfrom
feat/scratchpad
Jul 21, 2026
Merged

New tool: Scratchpad#14
havokentity merged 8 commits into
mainfrom
feat/scratchpad

Conversation

@havokentity

Copy link
Copy Markdown
Owner

New menu-bar tool: an always-there plain-text notepad with multiple named notes, live word/character count, and copy-all/clear.

Not losing your notes

That is the only unacceptable failure mode, so persistence follows the corrected ClipboardStore shape: debounced off the main actor, write generation claimed inside a serial queue, atomic writes, and flushes on panel close and applicationWillTerminate. Each note is its own file, so one corrupt note cannot take the others down.

The adversarial review caught a data-multiplication bug: salvage fell back to UUID(uuidString: filename) ?? UUID(), so any .json file whose basename was not a UUID got a fresh identity on every load — the recovered note was rewritten under a new id each time the store opened, multiplying it on disk. Fixed, with a regression test.

Character counting uses Character, not UTF-16 units, so emoji and combining marks count correctly.

Tests: 428 → 475, all passing. Persistence tests use a per-test temp directory, never the real Application Support.

Reviewer notes (left for you to decide)

  • The editor does not take first responder when the panel opens — you open it and start typing and nothing happens until you click into the text area. For a notepad this is a real usability gap and it is a one-line makeFirstResponder, but it is a UI behaviour change that cannot be verified headlessly, so it was left rather than changed blind.
  • A note file whose bytes are not valid UTF-8 is skipped and left on disk, but vanishes from the switcher with no explanation. Worth a status message eventually.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a new “Scratchpad” menu-bar helper app and the core storage/controller/UI needed to support an always-available plain-text notepad with multiple notes, live counts, and basic actions, integrated into the existing Toolbox catalog and packaging flow.

Changes:

  • Introduces ScratchpadKit + ScratchpadController for per-note JSON persistence (one file per note), debounced background saves, and flush-on-close/termination behavior.
  • Adds SwiftUI/AppKit UI (ScratchpadPopoverView + NSTextView bridge) including note switcher, rename, counts, and copy/clear/delete actions.
  • Adds comprehensive test coverage for counting, naming, persistence, and corruption/salvage scenarios; wires the new helper into Package.swift, packaging, docs, and changelog.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Tests/DMonteCoreTests/ScratchpadKitTests.swift New tests covering counts, naming, per-file persistence, and corrupt-file recovery behavior.
Tests/DMonteCoreTests/ScratchpadControllerTests.swift New controller-level tests validating autosave/flush, selection restore, clear+undo, delete confirmation, and failure reporting.
Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift New helper app delegate hosting the popover and forcing flush on close and termination.
Sources/DMonteScratchpadApp/main.swift New helper app entrypoint with single-instance guard and --open distributed-notification behavior.
Sources/DMonteCore/ToolboxCatalog.swift Registers the new Scratchpad tool in the toolbox catalog.
Sources/DMonteCore/ScratchpadView.swift Implements the Scratchpad popover UI and an NSTextView-backed editor bridge.
Sources/DMonteCore/ScratchpadSizing.swift Adds sizing/scale heuristics for the Scratchpad panel.
Sources/DMonteCore/ScratchpadKit.swift Adds pure storage + text measurement utilities (sanitize/dedup titles, counts, per-note file IO, salvage).
Sources/DMonteCore/ScratchpadController.swift Adds main-actor controller coordinating notes, debounced saves, flushes, and user actions; includes serialized writer.
Sources/DMonteCore/AppPreferences.swift Registers default font size for Scratchpad.
Scripts/package_app.sh Adds the Scratchpad helper to the packaging list.
README.md Documents Scratchpad as a new tool.
Packaging/ScratchpadInfo.plist Adds helper app Info.plist metadata for packaging/distribution.
Package.swift Adds a new executable target/product for DMonteScratchpad.
CHANGELOG.md Notes the Scratchpad addition under Unreleased.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Sources/DMonteCore/ScratchpadKit.swift
Comment thread Sources/DMonteCore/ScratchpadController.swift
havokentity added a commit that referenced this pull request Jul 18, 2026
1. prepareDirectory(_:) reported success for any filesystem item at the
   store path, so a regular file squatting there would make the controller
   believe storage was ready while every enumeration and write below it
   failed — silently dropping the only copy of the user's notes. It now
   requires the path to exist AND be a directory. Test added for the
   plain-file-at-the-directory-path case.

2. ScratchpadNoteWriter.write encoded the note before checking whether the
   generation was stale, paying a full JSON encode for writes that were
   going to be dropped (superseded snapshots, and anything for a note whose
   generation was retired by delete). Added a cheap pre-check that reads the
   recorded generation inside the serial queue and returns early; the
   authoritative claim-and-write check stays where it was. Recorded
   generations only move forward, so a snapshot stale at the pre-check is
   still stale at the authoritative one — the shortcut can never drop a
   write that was needed. Test added covering stale-drop and post-delete
   resurrection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
havokentity and others added 3 commits July 18, 2026 16:27
New helper tool (DMonteScratchpad, com.havokentity.mactools.scratchpad): a
plain-text notepad in the menu bar with multiple named notes, a live
word/character/line readout, copy-all, and an undoable clear.

Losing typed text is the only unacceptable failure for a notepad, so the
persistence is layered rather than trusted to one mechanism. Each note is
its own atomically written JSON file in Application Support, so a bad write
can cost at most the note it belonged to instead of taking the whole set
with it; a file that no longer parses is salvaged into a "Recovered Note"
under its original id, and bytes that are not text at all are skipped and
deliberately left on disk rather than deleted. Saves follow the corrected
ClipboardStore shape: debounced off the main actor, with the write
generation claimed inside a serial queue so an older large snapshot cannot
land after a newer one, plus forced flushes when the panel closes and at
applicationWillTerminate — the debounced tasks are detached and die with
the process, so without those the last 400ms of typing would never reach
disk. Deleting a note retires its generation permanently so an in-flight
write cannot recreate the file behind the user.

Clear stashes the removed body per note for the session, so the undo
survives switching to another note and back; it is withdrawn once the user
types again, since restoring at that point would clobber the new text.
Deleting a note takes two presses instead of an alert, matching the
suite's no-alerts rule.

The editor is NSTextView via NSViewRepresentable rather than TextEditor:
TextEditor round-trips the whole document through its binding on every
keystroke, which is visible lag on a long note, and NSTextView brings
native undo and correct Unicode handling with it. Smart quote/dash/
replacement substitution is off — a scratchpad is where people park URLs
and snippets that must stay verbatim. Counting is over Characters, not
UTF-16 units, so a ZWJ family emoji reads as one character; the count runs
on a short debounce off the main actor so a very large note does not make
typing sticky.

Pure storage, naming/dedup and counting live in ScratchpadKit, covered by
ScratchpadKitTests plus ScratchpadControllerTests, both against
per-test temp directories and an isolated defaults suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Salvage no longer invents an identity for a .json file the tool did not
write. It used to fall back to a fresh UUID, which meant such a file was
recovered under a new id on every launch while the original stayed on
disk — the note count climbed forever and nothing in the UI could clear
it. Unidentifiable files are now left alone, like undecodable bytes.

A save that never reached disk is now surfaced on the status line instead
of being discarded. ScratchpadNoteWriter.write returns whether the bytes
landed (a superseded snapshot still counts as success), and all three
save paths report a failure. A full volume or an uncreatable store
directory previously left the user typing into a note that was not being
persisted, with no indication until after they quit.

Typing now disarms an armed-but-unconfirmed delete. The arm otherwise
outlived a whole editing session and a panel close, so a single stray
press much later destroyed the note outright and the two-press
confirmation was protecting nothing.

Tests: 471 -> 475.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. prepareDirectory(_:) reported success for any filesystem item at the
   store path, so a regular file squatting there would make the controller
   believe storage was ready while every enumeration and write below it
   failed — silently dropping the only copy of the user's notes. It now
   requires the path to exist AND be a directory. Test added for the
   plain-file-at-the-directory-path case.

2. ScratchpadNoteWriter.write encoded the note before checking whether the
   generation was stale, paying a full JSON encode for writes that were
   going to be dropped (superseded snapshots, and anything for a note whose
   generation was retired by delete). Added a cheap pre-check that reads the
   recorded generation inside the serial queue and returns early; the
   authoritative claim-and-write check stays where it was. Recorded
   generations only move forward, so a snapshot stale at the pre-check is
   still stale at the authoritative one — the shortcut can never drop a
   write that was needed. Test added covering stale-drop and post-delete
   resurrection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@havokentity
havokentity changed the base branch from fix/deep-review-2026-07 to main July 18, 2026 11:23
havokentity and others added 5 commits July 21, 2026 00:14
PreferencesOverlay centres the settings sheet over the panel, so a sheet
wider than the panel is clipped equally on both sides. The panel's size
goes through ScratchpadSizing.currentScale (menu bar thickness / 26,
screen height / 950, clamped to 0.82...1.0) but the sheet's was a literal
320 x 290, so the two only agreed at scale 1.

On this machine the menu bar is 22pt, giving scale 0.846: the panel comes
out 322 x 457 while the sheet stayed 320 x 290. That fit, but by 2pt of
width — 1pt of margin per side. Any change to either base number, or a
Mac reporting a slightly thinner menu bar, would have pushed it over and
started clipping the title's first and last characters.

Sheet size now lives in ScratchpadSizing.settingsSize(), scaled like
everything else and capped at the panel width so it cannot overflow at
any scale. At 0.846 the sheet is 271 x 245 inside a 322 x 457 panel,
51pt of width to spare. Putting the size in the enum also makes the
invariant testable, which a literal in a SwiftUI modifier was not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first version of this fix scaled the sheet by `currentScale` alongside the
panel. That is right where the sheet is as wide as its panel, but wrong where
it is narrower: Scratchpad's 320pt sheet sits in a 380pt panel, so scaling
shrank it to 271 inside a 322 panel — 51pt of inset added to a tool that was
never clipped. The sheet's contents are laid out with unscaled padding, so
squeezing the frame risks clipping inside the sheet rather than outside it.

The requirement is only that the sheet never exceeds the panel, so cap it:
`min(designSize, panelSize)`. Identical for the four tools whose sheet matches
or exceeds its panel, and strictly better for the two where it does not —
Scratchpad keeps its designed 320 and Network Info gains 23pt back.

The tests that broke were the ones restating the arithmetic; the ones asserting
"the sheet fits the panel" passed through the change untouched. Replaced the
former with the actual contract: the sheet uses its design size unless the
panel is smaller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both tools shipped with `note.text` in the tray, the toolbox tile and the
panel header, so the two menu-bar items were indistinguishable and the grid
showed the same tile twice. Scratchpad takes `square.and.pencil` — a page you
write on, against Snippets' page of stored lines. Verified the symbol resolves
on this OS: an unresolvable name renders as a blank menu-bar item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The earlier cap-at-panel-width fix was incomplete. PreferencesOverlay wraps the
sheet in 18pt of padding on every side, so a sheet sized to the full panel
becomes panel+36 once padded and overflows. That over-wide overlay layer then
dragged the panel content behind it off both edges — the header's title and
gear spilled past the window and the footer pushed below it — reproduced and
fixed by eye on the real NSHostingController path.

settingsSize now caps at panel minus 36 (2×18), so the padded sheet fits the
panel exactly and the content behind it stays put.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	CHANGELOG.md
#	Package.swift
#	README.md
#	Scripts/package_app.sh
#	Sources/DMonteCore/AppPreferences.swift
#	Sources/DMonteCore/ToolboxCatalog.swift
@havokentity
havokentity merged commit 446f079 into main Jul 21, 2026
1 check passed
@havokentity
havokentity deleted the feat/scratchpad branch July 21, 2026 06:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants