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 {
2020 @State private var selection: TextSelection?
2121 @State private var context = CaptureContext()
2222 @State private var editing = false
23 /// The template keys typed so far, for multi-key templates such as "wb".
24 @State private var keys = ""
2325 @FocusState private var focused: Bool
2426 @Environment(\.dismissWindow) private var dismissWindow
2527
@@ -57,8 +59,8 @@ struct CaptureView: View {
5759 .keyboardShortcut(.defaultAction)
5860 }
5961 } else {
60 Text("Capture").font(.headline)
61 ForEach(templates) { template in
62 Text(keys.isEmpty ? "Capture" : "Capture: \(keys)").font(.headline)
63 ForEach(templates.filter { $0.key.hasPrefix(keys) }) { template in
6264 Button {
6365 choose(template)
6466 } label: {
@@ -71,14 +73,26 @@ struct CaptureView: View {
7173 }
7274 .buttonStyle(.plain)
7375 }
74 if let problem { Text(problem).font(.caption).foregroundStyle(.red) }
7576 }
77 if let problem { Text(problem).font(.caption).foregroundStyle(.red) }
7678 }
7779 .padding(16)
7880 .frame(width: 520)
7981 .onKeyPress(phases: .down) { press in
80 guard template == nil, let match = templates.first(where: { $0.key == press.characters }) else { return .ignored }
81 choose(match)
82 guard template == nil else { return .ignored }
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 }
8296 return .handled
8397 }
8498 .onAppear {
@@ -119,6 +133,7 @@ struct CaptureView: View {
119133 problem = loaded.problems.first
120134 template = nil
121135 editing = false
136 keys = ""
122137 let clocked = clock.running
123138 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) }
124139 context = CaptureContext(
@@ -149,6 +164,7 @@ struct CaptureView: View {
149164
150165 private func choose(_ chosen: CaptureTemplate) {
151166 template = chosen
167 keys = ""
152168 // `%^g` offers the target file's tags.
153169 let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first)
154170 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 {
818818 let workspace: WorkspaceModel
819819 let open: (FileEntry) -> Void
820820 @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
821823 @Environment(\.dismiss) private var dismiss
822824
823825 var body: some View {
824826 let matches = workspace.quickOpen(query)
827 let current = min(selected, matches.count - 1)
825828 VStack(spacing: 0) {
826829 TextField("Open file", text: $query)
827830 .textFieldStyle(.plain)
828831 .font(.title3)
829832 .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 }
831843 Divider()
832 List(matches) { entry in
833 Button {
834 open(entry)
835 } label: {
836 VStack(alignment: .leading, spacing: 2) {
837 Text(entry.name)
838 Text(entry.relativePath).font(.caption).foregroundStyle(.secondary)
844 ScrollViewReader { proxy in
845 List(matches.indices, id: \.self) { index in
846 let entry = matches[index]
847 Button {
848 open(entry)
849 } label: {
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())
839856 }
857 .buttonStyle(.plain)
858 .listRowBackground(index == current ? Color.accentColor.opacity(0.25) : Color.clear)
859 .accessibilityAddTraits(index == current ? .isSelected : [])
840860 }
841 .buttonStyle(.plain)
861 .onChange(of: current) { if current >= 0 { proxy.scrollTo(current) } }
842862 }
843863 }
844864 .frame(width: 520, height: 360)
Sources/Orgstar/OrgstarApp.swift +2 −2
@@ -351,10 +351,10 @@ struct OrgstarApp: App {
351351 }
352352 CommandGroup(after: .sidebar) {
353353 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
354 .keyboardShortcut("o", modifiers: [.command, .option])
354355 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") }
355356 Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") }
356357 .keyboardShortcut("i", modifiers: [.command, .option])
357 .keyboardShortcut("o", modifiers: [.command, .option])
358358 MarkupToggle()
359359 }
360360 CommandGroup(after: .pasteboard) {
@@ -629,7 +629,7 @@ struct SettingsView: View {
629629 private var agenda: some View {
630630 Form {
631631 Section {
632 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
632 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...366)
633633 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
634634 Toggle("Include files in subfolders", isOn: $agendaSubfolders)
635635 }
Sources/Orgstar/SidebarFiles.swift +3 −3
@@ -35,8 +35,8 @@ extension Sidebar {
3535 session.openPaths.filter { $0 == path || $0.hasPrefix(path + "/") }
3636 }
3737
38 /// Whether `path` is an open file or a folder holding one.
39 func holdsOpenFile(_ path: String) -> Bool { !openFiles(under: path).isEmpty }
38 /// Whether `path` is an open file with unsaved edits or a folder holding one.
39 func holdsUnsavedFile(_ path: String) -> Bool { openFiles(under: path).contains(where: session.dirtyPaths.contains) }
4040
4141 func finish() {
4242 guard let operation = pending else { return }
@@ -90,7 +90,7 @@ extension View {
9090 .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) {
9191 Button("Move to Trash", role: .destructive) { sidebar.finish() }
9292 } 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) {
9494 Text("Unsaved changes to the open file will be lost.")
9595 } else {
9696 Text("You can put it back from the Trash in Finder.")