From 1a12eefaf8cfac0d9b718c291249738875c81108 Mon Sep 17 00:00:00 2001 From: spongycode Date: Sun, 23 Aug 2026 23:08:32 +0530 Subject: [PATCH 1/3] clap add/in, and launch-at-login fix --- ARCHITECTURE.md | 1 + README.md | 2 + Scripts/make_app.sh | 13 +- Sources/ClapApp/Panel.swift | 9 + Sources/ClapApp/PreviewPanel.swift | 179 ++++++++++++------ Sources/ClapApp/RowViews.swift | 5 - Sources/ClapApp/SettingsView.swift | 11 +- Sources/ClapApp/SlideoutController.swift | 3 - Sources/ClapCLIKit/CLISupport.swift | 1 + Sources/ClapCLIKit/ClapCLI.swift | 8 +- Sources/ClapCLIKit/Commands/AddCommand.swift | 66 +++++++ .../ClapCLIKit/Commands/CaptureCommand.swift | 27 --- 12 files changed, 222 insertions(+), 103 deletions(-) create mode 100644 Sources/ClapCLIKit/Commands/AddCommand.swift delete mode 100644 Sources/ClapCLIKit/Commands/CaptureCommand.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9d3ad94..872a99b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -322,6 +322,7 @@ clap list [--images] [--limit N] [--offset N] clap search [--regex ] [--type text|image] [--limit N] clap get clap copy +clap add | - insert entry (- reads stdin); alias: clap in clap delete | --text | --regex clap out [ | ] alias of delete clap pin / clap unpin diff --git a/README.md b/README.md index 5682041..8250c2a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ clap search [--type text|image|shell] # Full-text search clap search --regex "^docker.*" # Regex search clap get # Show entry (pipe-friendly raw output) clap copy # Copy entry to pasteboard +clap add | - # Insert an entry (alias: clap in; use - for stdin) +echo "piped" | clap add - # Insert from a pipe clap delete | --text | --regex # Delete entries (alias: clap out) clap pin / clap unpin # Pin/unpin entries clap clear [--force] # Wipe history (preserves pinned) diff --git a/Scripts/make_app.sh b/Scripts/make_app.sh index 829cb1c..6c058b7 100755 --- a/Scripts/make_app.sh +++ b/Scripts/make_app.sh @@ -21,8 +21,17 @@ if [ -d "$ROOT/Resources" ]; then cp -R "$ROOT/Resources/"* "$APP/Contents/Resources/" 2>/dev/null || true fi -# Ad-hoc sign so the hotkey/app behave under Gatekeeper locally. -codesign --force --deep -s - --identifier "com.spongycode.clap" "$APP" +# Sign with the first available Apple Development identity so macOS +# services that require stable signing (e.g. SMAppService login items) +# accept the bundle; ad-hoc only as last resort. +SIGN_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \ + | awk '/Apple Development/ { print $2; exit }')" +if [ -n "$SIGN_IDENTITY" ]; then + echo "Signing with identity $SIGN_IDENTITY" + codesign --force --deep -s "$SIGN_IDENTITY" --identifier "com.spongycode.clap" "$APP" +else + codesign --force --deep -s - --identifier "com.spongycode.clap" "$APP" +fi mkdir -p "$OUT/bin" cp "$BIN/clap" "$OUT/bin/clap" diff --git a/Sources/ClapApp/Panel.swift b/Sources/ClapApp/Panel.swift index d55b320..864c77f 100644 --- a/Sources/ClapApp/Panel.swift +++ b/Sources/ClapApp/Panel.swift @@ -214,6 +214,15 @@ final class PanelController: NSObject, NSWindowDelegate { rememberCurrentFrame() } + func windowDidResize(_ notification: Notification) { + // Live-sync while dragging an edge with the preview open; the list + // absorbs the delta and the pane keeps its width. + guard appState.slideout.state == .open else { return } + appState.slideout.contentWidth = max( + appState.slideout.minimumContentWidth, + panel.frame.width - appState.slideout.slideoutWidth) + } + func windowDidEndLiveResize(_ notification: Notification) { let width = panel.frame.width let slideout = appState.slideout diff --git a/Sources/ClapApp/PreviewPanel.swift b/Sources/ClapApp/PreviewPanel.swift index e8b572f..6818795 100644 --- a/Sources/ClapApp/PreviewPanel.swift +++ b/Sources/ClapApp/PreviewPanel.swift @@ -155,7 +155,6 @@ struct PreviewView: View { let entry: ClipboardEntry @State private var image: NSImage? - @State private var idCopied = false @State private var parsed: ParsedEntryContent = .empty var body: some View { @@ -242,67 +241,66 @@ struct PreviewView: View { Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 6) { GridRow { metaLabel("Actions") - HStack(spacing: 8) { + HStack(spacing: 6) { if entry.type == .image, let ocrText = entry.content, !ocrText.isEmpty { - Button { + IconActionButton(systemImage: "doc.text.viewfinder", + help: "Copy extracted text (OCR)") { state.copyTransformedText(ocrText) - } label: { - Label("Copy Text", systemImage: "doc.text.viewfinder") - .font(.system(size: 11)) } - .buttonStyle(.bordered) - .controlSize(.small) - .help("Copy recognized OCR text from this image") } + if entry.type == .text || entry.type == .shell, - let content = entry.content, content.count <= TextTransformer.maxTransformLength { - Menu { - TransformMenuContent(content: content) { transformed in - state.copyTransformedText(transformed) + let content = entry.content, + content.count <= TextTransformer.maxTransformLength { + IconMenu(systemImage: "textformat") { + ForEach(CaseConverter.CaseStyle.allCases) { style in + Button(style.rawValue) { + state.copyTransformedText( + CaseConverter.convert(content, to: style)) + } } - } label: { - Label("Copy as…", systemImage: "textformat") - .font(.system(size: 11)) } - .menuStyle(.button) - .controlSize(.small) - .help("Convert text case or encode/decode and copy directly to clipboard") + .help("Copy as… camelCase, snake_case, kebab-case, UPPER, lower…") + .accessibilityLabel("Copy as different text case") + + IconMenu(systemImage: "chevron.left.forwardslash.chevron.right") { + Button("Base64 Encode") { + state.copyTransformedText(TextTransformer.encodeBase64(content)) + } + if let decoded = TextTransformer.decodeBase64(content) { + Button("Base64 Decode") { + state.copyTransformedText(decoded) + } + } + Divider() + Button("URL Encode") { + state.copyTransformedText(TextTransformer.encodeURL(content)) + } + if let decoded = TextTransformer.decodeURL(content) { + Button("URL Decode") { + state.copyTransformedText(decoded) + } + } + } + .help("Copy Base64- or URL-encoded / decoded text") + .accessibilityLabel("Copy encoded or decoded text") } if entry.type == .text || entry.type == .shell { - Button { + IconActionButton(systemImage: entry.shortcut != nil ? "bolt.fill" : "bolt", + help: entry.shortcut != nil + ? "Snippet shortcut: \(entry.shortcut ?? "")" + : "Assign a snippet abbreviation (e.g. ;email)") { state.promptSetShortcut(entry) - } label: { - Label(entry.shortcut ?? "Shortcut", - systemImage: entry.shortcut != nil ? "keyboard.fill" : "keyboard") - .font(.system(size: 11)) } - .buttonStyle(.bordered) - .controlSize(.small) - .help("Assign or edit a text abbreviation (e.g. ;email) that auto-expands this snippet") } - Button { + IconActionButton(systemImage: entry.tags.isEmpty ? "tag" : "tag.fill", + help: entry.tags.isEmpty + ? "Add tags" + : "Tags: \(entry.tags.joined(separator: ", "))") { state.promptManageTags(entry) - } label: { - Label(entry.tags.isEmpty ? "Tags" : "\(entry.tags.count) Tags", - systemImage: entry.tags.isEmpty ? "tag" : "tag.fill") - .font(.system(size: 11)) } - .buttonStyle(.bordered) - .controlSize(.small) - .help("Manage tags and custom pinboards for this entry") - - Button { - copyID() - } label: { - Label(idCopied ? "Copied" : "Copy ID", - systemImage: idCopied ? "checkmark" : "doc.on.doc") - .font(.system(size: 11)) - } - .buttonStyle(.bordered) - .controlSize(.small) - .help("Copy the numeric ID for CLI use, e.g. clap get \(entry.id)") } } GridRow { @@ -337,7 +335,19 @@ struct PreviewView: View { metaLabel(entry.type == .shell ? "Times run" : "Times used") Text(String(entry.useCount)).font(.system(size: 12)) } - if let app = entry.sourceApp { + if entry.type == .shell { + GridRow { + metaLabel("From") + HStack(spacing: 6) { + // Shell rows have no source bundle id; Terminal.app's + // icon is the honest stand-in. + AppIconView(bundleID: "com.apple.Terminal", size: 14) + Text(Self.shellSourceName(entry.sourceApp)) + .font(.system(size: 12)) + .help(entry.sourceApp ?? "shell") + } + } + } else if let app = entry.sourceApp { GridRow { metaLabel("From") HStack(spacing: 6) { @@ -416,20 +426,6 @@ struct PreviewView: View { .foregroundStyle(.secondary) } - /// Copies the numeric id, marked transient so the pasteboard monitor - /// doesn't record the id string as a new history entry. - private func copyID() { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(String(entry.id), forType: .string) - pasteboard.setString("", forType: NSPasteboard.PasteboardType("org.nspasteboard.TransientType")) - idCopied = true - Task { @MainActor in - try? await Task.sleep(nanoseconds: Timing.copiedResetNanos) - idCopied = false - } - } - private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -437,6 +433,14 @@ struct PreviewView: View { return formatter }() + /// ".zsh_history" -> "zsh history"; empty/unknown -> "Terminal". + static func shellSourceName(_ rawSource: String?) -> String { + var name = (rawSource ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if name.hasPrefix(".") { name.removeFirst() } + if name.hasSuffix("_history") { name.removeLast("_history".count) } + return name.isEmpty ? "Terminal" : "\(name) history" + } + static func appDisplayName(bundleID: String) -> String { guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { return bundleID @@ -808,3 +812,56 @@ private struct ImageContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + +// MARK: - Actions-row icon buttons + +/// Circular hover-highlighting icon button used across the Actions row. +private struct IconActionButton: View { + let systemImage: String + let help: String + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 12)) + .symbolRenderingMode(.monochrome) + .foregroundStyle(isHovered ? Color.primary : Color.secondary) + .frame(width: 26, height: 26) + .background( + Circle() + .fill(isHovered ? Color.primary.opacity(AppAlpha.Hover.fill) : Color.clear) + ) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help(help) + .accessibilityLabel(help) + } +} + +/// Dropdown variant of `IconActionButton`. Hover must be tracked on the +/// Menu itself — SwiftUI never delivers onHover to a Menu's label content. +private struct IconMenu: View { + let systemImage: String + @ViewBuilder var items: () -> MenuItems + + var body: some View { + Menu { + items() + } label: { + Image(systemName: systemImage) + .font(.system(size: 12)) + .symbolRenderingMode(.monochrome) + .foregroundStyle(.secondary) + .frame(width: 26, height: 26) + .contentShape(Circle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } +} diff --git a/Sources/ClapApp/RowViews.swift b/Sources/ClapApp/RowViews.swift index 75adae7..7778369 100644 --- a/Sources/ClapApp/RowViews.swift +++ b/Sources/ClapApp/RowViews.swift @@ -87,11 +87,6 @@ struct EntryRow: View { ThumbnailView(entry: entry) .frame(width: 44, height: 30) .clipShape(RoundedRectangle(cornerRadius: 4)) - } else if entry.type == .shell { - Image(systemName: "terminal") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - .frame(width: 20) } else if let content = entry.content, content.count <= 100, let parsed = ColorParser.parse(content) { RoundedRectangle(cornerRadius: 4, style: .continuous) .fill(Color(red: parsed.red, green: parsed.green, blue: parsed.blue, diff --git a/Sources/ClapApp/SettingsView.swift b/Sources/ClapApp/SettingsView.swift index 075a9d2..b81ae39 100644 --- a/Sources/ClapApp/SettingsView.swift +++ b/Sources/ClapApp/SettingsView.swift @@ -342,7 +342,7 @@ struct SettingsView: View { paused = await configString(ConfigKey.monitoringPaused) == "1" snippetsEnabled = await configString(ConfigKey.snippetsEnabled) != "0" pasteOnCopy = await configString(ConfigKey.pasteOnCopy) != "0" - launchAtLogin = await configString(ConfigKey.launchAtLogin) == "1" + launchAtLogin = SMAppService.mainApp.status == .enabled if let raw = await configString(ConfigKey.exclusions), let data = raw.data(using: .utf8), let array = try? JSONDecoder().decode([String].self, from: data) { @@ -385,8 +385,13 @@ struct SettingsView: View { launchError = nil save(ConfigKey.launchAtLogin, enabled ? "1" : "0") } catch { - // Typical in dev/unbundled builds where SMAppService is unavailable. - launchError = "Launch at login is unavailable: \(error.localizedDescription)" + // Ad-hoc builds are rejected by BTM. The app is signed with an + // Apple Development identity by make_app.sh when available, so + // this path should be rare — guide manual registration if hit. + launchError = """ + Launch at login couldn't be registered (\(error.localizedDescription)). \ + Add clap manually: System Settings › General › Login Items & Extensions. + """ suppressLoginToggle = true launchAtLogin = !enabled Task { suppressLoginToggle = false } diff --git a/Sources/ClapApp/SlideoutController.swift b/Sources/ClapApp/SlideoutController.swift index bda2331..1fa115e 100644 --- a/Sources/ClapApp/SlideoutController.swift +++ b/Sources/ClapApp/SlideoutController.swift @@ -49,7 +49,6 @@ public final class SlideoutController: ObservableObject { public weak var window: NSWindow? private var windowAnimationOrigin: CGPoint? - private var windowAnimationOriginBaseState: SlideoutState = .closed private var autoOpenTask: Task? public var autoOpenDelayMs: Int = 1000 @@ -94,7 +93,6 @@ public final class SlideoutController: ObservableObject { if animated { windowAnimationOrigin = window.frame.origin - windowAnimationOriginBaseState = state withAnimation(.easeInOut(duration: Self.animationDuration)) { state = .opening @@ -136,7 +134,6 @@ public final class SlideoutController: ObservableObject { if animated { windowAnimationOrigin = window.frame.origin - windowAnimationOriginBaseState = state withAnimation(.easeInOut(duration: Self.animationDuration)) { state = .closing diff --git a/Sources/ClapCLIKit/CLISupport.swift b/Sources/ClapCLIKit/CLISupport.swift index 2527fdb..1685128 100644 --- a/Sources/ClapCLIKit/CLISupport.swift +++ b/Sources/ClapCLIKit/CLISupport.swift @@ -35,6 +35,7 @@ enum CLI { } static var stdoutIsTTY: Bool { isatty(1) == 1 } + static var stdinIsTTY: Bool { isatty(0) == 1 } /// Runs a throwing async body, mapping errors to exit codes. /// Invalid regex -> exit 2; anything else -> exit 1. Never a stack trace. diff --git a/Sources/ClapCLIKit/ClapCLI.swift b/Sources/ClapCLIKit/ClapCLI.swift index 617af3c..acb1871 100644 --- a/Sources/ClapCLIKit/ClapCLI.swift +++ b/Sources/ClapCLIKit/ClapCLI.swift @@ -15,6 +15,8 @@ public enum ClapCLI { "search": { await SearchCommand.run($0, context: $1) }, "get": { await GetCommand.run($0, context: $1) }, "copy": { await CopyCommand.run($0, context: $1) }, + "add": { await AddCommand.run($0, context: $1) }, + "in": { await AddCommand.run($0, context: $1) }, "delete": { await DeleteCommand.run($0, context: $1) }, "out": { await DeleteCommand.runOutAlias($0, context: $1) }, "pin": { await PinCommand.run($0, pinned: true, context: $1) }, @@ -28,8 +30,8 @@ public enum ClapCLI { "import": { await ImportCommand.run($0, context: $1) }, "pause": { await PauseCommand.run($0, paused: true, context: $1) }, "resume": { await PauseCommand.run($0, paused: false, context: $1) }, - // Hidden: seeds the store for testing/scripting. Not in help. - "_capture": { await CaptureCommand.run($0, context: $1) }, + // Hidden: legacy spelling of `add` for existing scripts. Not in help. + "_capture": { await AddCommand.run($0, context: $1, legacyOutput: true) }, // Hidden: runs eviction/retention/vacuum like the app's workers. "_maintain": { await MaintainCommand.run($0, context: $1) } ] @@ -99,6 +101,8 @@ enum HelpText { clap get [--json] clap copy clap delete | --text | --regex + clap add | - Insert an entry (use - to read stdin) + clap in Alias for clap add clap out [ | ] Alias for clap delete clap pin / clap unpin clap tag add / clap tag remove diff --git a/Sources/ClapCLIKit/Commands/AddCommand.swift b/Sources/ClapCLIKit/Commands/AddCommand.swift new file mode 100644 index 0000000..9dbaeda --- /dev/null +++ b/Sources/ClapCLIKit/Commands/AddCommand.swift @@ -0,0 +1,66 @@ +import Foundation +import Darwin +import ClapCore + +/// `clap add ` — insert a clipboard history entry directly. +/// Counterpart of `clap out` (delete). Also accepts piped input: +/// `echo hi | clap add -`. The hidden `_capture` spelling is kept as an +/// alias for existing scripts. +enum AddCommand { + static let usage = """ + Usage: clap add [-] + echo | clap add - + clap in (alias of add) + + Inserts an entry into clipboard history exactly as if it had been copied. + Deduplicates like normal capture: re-adding known text bumps its recency. + + Options: + - Read the text from standard input instead of arguments + """ + + /// `legacyOutput` preserves the hidden `_capture` script-facing format. + static func run(_ args: [String], context: CLIContext, legacyOutput: Bool = false) async { + let parsed = ArgParser.parse(args, + boolFlags: ["-"], + usage: usage) + + var text = parsed.positionals.joined(separator: " ") + if parsed.has("-") && CLI.stdinIsTTY { + CLI.usageError("add - reads standard input, but stdin is a terminal; pipe text instead", + usage: usage) + } + if parsed.has("-") || (!CLI.stdinIsTTY && text.isEmpty) { + guard !CLI.stdinIsTTY else { + CLI.usageError("add requires text arguments or piped input (-)", usage: usage) + } + let data = FileHandle.standardInput.readDataToEndOfFile() + guard let decoded = String(data: data, encoding: .utf8), + !decoded.isEmpty else { + CLI.usageError("add: standard input contained no valid UTF-8 text", + usage: usage) + } + text = decoded + } + guard !text.isEmpty else { + CLI.usageError("add requires text", usage: usage) + } + + let result = await CLI.run { + let store = try context.makeStore() + return try await store.captureText(text, sourceApp: nil) + } + guard let result else { + CLI.fail("nothing to capture (empty after normalization)") + } + Notify.storeChanged() + + if legacyOutput { + print("captured id=\(result.entry.id) duplicate=\(result.wasDuplicate)") + } else if result.wasDuplicate { + print("Entry \(result.entry.id) already exists — bumped to top.") + } else { + print("Added entry \(result.entry.id).") + } + } +} diff --git a/Sources/ClapCLIKit/Commands/CaptureCommand.swift b/Sources/ClapCLIKit/Commands/CaptureCommand.swift deleted file mode 100644 index 7ddd0b8..0000000 --- a/Sources/ClapCLIKit/Commands/CaptureCommand.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import ClapCore - -/// Hidden command: `clap _capture `. Seeds the store the same way the -/// app's pasteboard monitor would. Undocumented (not in help); used for -/// testing and scripting. -enum CaptureCommand { - static let usage = "Usage: clap _capture " - - static func run(_ args: [String], context: CLIContext) async { - let parsed = ArgParser.parse(args, usage: usage) - let text = parsed.positionals.joined(separator: " ") - guard !text.isEmpty else { - CLI.usageError("_capture requires text", usage: usage) - } - - let result = await CLI.run { - let store = try context.makeStore() - return try await store.captureText(text, sourceApp: nil) - } - guard let result else { - CLI.fail("nothing to capture (empty after normalization)") - } - Notify.storeChanged() - print("captured id=\(result.entry.id) duplicate=\(result.wasDuplicate)") - } -} From fbf2c7ae56749285d69d6a3fda43c05dc950247e Mon Sep 17 00:00:00 2001 From: spongycode Date: Mon, 24 Aug 2026 12:04:11 +0530 Subject: [PATCH 2/3] fix window resize center slider --- Sources/ClapApp/Panel.swift | 5 +-- Sources/ClapApp/SlideoutController.swift | 26 +++++++++++ Sources/ClapApp/SlideoutView.swift | 55 ++++++++---------------- Sources/ClapApp/WidthReader.swift | 33 ++++++++++++++ 4 files changed, 78 insertions(+), 41 deletions(-) create mode 100644 Sources/ClapApp/WidthReader.swift diff --git a/Sources/ClapApp/Panel.swift b/Sources/ClapApp/Panel.swift index 864c77f..a864fdb 100644 --- a/Sources/ClapApp/Panel.swift +++ b/Sources/ClapApp/Panel.swift @@ -75,6 +75,7 @@ final class PanelController: NSObject, NSWindowDelegate { guard let self else { return } let hasEntry = (newID != nil) if hasEntry { + self.appState.slideout.cancelCloseIfNoSelection() if self.appState.slideout.state.isOpen { // Already open: stays open, preview content updates live } else if self.panel.isVisible { @@ -82,9 +83,7 @@ final class PanelController: NSObject, NSWindowDelegate { } } else { self.appState.slideout.cancelAutoOpen() - if self.appState.slideout.state.isOpen { - self.appState.slideout.closePreview(animated: self.panel.isVisible) - } + self.appState.slideout.scheduleCloseIfNoSelection() } } diff --git a/Sources/ClapApp/SlideoutController.swift b/Sources/ClapApp/SlideoutController.swift index 1fa115e..4a32dfc 100644 --- a/Sources/ClapApp/SlideoutController.swift +++ b/Sources/ClapApp/SlideoutController.swift @@ -43,6 +43,12 @@ public final class SlideoutController: ObservableObject { @Published public var contentWidth: CGFloat = 480 @Published public var slideoutWidth: CGFloat = 360 + + // Last RENDERED widths, written continuously by readWidth in + // SlideoutView. Divider drag-end resets to these so the settled layout + // always matches what is actually on screen. + var contentResizeWidth: CGFloat = 0 + var slideoutResizeWidth: CGFloat = 0 @Published public var placement: SlideoutPlacement = .right @Published public var state: SlideoutState = .closed @@ -50,6 +56,7 @@ public final class SlideoutController: ObservableObject { private var windowAnimationOrigin: CGPoint? private var autoOpenTask: Task? + private var closeIfEmptyTask: Task? public var autoOpenDelayMs: Int = 1000 public init() {} @@ -74,6 +81,25 @@ public final class SlideoutController: ObservableObject { autoOpenTask = nil } + /// Tab switches momentarily clear the selection before the new tab's + /// first row is auto-selected. Close only if NO selection arrives within + /// the grace period (i.e. the tab is genuinely empty) — otherwise the + /// pane stays open and its content updates in place. + public func scheduleCloseIfNoSelection(delayMs: Int = 250) { + closeIfEmptyTask?.cancel() + closeIfEmptyTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + guard !Task.isCancelled else { return } + guard let self, self.state.isOpen else { return } + self.closePreview(animated: true) + } + } + + public func cancelCloseIfNoSelection() { + closeIfEmptyTask?.cancel() + closeIfEmptyTask = nil + } + public func computePlacement(window: NSWindow, for size: NSSize) -> SlideoutPlacement { guard let screen = window.screen?.visibleFrame else { return placement } let windowFrame = window.frame diff --git a/Sources/ClapApp/SlideoutView.swift b/Sources/ClapApp/SlideoutView.swift index b3db0ac..2c792d8 100644 --- a/Sources/ClapApp/SlideoutView.swift +++ b/Sources/ClapApp/SlideoutView.swift @@ -36,10 +36,6 @@ public struct SlideoutView: View { self.slideout = slideout } - @State private var dragStartContentWidth: CGFloat? - @State private var dragStartSlideoutWidth: CGFloat? - @State private var isDraggingDivider = false - private var leftToRight: Bool { controller.placement == .right } @@ -47,13 +43,14 @@ public struct SlideoutView: View { @ViewBuilder private func resizeDivider() -> some View { Divider() - .overlay(Color.primary.opacity(AppAlpha.Stroke.hairline)) + .padding(.vertical, 4) .padding(.horizontal, 6) + // macOS 26 broke gestures when no background is present; the + // near-invisible background is the workaround. .background(Color.white.opacity(0.001)) - .contentShape(Rectangle()) .onHover { inside in if let window = controller.window { - window.isMovableByWindowBackground = !inside && !isDraggingDivider + window.isMovableByWindowBackground = !inside } if inside { if #available(macOS 15.0, *) { @@ -61,45 +58,25 @@ public struct SlideoutView: View { } else { NSCursor.resizeLeftRight.push() } - } else if !isDraggingDivider { + } else { NSCursor.pop() } } .gesture( - DragGesture(minimumDistance: 1) + DragGesture() .onChanged { value in - if dragStartContentWidth == nil { - isDraggingDivider = true - dragStartContentWidth = controller.contentWidth - dragStartSlideoutWidth = controller.slideoutWidth - if let window = controller.window { - window.isMovableByWindowBackground = false - } + if let window = controller.window { + controller.slideoutWidth = min( + max(controller.minimumSlideoutWidth, + controller.slideoutResizeWidth + + (leftToRight ? -1 : 1) * value.translation.width), + window.frame.width - controller.minimumContentWidth) + controller.contentWidth = window.frame.width - controller.slideoutWidth } - guard let startContent = dragStartContentWidth, - let startSlideout = dragStartSlideoutWidth else { return } - - let total = startContent + startSlideout - let delta = (leftToRight ? 1 : -1) * value.translation.width - let rawContent = (startContent + delta).rounded() - - let minContent = controller.minimumContentWidth - let maxContent = max(minContent, total - controller.minimumSlideoutWidth) - - let clampedContent = min(maxContent, max(minContent, rawContent)).rounded() - let clampedSlideout = max(controller.minimumSlideoutWidth, total - clampedContent).rounded() - - controller.contentWidth = clampedContent - controller.slideoutWidth = clampedSlideout } .onEnded { _ in - isDraggingDivider = false - dragStartContentWidth = nil - dragStartSlideoutWidth = nil - NSCursor.pop() - if let window = controller.window { - window.isMovableByWindowBackground = true - } + controller.slideoutWidth = controller.slideoutResizeWidth + controller.contentWidth = controller.contentResizeWidth } ) .disabled(controller.state != .open) @@ -121,6 +98,7 @@ public struct SlideoutView: View { ) .frame(width: controller.contentWidth.rounded()) .fixedSize(horizontal: controller.state.isAnimating, vertical: false) + .readWidth(controller, into: \.contentResizeWidth) // Draggable Divider between list and slideout preview resizeDivider() @@ -142,6 +120,7 @@ public struct SlideoutView: View { } .environment(\.layoutDirection, .leftToRight) .fixedSize(horizontal: controller.state.isAnimating, vertical: false) + .readWidth(controller, into: \.slideoutResizeWidth) .frame( minWidth: controller.state != .open ? 0 : nil, maxWidth: controller.state == .closed ? 0 : nil diff --git a/Sources/ClapApp/WidthReader.swift b/Sources/ClapApp/WidthReader.swift new file mode 100644 index 0000000..6588e41 --- /dev/null +++ b/Sources/ClapApp/WidthReader.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// Measures a view's rendered width and writes it back. Used by +/// SlideoutView's divider so drag end-states reset to actually-rendered +/// widths. +struct SizeReaderModifier: ViewModifier { + @Binding var value: Value + let mapper: (CGSize) -> Value + + func body(content: Content) -> some View { + content.onGeometryChange(for: Value.self) { proxy in + mapper(proxy.size) + } action: { newValue in + value = newValue + } + } +} + +extension View { + func readWidth( + _ state: SlideoutController, + into keyPath: ReferenceWritableKeyPath + ) -> some View { + readWidth(Binding( + get: { state[keyPath: keyPath] }, + set: { state[keyPath: keyPath] = $0 } + )) + } + + func readWidth(_ value: Binding) -> some View { + modifier(SizeReaderModifier(value: value, mapper: \.width)) + } +} From e3b9d3ec81ed8589251f526408da00e57f5d2ae9 Mon Sep 17 00:00:00 2001 From: spongycode Date: Mon, 24 Aug 2026 14:18:16 +0530 Subject: [PATCH 3/3] add bump and release workflow --- .github/workflows/release-trigger.yml | 87 +++++++++++++++++++++++++++ Scripts/bump_version.sh | 37 ++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 .github/workflows/release-trigger.yml create mode 100755 Scripts/bump_version.sh diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml new file mode 100644 index 0000000..a64b248 --- /dev/null +++ b/.github/workflows/release-trigger.yml @@ -0,0 +1,87 @@ +name: Release Trigger + +# UI-driven releases: pick a bump type, this workflow bumps the version in +# the codebase, commits it, pushes a vX.Y.Z tag — and the tag push runs the +# existing `Release` workflow, which builds, attaches assets, and publishes +# the release with auto-generated notes. + +on: + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump' + type: choice + required: true + default: 'patch' + options: + - major + - minor + - patch + - none + +permissions: + contents: write + +jobs: + bump-and-tag: + name: Bump version & push tag + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Configure git (github-actions bot) + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Compute new version + id: version + run: | + chmod +x Scripts/bump_version.sh + NEW_VERSION="$(Scripts/bump_version.sh "${{ inputs.bump_type }}")" + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing v$NEW_VERSION (bump: ${{ inputs.bump_type }})" + + - name: Guard — tag must not already exist + run: | + if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then + echo "::error::Tag ${{ steps.version.outputs.tag }} already exists. Use bump type 'none' only for re-releases of a version whose tag was deleted." + exit 1 + fi + + - name: Commit version bump + run: | + git add Sources/ClapCLIKit/ClapCLI.swift \ + Sources/ClapApp/SettingsView.swift \ + Scripts/Info.plist + if git diff --cached --quiet; then + if [ "${{ inputs.bump_type }}" != "none" ]; then + echo "::error::Version bump changed nothing — unexpected." + exit 1 + fi + echo "No version change for bump type 'none'." + else + git commit -m "Bump version to ${{ steps.version.outputs.tag }}" + fi + + - name: Push commit and tag + run: | + git push origin "HEAD:${{ github.ref_name }}" + git tag "${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + + - name: Summary + run: | + { + echo "## Release triggered 🚀" + echo "- Tag: \`${{ steps.version.outputs.tag }}\`" + echo "- Bump: \`${{ inputs.bump_type }}\`" + echo "- The \`Release\` workflow is now building assets; the GitHub" + echo " release will appear with auto-generated notes when it finishes." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Scripts/bump_version.sh b/Scripts/bump_version.sh new file mode 100755 index 0000000..24c028a --- /dev/null +++ b/Scripts/bump_version.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Bumps the release version across every file that stores it and prints the +# new version. Usage: Scripts/bump_version.sh +set -euo pipefail + +BUMP="${1:-}" +if [[ ! "$BUMP" =~ ^(major|minor|patch|none)$ ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +CLI_FILE="Sources/ClapCLIKit/ClapCLI.swift" +CURRENT="$(sed -n 's/^ public static let version = "\([0-9]*\.[0-9]*\.[0-9]*\)"/\1/p' "$CLI_FILE")" +if [[ -z "$CURRENT" ]]; then + echo "error: could not read current version from $CLI_FILE" >&2 + exit 1 +fi + +IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" +case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + none) ;; +esac +NEW="$MAJOR.$MINOR.$PATCH" + +if [[ "$NEW" == "$CURRENT" && "$BUMP" != "none" ]]; then + echo "error: version unchanged" >&2 + exit 1 +fi + +sed -i '' "s/public static let version = \"$CURRENT\"/public static let version = \"$NEW\"/" "$CLI_FILE" +sed -i '' "s/clipboard & shell history manager · v$CURRENT/clipboard \& shell history manager · v$NEW/" \ + Sources/ClapApp/SettingsView.swift +sed -i '' "s/$CURRENT<\/string>/$NEW<\/string>/" Scripts/Info.plist +echo "$NEW"