2 Commits
Author SHA1 Message Date
TZGyn 9907db373c Add Copy & Delete to the context menu
Copies the item (or the selection, as plain lines) and removes it in one
step — the 'take this out' gesture. No shortcut: Cmd+X belongs to the text
fields, and a panel binding would steal it.
2026-10-27 21:31:30 +08:00
TZGyn 15a2aa92f7 Open on startup by default and block duplicate workspace names
- LoginItem registers on the first bundled launch, so the app opens on
  startup without the user finding the toggle; a user who turns it off is
  never overridden. A never-registered bundle reports SMAppService .notFound
  rather than .notRegistered, so the guard only treats enabled/approval as
  done. --diag now prints the raw status.
- New Workspace refuses a name that already exists (case-insensitive) and
  says so inline; Return cannot bypass it. The composer's '# Name' still
  switches to the existing workspace, which is the quick-capture gesture.
2026-10-26 20:46:02 +08:00
3 changed files with 77 additions and 6 deletions
+10 -3
View File
@@ -13,8 +13,10 @@ 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`). The sync panel also carries a Launch at Login toggle (the (`SMAppService`): the app auto-registers as a login item on the first
app's only settings surface). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit bundled launch (once, tracked by `loginItem.autoRegistered` in UserDefaults)
so it opens on startup by default; the Launch at Login toggle in the sync
panel (the app's only settings surface) is the opt-out. 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 (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 services key equivalents, which is the only way to quit from the keyboard
while the panel has focus. `applicationShouldHandleReopen` shows the panel so while the panel has focus. `applicationShouldHandleReopen` shows the panel so
@@ -58,7 +60,7 @@ clicking an item copies it back to the clipboard.
switches sections. Click = select (Shift+click for a range); the selection switches sections. Click = select (Shift+click for a range); the selection
bar offers Copy (single item), Copy as List, Copy as Lines and Delete; bar offers Copy (single item), Copy as List, Copy as Lines and Delete;
right-click = context menu acting on the whole selection (Copy, Copy as List, right-click = context menu acting on the whole selection (Copy, Copy as List,
Copy as Lines, Done, Edit, Open, Merge, Move To, Delete). Esc clears the Copy as Lines, Copy & Delete, Done, Edit, Open, Merge, Move To, Delete). Esc clears the
selection before it hides the panel. Selection is scoped to the current view: selection before it hides the panel. Selection is scoped to the current view:
switching workspace, switching tab, or closing search clears it. Drop target for images/files. switching workspace, switching tab, or closing search clears it. Drop target for images/files.
- `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel` - `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel`
@@ -239,6 +241,11 @@ brew upgrade --cask prompt
populated when the command fires. populated when the command fires.
- `captureSelection()` blocks up to 0.5s polling the pasteboard — keep it off - `captureSelection()` blocks up to 0.5s polling the pasteboard — keep it off
the main thread (the hotkey handler already dispatches to a global queue). the main thread (the hotkey handler already dispatches to a global queue).
- **`SMAppService.mainApp.status` is `.notFound` for a bundle BTM has never
seen**, not `.notRegistered`, and `register()` still works from that state.
Only treat `.enabled` and `.requiresApproval` as "already done"; checking for
`.notRegistered` silently skips the registration. `Prompt --diag` prints the
raw status.
- 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, and `AppDelegate` shows a launch menu both surface the permission state, and `AppDelegate` shows a launch
+27
View File
@@ -61,6 +61,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
setupStatusItem() setupStatusItem()
promptForAccessibilityIfNeeded() promptForAccessibilityIfNeeded()
LoginItem.autoRegisterOnce()
// Let SwiftUI finish installing its main menu, then augment it. // Let SwiftUI finish installing its main menu, then augment it.
DispatchQueue.main.async { self.installMenuItems() } DispatchQueue.main.async { self.installMenuItems() }
@@ -140,6 +141,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor @MainActor
private func writeDiagnostics() { private func writeDiagnostics() {
var lines = ["Prompt diagnostics", "accessibility: \(AXIsProcessTrusted())"] var lines = ["Prompt diagnostics", "accessibility: \(AXIsProcessTrusted())"]
lines.append("login item status: \(SMAppService.mainApp.status.rawValue)")
if let window = statusItem?.button?.window { if let window = statusItem?.button?.window {
lines.append("status item frame: \(window.frame)") lines.append("status item frame: \(window.frame)")
if let screen = window.screen ?? NSScreen.main { if let screen = window.screen ?? NSScreen.main {
@@ -266,6 +268,31 @@ enum LoginItem {
SMAppService.mainApp.status == .enabled SMAppService.mainApp.status == .enabled
} }
/// Registers the app as a login item on first launch so Prompt opens on
/// startup without the user hunting for the toggle. Runs once (tracked in
/// UserDefaults) so a user who disables it is never overridden, and skips
/// `swift run`, where there is no bundle to register.
static func autoRegisterOnce() {
let key = "loginItem.autoRegistered"
guard Bundle.main.bundleIdentifier != nil,
!UserDefaults.standard.bool(forKey: key) else { return }
// A never-registered bundle reports `.notFound` on current macOS
// (BTM has no record), not `.notRegistered`, so only skip when there
// is nothing left to do.
switch SMAppService.mainApp.status {
case .enabled, .requiresApproval:
UserDefaults.standard.set(true, forKey: key)
default:
do {
try SMAppService.mainApp.register()
UserDefaults.standard.set(true, forKey: key)
NSLog("Prompt: registered as a login item (%d)", SMAppService.mainApp.status.rawValue)
} catch {
NSLog("Prompt: login item registration failed: %@", error.localizedDescription)
}
}
}
static func toggle() -> Bool { static func toggle() -> Bool {
let service = SMAppService.mainApp let service = SMAppService.mainApp
do { do {
+40 -3
View File
@@ -324,6 +324,9 @@ struct PanelView: View {
store.copyAsLines(ids: ids) store.copyAsLines(ids: ids)
flashCopied(item.id) flashCopied(item.id)
} }
Button(multiple ? "Copy & Delete \(ids.count) Items" : "Copy & Delete") {
copyAndDelete(item)
}
Divider() Divider()
Button(item.done ? "Mark as Not Done" : "Mark as Done") { Button(item.done ? "Mark as Not Done" : "Mark as Done") {
store.setDone(!item.done, ids: ids) store.setDone(!item.done, ids: ids)
@@ -616,6 +619,19 @@ struct PanelView: View {
// MARK: - New workspace // MARK: - New workspace
private var trimmedWorkspaceName: String {
newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Case-insensitive, like the store's dedupe. The modal blocks creation
/// with a hint instead of silently switching to the existing workspace.
private var workspaceNameExists: Bool {
let name = trimmedWorkspaceName
return !name.isEmpty && store.sections.contains {
$0.name.caseInsensitiveCompare(name) == .orderedSame
}
}
@ViewBuilder @ViewBuilder
private var newWorkspaceModal: some View { private var newWorkspaceModal: some View {
if showNewWorkspace { if showNewWorkspace {
@@ -630,12 +646,17 @@ struct PanelView: View {
.focused($focusedField, equals: .newWorkspace) .focused($focusedField, equals: .newWorkspace)
.onSubmit(createWorkspace) .onSubmit(createWorkspace)
.onExitCommand { dismissNewWorkspace() } .onExitCommand { dismissNewWorkspace() }
if workspaceNameExists {
Text("A workspace named “\(trimmedWorkspaceName)” already exists.")
.font(.caption)
.foregroundStyle(.red)
}
HStack { HStack {
Spacer() Spacer()
Button("Cancel") { dismissNewWorkspace() } Button("Cancel") { dismissNewWorkspace() }
Button("Create") { createWorkspace() } Button("Create") { createWorkspace() }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
.disabled(newWorkspaceName.trimmingCharacters(in: .whitespaces).isEmpty) .disabled(trimmedWorkspaceName.isEmpty || workspaceNameExists)
} }
} }
.padding(16) .padding(16)
@@ -649,8 +670,8 @@ struct PanelView: View {
} }
private func createWorkspace() { private func createWorkspace() {
let name = newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines) let name = trimmedWorkspaceName
guard !name.isEmpty else { return } guard !name.isEmpty, !workspaceNameExists else { return }
store.createSection(named: name) store.createSection(named: name)
dismissNewWorkspace() dismissNewWorkspace()
} }
@@ -837,6 +858,22 @@ struct PanelView: View {
flashCopied(item.id) flashCopied(item.id)
} }
/// One gesture for "take this out": copy, then delete. A multi-selection
/// is copied as plain lines, same as Copy as Lines. No shortcut — Cmd+X
/// belongs to the composer/editor text fields, and a panel binding would
/// steal Cut from them.
private func copyAndDelete(_ item: PromptItem) {
let ids = effectiveIDs(for: item)
if ids.count > 1 {
store.copyAsLines(ids: ids)
} else {
store.copy(item)
}
store.delete(ids: ids)
selection.subtract(ids)
lastSelectedID = nil
}
private func flashCopied(_ id: UUID) { private func flashCopied(_ id: UUID) {
withAnimation { copiedID = id } withAnimation { copiedID = id }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {