Repository navigation
Integration for 1.3.0 - #104
Merged
Merged
Conversation
Plays the system "Tink" sound when a clip is copied from the bar via copyItem(_:), kept audibly distinct from the existing "Play sound on paste" toggle, which plays "Pop" from PasteService.paste(_:). Both sound toggles ship disabled: an update should not introduce a new audible behavior existing users never asked for, and two sibling settings with opposite defaults would read as an oversight.
The Keep History section gains a "Currently storing" row: clip count plus the store directory's actual size (history JSON and saved images), walked off the main actor when the pane opens so a large image library can't hitch the Settings window.
The size was measured once when the pane appeared, so clearing history left the old number next to "0 clips" until the window was rebuilt. It is now re-measured after every store save. The bytes cover history and pinboards, since pinned clips keep their own image files, so the row is labeled that way and the clip count includes pinned clips. Based on #91 by @alvst.
The footer's "Image" and "1 file" told you nothing you couldn't already see from the card itself. Image clips now read their pixel dimensions - "1920 × 1080" - and file clips say which files they actually point at. What a file clip's footer says depends on what the file is. An image already shows what it is by sitting on the card, so its dimensions are the useful fact - a screenshot's path is a timestamped folder nobody reads. Everything else gets its full location instead, abbreviated with "~", because two documents of the same name differ only by where they live. Multi-file clips list every filename rather than a bare "N files" count, which named none of them. Whether a file is an image is decided from its path extension, never by opening it: the footer is evaluated on every render, so it must not touch disk. A path is worth reading in full, so the meta line wraps to three lines for file clips rather than collapsing to an ellipsis; every other type stays on one line. The footer row aligns on its bottom edge so the quick-paste hint stays put as the path grows. Dimensions come from the image file's metadata via CGImageSource rather than from decoding it, and are cached by path: the footer asks on every render, so a decode per card would be paid over and over for a number that never changes. Clips whose image file is missing or unreadable fall back to the old "Image" label, and an image file whose size can't be read falls back to its filename.
ImagePixelSize cached only successful reads, so a card whose image file is gone asked ImageIO for it again on every render. Misses are cached too, and the cache is emptied once it passes 512 entries. abbreviatingWithTildeInPath goes through NSHomeDirectory, which in the sandboxed App Store build is the container, so paths never shortened there. The home now comes from getpwuid, which a sandboxed probe showed is the only one of NSHomeDirectory, homeDirectoryForCurrentUser and NSHomeDirectoryForUser(NSUserName()) that returns /Users/<name>. Based on #95 by @alvst.
Replaces the "remove clips older than" day-choice Picker with a 10-stop slider (1 Day through Forever) matching Alvie's Pesty's granularity, via a new HistoryRetentionPreset enum. Adds three new stops baseline didn't have (3 Weeks, 2 Months, Forever) - Forever is represented as historyRetentionDays == 0, meaning "no automatic pruning by age", with only the existing safety cap still applying. Keeps the confirm-before-apply destructive-change dialog baseline already had; only the presentation of the choice changes, not the safety UX around it. The slider itself needed a fix unrelated to its content: on macOS, Form(.formStyle(.grouped)) lays out every row in a Section's body on a shared label/control NSGridView, which clamps and right-shifts any bare control placed there regardless of SwiftUI-side frame modifiers. Moving the slider block into the Section's footer (plain full-width content, never part of that grid) fixes it - see the comment at the call site for how this was confirmed.
The slider wrote the draft at every stop it crossed, and each write ran the removal check: dragging from 1 Year toward 1 Day committed 6 Months on the way past it, then put up the confirm alert for 3 Months while the mouse was still down, and Cancel restored 6 Months instead of 1 Year. The slider now moves a local value and applies it once editing ends (or on a keyboard step), so nothing is committed during a drag and Cancel returns to the value the drag started from. The stop under the thumb is still shown live. retentionCutoff returns nil for 0 days instead of silently treating it as 1, so Forever cannot regress through a new caller. Based on #89 by @alvst.
A "Clip Navigation" section in Settings chooses where the selected clip parks in the strip: centered, as before, or at the right edge the way Paste does. The bar's scroll-to-selection reads the preference for its anchor and re-scrolls when the preference changes while a clip is selected.
A "Clip Colors" section in Settings offers three palettes for the card headers: the per-source-app palette cards have always used, a boosted version of it with higher saturation and contrast, and Accent Shades, which derives ten deterministic shades from one user-picked base color (FNV-1a of the bundle identifier picks the shade, so an app keeps its shade across launches) with a live swatch preview. The boosted palette is the default here.
The themes commit made the boosted palette the default, which would have recolored every card on update. Default is again the palette cards have always used, the boosted treatment is an opt-in theme named Vibrant, and Accent Shades stays opt-in, so the user-visible default is unchanged. The HSB conversion behind Vibrant ran per card per render. The twelve boosted colors are now computed once per palette slot, which is what a bundle id maps to, and Accent Shades reads the base color's HSB once per chosen color. Based on #94 by @alvst.
Image clips, and file clips that point at one image file, draw the picture edge to edge over a transparency checkerboard instead of a padded thumbnail. The bitmap no longer comes from NSImage(contentsOf:) inside the view body: ClipPreviewProvider decodes the stored PNG downsampled off the main actor, asks Quick Look for image files, and keeps the results in an NSCache bounded by count and byte cost. The header of a multi-file clip reads "5 files" rather than "File". Other file clips show the file's Finder icon, resolved in the card's task and cached, with a "File not found" caption when stat fails with ENOENT. The caption is never shown in the sandboxed App Store build, where access to a copied file does not survive a relaunch and the stat fails for files that are still there. The full-bleed treatment, the checkerboard view and the file-count header come from #96 by @alvst. Fixes #102.
- Add a Copied to Clipboard toast, shown after every successful copy (Command-C and the context menu's Copy action both go through copyItem). - Promote the copied clip to the front of history, since copying an existing clip back to the pasteboard is a deliberate re-use of it and the clipboard monitor correctly ignores Pesty's own pasteboard writes.
promoteCopiedItem went through addCaptured, which inserts the clip it is handed whenever history has no same-content twin. Copying a Pinboard card whose history twin had been deleted or trimmed therefore put the pinboard's own UUID into history, an identity the two containers are never meant to share: the App Store sync keys records by UUID and lets the pinboard record win, so that history copy would never upload, and a later remote apply of the pinboard record would evict it. With no twin, history now receives an independent copy, minted the same way saveToPinboard mints one, with its own UUID and image file. The "Copied to Clipboard" toast shows only when the pasteboard actually changed; PasteService.copy leaves it untouched for an image whose file is missing. Command-C requires exactly Command, so Command-Shift-C and the other combinations fall through as before. Based on #82 by @alvst.
Two new context-menu paste modes alongside Paste as Plain Text, enabled whenever a clip has rich content. Clean Formatting keeps bold, italic, underline, strikethrough, and links while normalizing fonts, sizes, and colors to the system default. Markdown converts the rich content to Markdown text, splitting style markers across line breaks so the output stays valid, and rendering links as [text](url). The clipboard monitor now also captures a copy's HTML flavor - browser copies often carry HTML with no RTF - without changing how clips are classified. Conversions prefer HTML, then RTF, then fall back to plain text. Paste plumbing moves from an asPlainText flag to a PasteFormat enum carrying all four modes. The HTML importer behind the Clean Formatting and Markdown conversions is WebKit-backed: handed raw captured HTML, it fetches remote subresources - tracking pixels included - synchronously on the main thread at paste time, breaking the no-network-calls promise. Strip every element that can reference an external resource (img, picture, source, iframe, object, embed, link, script, style, svg, video, audio, and CSS url() values) before conversion; the converters only keep text plus bold/italic/underline/links, so nothing representable is lost. A 2-second importer timeout backs the stripping up, and sources over 1MB skip rich conversion entirely, falling back to plain text so a pathological clip can't hitch the paste. Capture-side, cap stored pasteboard HTML at the sync layer's inline payload bound (CKSchema.inlineLimit) so every routine text copy can't persist an unbounded HTML blob; oversized HTML is dropped and the conversions fall back to RTF/plain text as before.
The regex stripping that ran before the HTML importer rewrote visible
text along with markup: every url(...) in the clip's own words, a
copied CSS snippet or the URL("https://...") call in a code sample,
pasted as "none" through Clean Formatting and Markdown. It also guarded
against something the importer does not do. NSAttributedString(html:)
loads no subresources at all: a local HTTP server logged no request for
an unsanitized img, input type=image, body background, image-set() or
meta refresh. The HTML now reaches the importer as captured; the 2 s
document timeout and the 1 MB conversion cap stay.
Editing a clip replaced its text and RTF but kept the captured HTML,
which the converters prefer, so Paste as Markdown after an edit
produced the pre-edit words. Both edit paths clear it.
HTML is captured only when the pasteboard carries no RTF, the browser
case the conversions need it for, and only up to 64 KB: store.json is
re-encoded and rewritten after every capture, and Safari copies already
carry RTF. The independent copies made for Pinboards and for history
promotions carry the HTML along.
Based on #93 by @alvst.
The previous search UI was a synthetic pill built from captured keystrokes — appended-only, no cursor, no click-to-edit, no text selection or IME support. NativeBarSearchField is a real NSTextField edited through the normal AppKit responder chain (kept mounted even in its compact zero-width state, so the bar's key monitor can focus it synchronously and hand off the very first keystroke without losing it). BarInputMode tracks whether focus belongs to the field or the clip strip, so the monitor and the field agree on who owns a given key without probing the first-responder chain from every call site.
The field took every key while it had focus. Return resigned it and handed focus back to the cards instead of pasting, so the open, type, Return flow needed two Returns; Command-1 to Command-9 went to the field editor, which has no binding for them; and Up, Down, Left and Right moved a caret inside a one-line query instead of the selection. Return now pastes the selected clip from the field's delegate, the quick-paste digits are handled before the field takes the event, and the delegate routes Up and Down, plus Left and Right once the caret sits at the matching end of the query, to the strip's selection. A hide while the field was focused (Escape on an empty query, the hotkey, a click outside) left it first responder of the ordered-out panel, so the next presentation came up in search mode: pill expanded, first Return swallowed, arrows moving the caret. showBar resigns the field and resets the input mode before anything else. Two comments described another project's version of this file and are gone. Based on #85 by @alvst.
- BarResizeGeometry centralizes bar-height sizing rules (default/min/max, per-display clamping, and the macOS 26 glass content frame math) shared by both the persisted preference and the live drag session. - BarResizeHandle is a compact AppKit drag target (with accessibility increment/decrement support) shown in an optional overlay at the top of the bar. - A new "Show resize handle on the Paste Bar" Settings toggle gates it, off by default. The bar's own key monitor steps aside while the handle owns first responder, so Escape/arrows during a drag reach it directly. The height slider in Settings previews the same size. Clicking into Settings dismisses the bar, so a pixel count was previously the only feedback for a dimension that is only meaningful on screen. Dragging the slider now live-resizes the real bar when it happens to be up, and otherwise raises BarHeightGhostController: a translucent, click-through panel occupying exactly the frame the bar would take on the display under the pointer, drawing a faded real BarView behind a dashed outline and a large "N px" readout, and retiring itself about 1.1s after the last change. Showing the bar hides any outline still lingering. The bar-height slider's unit label reads px instead of pt.
The Settings slider previewed its value with a second panel: a click-through window at modal-panel level carrying a faded live BarView, a dashed outline and a readout, raised on every slider stop. On a laptop display it covers the Settings window it is meant to serve, and it was the one part of the change the new toggle did not gate. The panel, the slider wiring and the live resize it drove are gone; the handle on the bar stays, off by default behind its toggle. The slider label reads pt again: the value is in points (430 pt is 860 px on a Retina display), which the first of the two commits had right. After a drag the handle restored the previous first responder and kept the keys when that failed, leaving the bar's key monitor stepping aside for good; the window itself now takes first responder when the restore is refused. Escape is named by its Carbon constant. Based on #87 by @alvst.
A deleted clip, or every clip of a bulk delete together, can be put back with Command-Z for five minutes, at the position it came from: after its former predecessor if that clip is still there, else before its successor, else at its old index. An Undo button sits in the bar's top right while anything is restorable, the bulk-delete alert says so instead of "There is no undo", and a clip that came back by other means in the meantime, copied again or synced in, is left alone rather than doubled. The stack lives in memory only. Deleted content is never written back to store.json, there are no tombstones, and the iCloud Drive merge, the CloudKit sync and remote applies are untouched. Image files of restorable clips stay on disk and are removed when the window closes, on Clear History, when a Pinboard is deleted, or at quit, the four moments the stack empties. A "Delete permanently" setting, off by default, skips the stack, and holding Option while pressing Backspace, Delete or Command-Backspace skips it for that one deletion; the Option state comes from the key event itself, and the context menu has no hidden modifier. Designed after #88 by @alvst, whose placement bookkeeping, button and alert copy this keeps.
Pasting can move the clip to the front of history the way copying it from the bar does, behind a "Promote pasted clips to the top of history" toggle that is off by default: history has always been ordered by when a clip was copied, and the quick-paste numbers would shift on every paste. The promotion uses the copy path's guards, so it happens only when the pasteboard actually changed and never puts a Pinboard copy's UUID into history. In the direct-download build a paste that the missing Accessibility grant turns into a plain copy used to end in silence, which reads as a broken paste. The toast now says "Copied. Grant Accessibility in System Settings to paste directly." once per launch, without a modal and without taking focus, and sizes itself to its text. The App Store build never reaches that path and compiles none of it. Both come from #92 by @alvst, whose Command-Delete change and modal alert are left out.
This was referenced Oct 8, 2026
This was referenced Oct 8, 2026
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.
Integration branch for 1.3.0. Every contributor commit is cherry-picked with its author kept; the follow-up commit after each one is the review fix. The original PRs are closed with a pointer to their landing commit once this is in.
From @alvst, with fixes:
Rewritten here after their PRs:
Also: a promote-on-paste option, off by default, and a one-time toast when direct paste is blocked by a missing Accessibility grant.
Tested: swift build and swift test (7 tests) on this branch.