krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 13ab79873c

13ab79873c80622eca482318aece2880cfb69e34

parent: 37a20f818f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:29 UTC

Fix Mac menu shortcut, trash warning, capture and Quick Open

- Put ⌥⌘O back on Show or Hide Outline.
- Warn before trashing only when the trashed file has unsaved edits.
- Show capture problems while the editor is showing too.
- Select multi-key capture templates by typing their keys; Delete drops the last one.
- Allow agenda spans up to 366 days.
- Move the Quick Open selection with Up/Down and C-n/C-p; Return opens it.

Layout: unified · split

Sources/Orgstar/CaptureView.swift +21 −5
@@ -20,6 +20,8 @@ struct CaptureView: View {
20 @State private var selection: TextSelection? 20 @State private var selection: TextSelection?
21 @State private var context = CaptureContext() 21 @State private var context = CaptureContext()
22 @State private var editing = false 22 @State private var editing = false
23 /// The template keys typed so far, for multi-key templates such as "wb".
24 @State private var keys = ""
23 @FocusState private var focused: Bool 25 @FocusState private var focused: Bool
24 @Environment(\.dismissWindow) private var dismissWindow 26 @Environment(\.dismissWindow) private var dismissWindow
25 27
@@ -57,8 +59,8 @@ struct CaptureView: View {
57 .keyboardShortcut(.defaultAction) 59 .keyboardShortcut(.defaultAction)
58 } 60 }
59 } else { 61 } else {
60 Text("Capture").font(.headline) 62 Text(keys.isEmpty ? "Capture" : "Capture: \(keys)").font(.headline)
61 ForEach(templates) { template in 63 ForEach(templates.filter { $0.key.hasPrefix(keys) }) { template in
62 Button { 64 Button {
63 choose(template) 65 choose(template)
64 } label: { 66 } label: {
@@ -71,14 +73,26 @@ struct CaptureView: View {
71 } 73 }
72 .buttonStyle(.plain) 74 .buttonStyle(.plain)
73 } 75 }
74 if let problem { Text(problem).font(.caption).foregroundStyle(.red) }
75 } 76 }
77 if let problem { Text(problem).font(.caption).foregroundStyle(.red) }
76 } 78 }
77 .padding(16) 79 .padding(16)
78 .frame(width: 520) 80 .frame(width: 520)
79 .onKeyPress(phases: .down) { press in 81 .onKeyPress(phases: .down) { press in
80 guard template == nil, let match = templates.first(where: { $0.key == press.characters }) else { return .ignored } 82 guard template == nil else { return .ignored }
81 choose(match) 83 if press.key == .delete, !keys.isEmpty {
84 keys.removeLast()
85 return .handled
86 }
87 // Keys narrow the list until they name a template, as `org-capture` selects one.
88 let typed = keys + press.characters
89 if let match = templates.first(where: { $0.key == typed }) {
90 choose(match)
91 } else if !press.characters.isEmpty, templates.contains(where: { $0.key.hasPrefix(typed) }) {
92 keys = typed
93 } else {
94 return .ignored
95 }
82 return .handled 96 return .handled
83 } 97 }
84 .onAppear { 98 .onAppear {
@@ -119,6 +133,7 @@ struct CaptureView: View {
119 problem = loaded.problems.first 133 problem = loaded.problems.first
120 template = nil 134 template = nil
121 editing = false 135 editing = false
136 keys = ""
122 let clocked = clock.running 137 let clocked = clock.running
123 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) } 138 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) }
124 context = CaptureContext( 139 context = CaptureContext(
@@ -149,6 +164,7 @@ struct CaptureView: View {
149 164
150 private func choose(_ chosen: CaptureTemplate) { 165 private func choose(_ chosen: CaptureTemplate) {
151 template = chosen 166 template = chosen
167 keys = ""
152 // `%^g` offers the target file's tags. 168 // `%^g` offers the target file's tags.
153 let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first) 169 let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first)
154 if !chosen.target.file.isEmpty, let text = session.openDocuments.first(where: { $0.path == path })?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) { 170 if !chosen.target.file.isEmpty, let text = session.openDocuments.first(where: { $0.path == path })?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) {
Sources/Orgstar/ContentView.swift +29 −9
@@ -818,27 +818,47 @@ struct QuickOpenView: View {
818 let workspace: WorkspaceModel 818 let workspace: WorkspaceModel
819 let open: (FileEntry) -> Void 819 let open: (FileEntry) -> Void
820 @State private var query = "" 820 @State private var query = ""
821 /// The highlighted match, moved by ↑/↓ or C-p/C-n and opened by Return.
822 @State private var selected = 0
821 @Environment(\.dismiss) private var dismiss 823 @Environment(\.dismiss) private var dismiss
822 824
823 var body: some View { 825 var body: some View {
824 let matches = workspace.quickOpen(query) 826 let matches = workspace.quickOpen(query)
827 let current = min(selected, matches.count - 1)
825 VStack(spacing: 0) { 828 VStack(spacing: 0) {
826 TextField("Open file", text: $query) 829 TextField("Open file", text: $query)
827 .textFieldStyle(.plain) 830 .textFieldStyle(.plain)
828 .font(.title3) 831 .font(.title3)
829 .padding(12) 832 .padding(12)
830 .onSubmit { if let first = matches.first { open(first) } } 833 .onSubmit { if current >= 0 { open(matches[current]) } }
834 .onKeyPress(phases: .down) { press in
835 let control = press.modifiers == .control
836 let step = if press.key == .downArrow || control && press.characters == "n" { 1 }
837 else if press.key == .upArrow || control && press.characters == "p" { -1 } else { 0 }
838 guard step != 0 else { return .ignored }
839 selected = max(0, min(current + step, matches.count - 1))
840 return .handled
841 }
842 .onChange(of: query) { selected = 0 }
831 Divider() 843 Divider()
832 List(matches) { entry in 844 ScrollViewReader { proxy in
833 Button { 845 List(matches.indices, id: \.self) { index in
834 open(entry) 846 let entry = matches[index]
835 } label: { 847 Button {
836 VStack(alignment: .leading, spacing: 2) { 848 open(entry)
837 Text(entry.name) 849 } label: {
838 Text(entry.relativePath).font(.caption).foregroundStyle(.secondary) 850 VStack(alignment: .leading, spacing: 2) {
851 Text(entry.name)
852 Text(entry.relativePath).font(.caption).foregroundStyle(.secondary)
853 }
854 .frame(maxWidth: .infinity, alignment: .leading)
855 .contentShape(Rectangle())
839 } 856 }
857 .buttonStyle(.plain)
858 .listRowBackground(index == current ? Color.accentColor.opacity(0.25) : Color.clear)
859 .accessibilityAddTraits(index == current ? .isSelected : [])
840 } 860 }
841 .buttonStyle(.plain) 861 .onChange(of: current) { if current >= 0 { proxy.scrollTo(current) } }
842 } 862 }
843 } 863 }
844 .frame(width: 520, height: 360) 864 .frame(width: 520, height: 360)
Sources/Orgstar/OrgstarApp.swift +2 −2
@@ -351,10 +351,10 @@ struct OrgstarApp: App {
351 } 351 }
352 CommandGroup(after: .sidebar) { 352 CommandGroup(after: .sidebar) {
353 Button("Show or Hide Outline") { commands.run("app.toggle-outline") } 353 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
354 .keyboardShortcut("o", modifiers: [.command, .option])
354 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") } 355 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") }
355 Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") } 356 Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") }
356 .keyboardShortcut("i", modifiers: [.command, .option]) 357 .keyboardShortcut("i", modifiers: [.command, .option])
357 .keyboardShortcut("o", modifiers: [.command, .option])
358 MarkupToggle() 358 MarkupToggle()
359 } 359 }
360 CommandGroup(after: .pasteboard) { 360 CommandGroup(after: .pasteboard) {
@@ -629,7 +629,7 @@ struct SettingsView: View {
629 private var agenda: some View { 629 private var agenda: some View {
630 Form { 630 Form {
631 Section { 631 Section {
632 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31) 632 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...366)
633 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0) 633 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
634 Toggle("Include files in subfolders", isOn: $agendaSubfolders) 634 Toggle("Include files in subfolders", isOn: $agendaSubfolders)
635 } 635 }
Sources/Orgstar/SidebarFiles.swift +3 −3
@@ -35,8 +35,8 @@ extension Sidebar {
35 session.openPaths.filter { $0 == path || $0.hasPrefix(path + "/") } 35 session.openPaths.filter { $0 == path || $0.hasPrefix(path + "/") }
36 } 36 }
37 37
38 /// Whether `path` is an open file or a folder holding one. 38 /// Whether `path` is an open file with unsaved edits or a folder holding one.
39 func holdsOpenFile(_ path: String) -> Bool { !openFiles(under: path).isEmpty } 39 func holdsUnsavedFile(_ path: String) -> Bool { openFiles(under: path).contains(where: session.dirtyPaths.contains) }
40 40
41 func finish() { 41 func finish() {
42 guard let operation = pending else { return } 42 guard let operation = pending else { return }
@@ -90,7 +90,7 @@ extension View {
90 .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) { 90 .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) {
91 Button("Move to Trash", role: .destructive) { sidebar.finish() } 91 Button("Move to Trash", role: .destructive) { sidebar.finish() }
92 } message: { 92 } message: {
93 if case .trash(let path) = sidebar.pending, sidebar.holdsOpenFile(path), sidebar.session.isDirty { 93 if case .trash(let path) = sidebar.pending, sidebar.holdsUnsavedFile(path) {
94 Text("Unsaved changes to the open file will be lost.") 94 Text("Unsaved changes to the open file will be lost.")
95 } else { 95 } else {
96 Text("You can put it back from the Trash in Finder.") 96 Text("You can put it back from the Trash in Finder.")