3 Commits
Author SHA1 Message Date
TZGyn c5011d03e7 Let a multi-selection be copied as plain lines as well as a list
Copying several notes only produced a numbered list, which is wrong for
pasting into a prompt that already has structure. The selection now
offers both forms, Shift+click selects a range, and a selection bar
makes it obvious that more than one note is selected. Esc clears the
selection before it hides the panel.
2026-10-09 17:18:04 +08:00
TZGyn 90890186c0 Tell the user when Accessibility is missing
Without it the Shift-Shift gestures fail silently, so hiding the panel
left no way to bring it back and the app looked broken. The app now says
so on launch and points at Cmd+Shift+Space, which needs no permission,
and --diag reports the permission state alongside the status item's
placement so a lockout can be diagnosed instead of guessed at.
2026-10-08 10:45:50 +08:00
TZGyn 962c205d1d Make the app reachable and quittable after the panel closes
Closing the panel only hid it, and launching the app again did nothing
because the running instance ignored the reopen, so it looked like the
app refused to exit. Reopening now shows the panel, and the main menu
makes Cmd+Q and Cmd+W work while the panel has focus. Termination also
tears down the event tap, hotkeys and sync timers instead of leaving
them live.
2026-10-08 10:43:42 +08:00
8 changed files with 280 additions and 37 deletions
+20 -7
View File
@@ -13,19 +13,28 @@ clicking an item copies it back to the clipboard.
`NSStatusItem Preferred Position Prompt`). Left click toggles the panel, `NSStatusItem Preferred Position Prompt`). Left click toggles the panel,
right click shows the menu (accessibility state, launch at login, open data right click shows the menu (accessibility state, launch at login, open data
folder, quit). Contains the single-instance guard and `LoginItem` folder, quit). Contains the single-instance guard and `LoginItem`
(`SMAppService`). (`SMAppService`). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit
(Cmd+Q) — an accessory app shows no menu bar, but the main menu still
services key equivalents, which is the only way to quit from the keyboard
while the panel has focus. `applicationShouldHandleReopen` shows the panel so
launching an already-running app does something. Supports `--sync-check` and
`--diag` (writes the status item's frame and permission state to
`/tmp/prompt-diag.txt`, then exits).
- `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton. - `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton.
Data model: `Section` → `[PromptItem]`, item content is Data model: `Section` → `[PromptItem]`, item content is
`.text(String)` or `.image(String)` (filename). Persists to `.text(String)` or `.image(String)` (filename). Persists to
`~/Library/Application Support/Prompt/store.json`; images are PNG files in `~/Library/Application Support/Prompt/store.json`; images are PNG files in
`Images/` next to it, cached in an `NSCache`. Owns all mutations (add, `Images/` next to it, cached in an `NSCache`. Owns all mutations (add,
toggle done, edit, delete, move, merge, clear done) and clipboard writes toggle done, edit, delete, move, merge, clear done) and clipboard writes
(`copy`, `copyAsList`). Saves on every mutation. (`copy`, `copyAsList` for numbered output, `copyAsLines` for plain newlines;
both text modes skip image items). Saves on every mutation.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher, - `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher,
search, add photos, close), item list, composer. Composer `# Name` creates/ search, add photos, close), item list, composer. Composer `# Name` creates/
switches sections. Click = copy; Cmd+click = multi-select; right-click = switches sections. Click = copy; Cmd+click toggles multi-select, Shift+click
context menu (Copy, Copy as List, Done, Edit, Open, Merge, Move To, Delete). selects a range, and a selection bar offers Copy as List / Copy as Lines;
Drop target for images/files. right-click = context menu (Copy, Copy as List, Copy as Lines, Done, Edit,
Open, Merge, Move To, Delete). Esc clears the selection before it hides the
panel. Drop target for images/files.
- `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel` - `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel`
(`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as (`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as
`PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController` `PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController`
@@ -150,7 +159,8 @@ brew upgrade --cask prompt
- **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete, - **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete,
plain Cmd+C). SwiftUI key equivalents are matched before text input and plain Cmd+C). SwiftUI key equivalents are matched before text input and
would steal characters from the composer TextField. Only Cmd-modified would steal characters from the composer TextField. Only Cmd-modified
shortcuts that TextFields don't use are bound (Cmd+F/K/N, Shift+Cmd+C). shortcuts that TextFields don't use are bound (Cmd+F/K/N, Shift+Cmd+C,
Option+Cmd+C).
This is why Copy has no in-panel shortcut: click-to-copy is the primary This is why Copy has no in-panel shortcut: click-to-copy is the primary
gesture, matching Copper's intent. gesture, matching Copper's intent.
- The panel is a `.nonactivatingPanel`; its `canBecomeKey` override is what - The panel is a `.nonactivatingPanel`; its `canBecomeKey` override is what
@@ -167,7 +177,10 @@ brew upgrade --cask prompt
the main thread (the hotkey handler already dispatches to a global queue). the main thread (the hotkey handler already dispatches to a global queue).
- The double-Shift tap needs Accessibility; without it the event tap silently - The double-Shift tap needs Accessibility; without it the event tap silently
fails and only the Carbon fallbacks work. The panel banner and the status fails and only the Carbon fallbacks work. The panel banner and the status
menu both surface the permission state. menu both surface the permission state, and `AppDelegate` shows a launch
alert when it is missing — otherwise a user who hides the panel is locked out
(no hotkey, no Dock icon) and reports it as "the app won't come back". Check
with `Prompt --diag` (`accessibility: false` is the tell).
- `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is - `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is
user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS
would otherwise block it with a confusing error. would otherwise block it with a confusing error.
+10 -6
View File
@@ -8,8 +8,9 @@ to your work. Click anything to copy it back to your clipboard.
- **Capture from anywhere** — select text in any app and tap **Left Shift twice**. - **Capture from anywhere** — select text in any app and tap **Left Shift twice**.
- **Photos** — paste (Cmd+V), drag & drop, or add via the photo button. - **Photos** — paste (Cmd+V), drag & drop, or add via the photo button.
- **Sections** — type `# Name` in the composer; Cmd+K switches sections. - **Sections** — type `# Name` in the composer; Cmd+K switches sections.
- **Copy as List** — Cmd+click to multi-select, right-click → Copy as List - **Copy as List / Copy as Lines** — Cmd+click to multi-select (Shift+click for
(or Shift+Cmd+C) for a numbered list ready to paste into a chat. a range), then copy the selection as a numbered list or as plain
newline-separated text, from the selection bar or the right-click menu.
- **Merge Notes, Mark as Done, Edit, Move To** — right-click any item. - **Merge Notes, Mark as Done, Edit, Move To** — right-click any item.
- **Search** — Cmd+F searches across all sections. - **Search** — Cmd+F searches across all sections.
- **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and - **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and
@@ -29,13 +30,16 @@ to your work. Click anything to copy it back to your clipboard.
| Focus composer | Cmd+N | | Focus composer | Cmd+N |
| Sync settings | Cmd+, | | Sync settings | Cmd+, |
| Copy selection as numbered list | Shift+Cmd+C | | Copy selection as numbered list | Shift+Cmd+C |
| Copy selection as plain lines | Option+Cmd+C |
| Clear selection | Esc |
| Close panel | Esc | | Close panel | Esc |
Double-Shift gestures require **Accessibility access** (Prompt reads the Double-Shift gestures require **Accessibility access** (Prompt reads the
selection by simulating Cmd+C, then restores your clipboard untouched). The selection by simulating Cmd+C, then restores your clipboard untouched). Until
Cmd+Shift fallbacks work without it. Bare keys like Space and Cmd+C are it is granted, use Cmd+Shift+Space and Cmd+Shift+C, or click the menu bar icon
deliberately not bound inside the panel so they never conflict with typing in — and launching the app again always brings the panel back. Bare keys like
the composer. Space and Cmd+C are deliberately not bound inside the panel so they never
conflict with typing in the composer.
## Install ## Install
+102
View File
@@ -27,6 +27,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return return
} }
// --diag reports where the status item landed. On a notched Mac a
// brand-new item can be placed under the notch and be unclickable,
// which locks the user out once the panel is hidden.
if CommandLine.arguments.contains("--diag") {
setupStatusItem()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.writeDiagnostics()
NSApp.terminate(nil)
}
return
}
// Single instance: stale copies keep their hotkeys alive after an // Single instance: stale copies keep their hotkeys alive after an
// update and eat the events meant for the new instance. // update and eat the events meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier let myPid = ProcessInfo.processInfo.processIdentifier
@@ -47,6 +59,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
HotkeyManager.shared.start() HotkeyManager.shared.start()
setupStatusItem() setupStatusItem()
setupMainMenu()
promptForAccessibilityIfNeeded()
// Show the panel on launch so the app is discoverable (it has no Dock // Show the panel on launch so the app is discoverable (it has no Dock
// icon and the menu bar icon can be hard to spot on a full bar). // icon and the menu bar icon can be hard to spot on a full bar).
@@ -56,6 +70,94 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
/// Without Accessibility the Shift-Shift gestures fail silently, so the
/// panel can be hidden with no obvious way back. Say so on launch and
/// point at the shortcut that works without the permission.
private func promptForAccessibilityIfNeeded() {
guard !AXIsProcessTrusted() else { return }
let alert = NSAlert()
alert.messageText = "Prompt needs Accessibility access"
alert.informativeText = """
Capturing with Shift-Shift (and showing the panel with right Shift twice) \
only works with Accessibility access.
Until it is granted, use Cmd+Shift+Space to show or hide the panel and \
Cmd+Shift+C to capture, or click the menu bar icon.
"""
alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "Later")
if alert.runModal() == .alertFirstButtonReturn {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
}
/// A launched-again app must reveal itself. Without this, `open` (or
/// clicking it in Finder/Spotlight) hits the running instance, which does
/// nothing, so the app looks like it never started.
@MainActor
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
PanelController.shared.show()
return true
}
@MainActor
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
// Stop the poll timer, any queued push, the event tap and the Carbon
// hotkeys so termination cannot be held up by a live callback.
SyncService.shared.stop()
HotkeyManager.shared.stop()
return .terminateNow
}
/// Accessory apps have no visible menu bar, but the main menu still
/// services key equivalents — this is what makes Cmd+Q and Cmd+W work
/// while the panel has focus.
private func setupMainMenu() {
let mainMenu = NSMenu()
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w")
hide.target = self
appMenu.addItem(hide)
appMenu.addItem(.separator())
let quit = NSMenuItem(title: "Quit Prompt", action: #selector(quit), keyEquivalent: "q")
quit.target = self
appMenu.addItem(quit)
NSApp.mainMenu = mainMenu
}
@MainActor @objc private func hidePanel() {
PanelController.shared.hide()
}
@MainActor
private func writeDiagnostics() {
var lines = ["Prompt diagnostics", "accessibility: \(AXIsProcessTrusted())"]
if let window = statusItem?.button?.window {
lines.append("status item frame: \(window.frame)")
if let screen = window.screen ?? NSScreen.main {
lines.append("screen: \(screen.frame) visible: \(screen.visibleFrame)")
lines.append("notch inset: \(screen.safeAreaInsets)")
let underNotch = window.frame.maxX > screen.frame.maxX - screen.safeAreaInsets.right
lines.append("under the notch: \(underNotch)")
}
} else {
lines.append("status item has no window (not in the menu bar)")
}
lines.append("preferred position: \(UserDefaults.standard.object(forKey: "NSStatusItem Preferred Position Prompt") ?? "unset")")
try? lines.joined(separator: "\n").write(
to: URL(fileURLWithPath: "/tmp/prompt-diag.txt"), atomically: true, encoding: .utf8
)
}
private func setupStatusItem() { private func setupStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.autosaveName = "Prompt" item.autosaveName = "Prompt"
+16
View File
@@ -34,6 +34,22 @@ final class HotkeyManager {
registerFallbackHotkeys() registerFallbackHotkeys()
} }
func stop() {
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
CFMachPortInvalidate(tap)
}
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
for ref in hotKeyRefs {
if let ref { UnregisterEventHotKey(ref) }
}
hotKeyRefs.removeAll()
eventTap = nil
runLoopSource = nil
}
// MARK: - Double-Shift event tap // MARK: - Double-Shift event tap
private func startEventTap() { private func startEventTap() {
+2
View File
@@ -31,12 +31,14 @@ final class PanelController: NSObject {
} }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil) panel.makeKeyAndOrderFront(nil)
NSLog("Prompt: panel shown")
NotificationCenter.default.post(name: .promptPanelDidShow, object: nil) NotificationCenter.default.post(name: .promptPanelDidShow, object: nil)
} }
func hide() { func hide() {
panel?.orderOut(nil) panel?.orderOut(nil)
NSApp.deactivate() NSApp.deactivate()
NSLog("Prompt: panel hidden (app keeps running for the Shift-Shift hotkeys)")
} }
private func makePanel() -> KeyablePanel { private func makePanel() -> KeyablePanel {
+96 -14
View File
@@ -13,6 +13,7 @@ struct PanelView: View {
@State private var showSync = false @State private var showSync = false
@State private var joinCode = "" @State private var joinCode = ""
@State private var selection = Set<UUID>() @State private var selection = Set<UUID>()
@State private var lastSelectedID: UUID?
@State private var copiedID: UUID? @State private var copiedID: UUID?
@State private var accessibilityGranted = AXIsProcessTrusted() @State private var accessibilityGranted = AXIsProcessTrusted()
@@ -31,6 +32,9 @@ struct PanelView: View {
Divider() Divider()
content content
Divider() Divider()
if !selection.isEmpty {
selectionBar
}
composerField composerField
} }
.frame(minWidth: 300) .frame(minWidth: 300)
@@ -76,7 +80,8 @@ struct PanelView: View {
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") } Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos") .buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos")
Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") } Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Close (Esc)") .buttonStyle(.plain).foregroundStyle(.secondary)
.help("Hide the panel (Cmd+W) — Prompt keeps running so Shift-Shift capture still works. Quit from the menu bar icon or Cmd+Q.")
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -86,8 +91,13 @@ struct PanelView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow) .foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 2) {
Text("Shift-tap capture needs Accessibility access.") Text("Shift-tap capture needs Accessibility access.")
.font(.caption) .font(.caption)
Text("Meanwhile: Cmd+Shift+Space toggles this panel.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer() Spacer()
Button("Grant…") { Button("Grant…") {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
@@ -217,6 +227,7 @@ struct PanelView: View {
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Click to copy · Cmd+click to select several · Shift+click for a range · right-click for more")
.contextMenu { contextMenu(for: item) } .contextMenu { contextMenu(for: item) }
} }
@@ -256,7 +267,11 @@ struct PanelView: View {
Button("Copy") { copy(item) } Button("Copy") { copy(item) }
Button("Copy as List") { Button("Copy as List") {
store.copyAsList(ids: effectiveIDs(for: item)) store.copyAsList(ids: effectiveIDs(for: item))
flashCopied(item) flashCopied(item.id)
}
Button("Copy as Lines") {
store.copyAsLines(ids: effectiveIDs(for: item))
flashCopied(item.id)
} }
Divider() Divider()
Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) } Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) }
@@ -273,7 +288,7 @@ struct PanelView: View {
if selection.count > 1, selection.contains(item.id) { if selection.count > 1, selection.contains(item.id) {
Button("Merge Notes") { Button("Merge Notes") {
store.merge(ids: selection) store.merge(ids: selection)
selection.removeAll() clearSelection()
} }
} }
Menu("Move To") { Menu("Move To") {
@@ -450,6 +465,49 @@ struct PanelView: View {
} }
} }
// MARK: - Selection
private var selectionBar: some View {
HStack(spacing: 12) {
Text("\(selection.count) selected")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Copy as List") { copySelection(asList: true) }
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
Button("Copy as Lines") { copySelection(asList: false) }
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
Button { clearSelection() } label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
.foregroundStyle(.tertiary)
.help("Clear selection (Esc)")
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.accentColor.opacity(0.12))
}
private func copySelection(asList: Bool) {
guard !selection.isEmpty else { return }
if asList {
store.copyAsList(ids: selection)
} else {
store.copyAsLines(ids: selection)
}
if let id = selection.first { flashCopied(id) }
}
private func clearSelection() {
selection.removeAll()
lastSelectedID = nil
}
// MARK: - Composer // MARK: - Composer
private var composerField: some View { private var composerField: some View {
@@ -458,7 +516,15 @@ struct PanelView: View {
.textFieldStyle(.plain) .textFieldStyle(.plain)
.focused($focusedField, equals: .composer) .focused($focusedField, equals: .composer)
.onSubmit(submit) .onSubmit(submit)
.onExitCommand { PanelController.shared.hide() } .onExitCommand {
// Esc clears a selection first, and only hides the panel
// once there is nothing selected.
if selection.isEmpty {
PanelController.shared.hide()
} else {
clearSelection()
}
}
if !composer.isEmpty { if !composer.isEmpty {
Button(action: submit) { Image(systemName: "arrow.up.circle.fill") } Button(action: submit) { Image(systemName: "arrow.up.circle.fill") }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -500,11 +566,13 @@ struct PanelView: View {
} }
.keyboardShortcut(",", modifiers: .command) .keyboardShortcut(",", modifiers: .command)
Button("") { Button("") {
guard !selection.isEmpty else { return } copySelection(asList: true)
store.copyAsList(ids: selection)
if let id = selection.first { flashCopied(PromptItem(id: id, content: .text(""))) }
} }
.keyboardShortcut("c", modifiers: [.command, .shift]) .keyboardShortcut("c", modifiers: [.command, .shift])
Button("") {
copySelection(asList: false)
}
.keyboardShortcut("c", modifiers: [.command, .option])
} }
.opacity(0) .opacity(0)
.frame(width: 0, height: 0) .frame(width: 0, height: 0)
@@ -514,26 +582,40 @@ struct PanelView: View {
// MARK: - Actions // MARK: - Actions
private func handleClick(_ item: PromptItem) { private func handleClick(_ item: PromptItem) {
if NSApp.currentEvent?.modifierFlags.contains(.command) == true { let modifiers = NSApp.currentEvent?.modifierFlags ?? []
if modifiers.contains(.command) {
if selection.contains(item.id) { if selection.contains(item.id) {
selection.remove(item.id) selection.remove(item.id)
} else { } else {
selection.insert(item.id) selection.insert(item.id)
} }
lastSelectedID = item.id
return
}
if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id {
selectRange(from: anchor, to: item.id)
return return
} }
copy(item) copy(item)
} }
private func copy(_ item: PromptItem) { private func selectRange(from anchor: UUID, to target: UUID) {
store.copy(item) let ids = displayedItems.map(\.item.id)
flashCopied(item) guard let start = ids.firstIndex(of: anchor), let end = ids.firstIndex(of: target) else { return }
let range = start <= end ? start...end : end...start
selection = Set(ids[range])
lastSelectedID = target
} }
private func flashCopied(_ item: PromptItem) { private func copy(_ item: PromptItem) {
withAnimation { copiedID = item.id } store.copy(item)
flashCopied(item.id)
}
private func flashCopied(_ id: UUID) {
withAnimation { copiedID = id }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
if copiedID == item.id { if copiedID == id {
withAnimation { copiedID = nil } withAnimation { copiedID = nil }
} }
} }
+21 -5
View File
@@ -269,18 +269,34 @@ final class Store: ObservableObject {
} }
func copyAsList(ids: Set<UUID>) { func copyAsList(ids: Set<UUID>) {
let items = sections.flatMap(\.items) let texts = selectedTexts(ids: ids)
guard !texts.isEmpty else { return }
let list = texts.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
writeToPasteboard(list)
}
/// Same selection as `copyAsList`, but joined with plain newlines so it
/// drops into a prompt without numbering.
func copyAsLines(ids: Set<UUID>) {
let texts = selectedTexts(ids: ids)
guard !texts.isEmpty else { return }
writeToPasteboard(texts.joined(separator: "\n"))
}
private func selectedTexts(ids: Set<UUID>) -> [String] {
sections.flatMap(\.items)
.filter { ids.contains($0.id) } .filter { ids.contains($0.id) }
.sorted { $0.createdAt < $1.createdAt } .sorted { $0.createdAt < $1.createdAt }
let texts = items.compactMap { item -> String? in .compactMap { item in
if case .text(let text) = item.content { return text } if case .text(let text) = item.content { return text }
return nil return nil
} }
guard !texts.isEmpty else { return } }
let list = texts.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
private func writeToPasteboard(_ string: String) {
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
pasteboard.clearContents() pasteboard.clearContents()
pasteboard.setString(list, forType: .string) pasteboard.setString(string, forType: .string)
} }
// MARK: - Images // MARK: - Images
+8
View File
@@ -67,6 +67,14 @@ final class SyncService: ObservableObject {
restartPolling() restartPolling()
} }
func stop() {
pollTimer?.invalidate()
pollTimer = nil
pushWork?.cancel()
pushWork = nil
Store.shared.onDidChange = nil
}
// MARK: - Group membership // MARK: - Group membership
func generateCode() { func generateCode() {