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 | 20 | @State private var selection: TextSelection? |
| 21 | 21 | @State private var context = CaptureContext() |
| 22 | 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 | 25 | @FocusState private var focused: Bool |
| 24 | 26 | @Environment(\.dismissWindow) private var dismissWindow |
| 25 | 27 | |
| @@ -57,8 +59,8 @@ struct CaptureView: View { |
| 57 | 59 | .keyboardShortcut(.defaultAction) |
| 58 | 60 | } |
| 59 | 61 | } 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 |
| 62 | 64 | Button { |
| 63 | 65 | choose(template) |
| 64 | 66 | } label: { |
| @@ -71,14 +73,26 @@ struct CaptureView: View { |
| 71 | 73 | } |
| 72 | 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 | 79 | .padding(16) |
| 78 | 80 | .frame(width: 520) |
| 79 | 81 | .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 | } |
| 82 | 96 | return .handled |
| 83 | 97 | } |
| 84 | 98 | .onAppear { |
| @@ -119,6 +133,7 @@ struct CaptureView: View { |
| 119 | 133 | problem = loaded.problems.first |
| 120 | 134 | template = nil |
| 121 | 135 | editing = false |
| 136 | keys = "" |
| 122 | 137 | let clocked = clock.running |
| 123 | 138 | let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) } |
| 124 | 139 | context = CaptureContext( |
| @@ -149,6 +164,7 @@ struct CaptureView: View { |
| 149 | 164 | |
| 150 | 165 | private func choose(_ chosen: CaptureTemplate) { |
| 151 | 166 | template = chosen |
| 167 | keys = "" |
| 152 | 168 | // `%^g` offers the target file's tags. |
| 153 | 169 | let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first) |
| 154 | 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 | 818 | let workspace: WorkspaceModel |
| 819 | 819 | let open: (FileEntry) -> Void |
| 820 | 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 | 823 | @Environment(\.dismiss) private var dismiss |
| 822 | 824 | |
| 823 | 825 | var body: some View { |
| 824 | 826 | let matches = workspace.quickOpen(query) |
| 827 | let current = min(selected, matches.count - 1) |
| 825 | 828 | VStack(spacing: 0) { |
| 826 | 829 | TextField("Open file", text: $query) |
| 827 | 830 | .textFieldStyle(.plain) |
| 828 | 831 | .font(.title3) |
| 829 | 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 | 843 | 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()) |
| 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 | 864 | .frame(width: 520, height: 360) |
Sources/Orgstar/OrgstarApp.swift
+2 −2
| @@ -351,10 +351,10 @@ struct OrgstarApp: App { |
| 351 | 351 | } |
| 352 | 352 | CommandGroup(after: .sidebar) { |
| 353 | 353 | Button("Show or Hide Outline") { commands.run("app.toggle-outline") } |
| 354 | .keyboardShortcut("o", modifiers: [.command, .option]) |
| 354 | 355 | Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") } |
| 355 | 356 | Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") } |
| 356 | 357 | .keyboardShortcut("i", modifiers: [.command, .option]) |
| 357 | | .keyboardShortcut("o", modifiers: [.command, .option]) |
| 358 | 358 | MarkupToggle() |
| 359 | 359 | } |
| 360 | 360 | CommandGroup(after: .pasteboard) { |
| @@ -629,7 +629,7 @@ struct SettingsView: View { |
| 629 | 629 | private var agenda: some View { |
| 630 | 630 | Form { |
| 631 | 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 | 633 | Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0) |
| 634 | 634 | Toggle("Include files in subfolders", isOn: $agendaSubfolders) |
| 635 | 635 | } |
Sources/Orgstar/SidebarFiles.swift
+3 −3
| @@ -35,8 +35,8 @@ extension Sidebar { |
| 35 | 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. |
| 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) } |
| 40 | 40 | |
| 41 | 41 | func finish() { |
| 42 | 42 | guard let operation = pending else { return } |
| @@ -90,7 +90,7 @@ extension View { |
| 90 | 90 | .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) { |
| 91 | 91 | Button("Move to Trash", role: .destructive) { sidebar.finish() } |
| 92 | 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 | 94 | Text("Unsaved changes to the open file will be lost.") |
| 95 | 95 | } else { |
| 96 | 96 | Text("You can put it back from the Trash in Finder.") |