New tool: Scratchpad - #14
Merged
Merged
Conversation
There was a problem hiding this comment.
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+ScratchpadControllerfor per-note JSON persistence (one file per note), debounced background saves, and flush-on-close/termination behavior. - Adds SwiftUI/AppKit UI (
ScratchpadPopoverView+NSTextViewbridge) 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.
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>
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
force-pushed
the
feat/scratchpad
branch
from
July 18, 2026 10:59
3c37578 to
7b13f84
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ClipboardStoreshape: debounced off the main actor, write generation claimed inside a serial queue, atomic writes, and flushes on panel close andapplicationWillTerminate. 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.jsonfile 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)
makeFirstResponder, but it is a UI behaviour change that cannot be verified headlessly, so it was left rather than changed blind.🤖 Generated with Claude Code