3 Commits
Author SHA1 Message Date
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
TZGyn e0c47ad010 Scope the selection to the current view
A selection made in one workspace survived switching workspace or tab, so
the selection bar and its batch actions (copy, delete) kept acting on items
that were no longer visible. Switching workspace, switching tab, or closing
search now clears the selection.
2026-10-24 20:20:43 +08:00
TZGyn a75d6fdf41 Fix the composer scroller flash, edit focus, and Shift+Return
- The composer only enables its scroller once the text is taller than the
  seven-line max, so it no longer flashes on every growth step.
- The composer re-asserts first responder only when canStealFocus is true;
  it is off while a modal field or the sync overlay has the keyboard, so
  typing in the edit dialog, search and sync fields no longer bounces back.
- The edit dialog is a ComposerView now: Return saves, Shift+Return inserts
  a newline, Esc cancels, and paste uses the same path as the composer.
- Shift+Return never actually worked: returning false from doCommandBy does
  not fall through to the default newline insertion, so the event died. The
  newline is inserted explicitly and the Shift state comes from the keyDown.
2026-10-23 22:54:04 +08:00
4 changed files with 140 additions and 23 deletions
+27 -4
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
@@ -44,7 +46,12 @@ clicking an item copies it back to the clipboard.
newline, Esc forwards to the panel (clear selection, else hide). Height is newline, Esc forwards to the panel (clear selection, else hide). Height is
measured from the text and pushed into a `height` binding the panel applies measured from the text and pushed into a `height` binding the panel applies
as a frame — a representable cannot resize itself through as a frame — a representable cannot resize itself through
`intrinsicContentSize` once SwiftUI offers a larger size. `intrinsicContentSize` once SwiftUI offers a larger size. The vertical
scroller is disabled until the text is taller than the max height, so it
never flashes during growth. The composer re-asserts first responder on
updates only while `canStealFocus` is true; the panel turns it off whenever
a modal field (search, edit, new workspace) or the sync overlay owns the
keyboard — otherwise every render stole focus back mid-edit.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (workspace switcher, - `Sources/Prompt/PanelView.swift` — the panel UI. Header (workspace switcher,
search, add photos), a segmented Workspace/All tab (All lists every prompt search, add photos), a segmented Workspace/All tab (All lists every prompt
labeled with its workspace), item list, in-panel edit modal, composer. labeled with its workspace), item list, in-panel edit modal, composer.
@@ -54,7 +61,8 @@ clicking an item copies it back to the clipboard.
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, Done, Edit, Open, Merge, Move To, Delete). Esc clears the
selection before it hides the panel. Drop target for images/files. 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.
- `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`), and the Cmd+V paste monitor. Editing a note is an in-panel `PromptPanel`), and the Cmd+V paste monitor. Editing a note is an in-panel
@@ -221,8 +229,23 @@ brew upgrade --cask prompt
firing. The tap is re-enabled on `tapDisabledByTimeout`. firing. The tap is re-enabled on `tapDisabledByTimeout`.
- `Store` is `@MainActor`; anything reaching it from monitors/callbacks must - `Store` is `@MainActor`; anything reaching it from monitors/callbacks must
go through `Task { @MainActor in }`. go through `Task { @MainActor in }`.
- **The composer steals focus via `updateNSView`, so gate it.** It used to
re-assert first responder on every render, which yanked focus out of the
edit dialog (and search/sync fields) on every keystroke. The gate is
`canStealFocus`; any new modal text field must be included in it.
- **`doCommandBy` has no fall-through once the delegate implements it.**
Returning false for `insertNewline` on Shift+Return killed the event instead
of inserting a newline, so Shift+Return never actually worked. The composer
now inserts it explicitly, and reads the Shift state from the `keyDown`
currently being handled because `NSApp.currentEvent` is not reliably
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 {
+31 -6
View File
@@ -16,6 +16,10 @@ struct ComposerView: NSViewRepresentable {
@Binding var isFocused: Bool @Binding var isFocused: Bool
@Binding var height: CGFloat @Binding var height: CGFloat
var placeholder: String var placeholder: String
/// When false, the composer never makes itself first responder. The panel
/// turns this off while a modal field (search, edit, new workspace) or the
/// sync overlay is up — otherwise every render stole focus back here.
var canStealFocus: Bool = true
var onSubmit: () -> Void var onSubmit: () -> Void
var onEscape: (() -> Void)? = nil var onEscape: (() -> Void)? = nil
@@ -34,7 +38,10 @@ struct ComposerView: NSViewRepresentable {
let scrollView = NSScrollView() let scrollView = NSScrollView()
scrollView.drawsBackground = false scrollView.drawsBackground = false
scrollView.borderType = .noBorder scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true // The scroller starts disabled and is only switched on once the text
// is taller than the max height. With it always on, every growth step
// briefly overflows and the scroller flashes for one frame.
scrollView.hasVerticalScroller = false
scrollView.autohidesScrollers = true scrollView.autohidesScrollers = true
scrollView.horizontalScrollElasticity = .none scrollView.horizontalScrollElasticity = .none
@@ -70,7 +77,7 @@ struct ComposerView: NSViewRepresentable {
if textView.placeholderText != placeholder { if textView.placeholderText != placeholder {
textView.placeholderText = placeholder textView.placeholderText = placeholder
} }
if isFocused, scrollView.window?.firstResponder != textView { if isFocused, canStealFocus, scrollView.window?.firstResponder != textView {
scrollView.window?.makeFirstResponder(textView) scrollView.window?.makeFirstResponder(textView)
} }
} }
@@ -81,7 +88,9 @@ struct ComposerView: NSViewRepresentable {
layoutManager.ensureLayout(for: textContainer) layoutManager.ensureLayout(for: textContainer)
let content = layoutManager.usedRect(for: textContainer).height + 2 * textView.textContainerInset.height let content = layoutManager.usedRect(for: textContainer).height + 2 * textView.textContainerInset.height
let line = Self.oneLineHeight let line = Self.oneLineHeight
let clamped = max(line, min(content, line * maxLines + 1)) let maxHeight = line * maxLines + 1
textView.enclosingScrollView?.hasVerticalScroller = content > maxHeight
let clamped = max(line, min(content, maxHeight))
if abs(height - clamped) > 0.5 { if abs(height - clamped) > 0.5 {
// Defer so the binding is not written from inside a layout pass. // Defer so the binding is not written from inside a layout pass.
DispatchQueue.main.async { self.height = clamped } DispatchQueue.main.async { self.height = clamped }
@@ -103,7 +112,7 @@ struct ComposerView: NSViewRepresentable {
) { [weak self, weak textView] note in ) { [weak self, weak textView] note in
guard let self, let textView, guard let self, let textView,
(note.object as? NSWindow) === textView.window, (note.object as? NSWindow) === textView.window,
self.parent.isFocused else { return } self.parent.isFocused, self.parent.canStealFocus else { return }
textView.window?.makeFirstResponder(textView) textView.window?.makeFirstResponder(textView)
} }
} }
@@ -131,8 +140,14 @@ struct ComposerView: NSViewRepresentable {
return true return true
} }
guard commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false } guard commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false }
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { // NSApp.currentEvent is not reliably set when the command fires,
return false // so the Shift state comes from the keyDown currently being handled.
// And returning false here does NOT fall through to the default
// newline insertion once a delegate implements doCommandBy — the
// event just dies — so insert the newline explicitly.
if (textView as? ComposerTextView)?.keyDownModifiers.contains(.shift) == true {
textView.insertNewline(nil)
return true
} }
parent.onSubmit() parent.onSubmit()
return true return true
@@ -145,6 +160,16 @@ private final class ComposerTextView: NSTextView {
didSet { needsDisplay = true } didSet { needsDisplay = true }
} }
/// Modifier flags of the keyDown currently being handled. `doCommandBy`
/// runs inside that call tree and cannot rely on `NSApp.currentEvent`.
var keyDownModifiers: NSEvent.ModifierFlags = []
override func keyDown(with event: NSEvent) {
keyDownModifiers = event.modifierFlags
super.keyDown(with: event)
keyDownModifiers = []
}
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect) super.draw(dirtyRect)
guard string.isEmpty, !placeholderText.isEmpty else { return } guard string.isEmpty, !placeholderText.isEmpty else { return }
+55 -13
View File
@@ -19,13 +19,15 @@ struct PanelView: View {
@State private var lastSelectedID: UUID? @State private var lastSelectedID: UUID?
@State private var editingItem: PromptItem? @State private var editingItem: PromptItem?
@State private var editText = "" @State private var editText = ""
@State private var editFocused = false
@State private var editHeight: CGFloat = ComposerView.oneLineHeight
@State private var copiedID: UUID? @State private var copiedID: UUID?
@State private var accessibilityGranted = AXIsProcessTrusted() @State private var accessibilityGranted = AXIsProcessTrusted()
private enum Tab { case workspace, all } private enum Tab { case workspace, all }
@State private var tab: Tab = .workspace @State private var tab: Tab = .workspace
private enum Field: Hashable { case search, newWorkspace, editor } private enum Field: Hashable { case search, newWorkspace }
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
var body: some View { var body: some View {
@@ -59,6 +61,11 @@ struct PanelView: View {
accessibilityGranted = AXIsProcessTrusted() accessibilityGranted = AXIsProcessTrusted()
} }
.onAppear { composerFocused = true } .onAppear { composerFocused = true }
// Selection is scoped to the current view: switching workspace, tab,
// or leaving search clears it. Otherwise a selection made in one
// workspace kept acting (copy, delete) on items you can no longer see.
.onChange(of: store.activeSectionID) { clearSelection() }
.onChange(of: tab) { clearSelection() }
} }
// MARK: - Header // MARK: - Header
@@ -141,6 +148,8 @@ struct PanelView: View {
.onExitCommand { .onExitCommand {
search = "" search = ""
showSearch = false showSearch = false
focusedField = nil
clearSelection()
composerFocused = true composerFocused = true
} }
if !search.isEmpty { if !search.isEmpty {
@@ -322,8 +331,9 @@ struct PanelView: View {
if case .text(let text) = item.content, !multiple { if case .text(let text) = item.content, !multiple {
Button("Edit…") { Button("Edit…") {
editText = text editText = text
editHeight = ComposerView.oneLineHeight
editingItem = item editingItem = item
focusedField = .editor editFocused = true
} }
} }
if case .image(let filename) = item.content, !multiple { if case .image(let filename) = item.content, !multiple {
@@ -542,6 +552,9 @@ struct PanelView: View {
// MARK: - Edit // MARK: - Edit
/// The editor is a ComposerView too, so it behaves exactly like the main
/// input: Return saves, Shift+Return inserts a newline, Esc cancels, and
/// pasting goes through the same working path.
@ViewBuilder @ViewBuilder
private var editModal: some View { private var editModal: some View {
if let item = editingItem { if let item = editingItem {
@@ -551,14 +564,18 @@ struct PanelView: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Edit") Text("Edit")
.font(.headline) .font(.headline)
TextEditor(text: $editText) ComposerView(
.font(.callout) text: $editText,
.scrollContentBackground(.hidden) isFocused: $editFocused,
.frame(minHeight: 120, maxHeight: 280) height: $editHeight,
.padding(8) placeholder: "",
.background(Theme.rowSwiftUI) onSubmit: { saveEdit(item) },
.cornerRadius(8) onEscape: { dismissEditor() }
.focused($focusedField, equals: .editor) )
.frame(height: editHeight)
.padding(8)
.background(Theme.rowSwiftUI)
.cornerRadius(8)
HStack { HStack {
Spacer() Spacer()
Button("Cancel") { dismissEditor() } Button("Cancel") { dismissEditor() }
@@ -593,11 +610,25 @@ struct PanelView: View {
private func dismissEditor() { private func dismissEditor() {
editingItem = nil editingItem = nil
editText = "" editText = ""
editFocused = false
composerFocused = true composerFocused = true
} }
// 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 {
@@ -612,12 +643,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)
@@ -631,8 +667,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()
} }
@@ -640,6 +676,7 @@ struct PanelView: View {
private func dismissNewWorkspace() { private func dismissNewWorkspace() {
showNewWorkspace = false showNewWorkspace = false
newWorkspaceName = "" newWorkspaceName = ""
focusedField = nil
composerFocused = true composerFocused = true
} }
@@ -713,6 +750,9 @@ struct PanelView: View {
isFocused: $composerFocused, isFocused: $composerFocused,
height: $composerHeight, height: $composerHeight,
placeholder: "Add a note or prompt (\(store.activeSection.name))", placeholder: "Add a note or prompt (\(store.activeSection.name))",
// A modal field (search, edit, new workspace) or the sync
// overlay owns the keyboard; don't steal focus back mid-edit.
canStealFocus: focusedField == nil && !showSync && editingItem == nil,
onSubmit: submit, onSubmit: submit,
onEscape: { onEscape: {
if selection.isEmpty { if selection.isEmpty {
@@ -835,6 +875,8 @@ struct PanelView: View {
focusedField = .search focusedField = .search
} else { } else {
search = "" search = ""
focusedField = nil
clearSelection()
composerFocused = true composerFocused = true
} }
} }