krz/orgstar

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

Commit 771145d436

771145d436cf29bfc925517d8abc5a410c9a2f7b

parent: 87e24faa39

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 18:36 UTC

Find and replace

Edit ▸ Find: Find (⌘F), Find and Replace (⌥⌘F), Find Next/Previous
(⌘G/⇧⌘G), Use Selection for Find (⌘E), also in the palette. M-% opens
the replace bar in the Emacs preset. A match inside folded text
unfolds its headings instead of staying hidden.

Layout: unified · split

Sources/OrgCore/Keymap/Presets.swift +3
@@ -481,6 +481,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
481481 [[bind]]
482482 keys = "C-r"
483483 command = "edit.find"
484 [[bind]]
485 keys = "M-%"
486 command = "edit.replace"
484487
485488 # Files and commands
486489 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +8 −1
@@ -1,4 +1,5 @@
11#if os(macOS)
2import AppKit
23import OrgCore
34
45/// Every command a key, menu or the palette can run, by id.
@@ -13,6 +14,8 @@ public struct EditorCommand: Sendable {
1314 case edit(selector: String, extending: String? = nil)
1415 /// Mark handling in the editor.
1516 case mark
17 /// The find bar.
18 case find(NSTextFinder.Action)
1619 /// Handled by the app (save, quick open, palette).
1720 case app
1821 }
@@ -83,7 +86,11 @@ public enum EditorCommands {
8386 edit("copy-region", "Copy Region", "copy:"),
8487 edit("undo", "Undo", "undo:"),
8588 edit("transpose-chars", "Transpose Characters", "transpose:"),
86 edit("find", "Find", "performTextFinderAction:"),
89 EditorCommand(id: "edit.find", title: "Find", action: .find(.showFindInterface)),
90 EditorCommand(id: "edit.replace", title: "Find and Replace", action: .find(.showReplaceInterface)),
91 EditorCommand(id: "edit.find-next", title: "Find Next", action: .find(.nextMatch)),
92 EditorCommand(id: "edit.find-previous", title: "Find Previous", action: .find(.previousMatch)),
93 EditorCommand(id: "edit.use-selection-for-find", title: "Use Selection for Find", action: .find(.setSearchString)),
8794 EditorCommand(id: "edit.set-mark", title: "Set Mark", action: .mark),
8895 EditorCommand(id: "edit.keyboard-quit", title: "Quit", action: .mark),
8996 ]
Sources/OrgEditorAppKit/OrgEditor.swift +23 −9
@@ -286,7 +286,7 @@ public final class OrgEditor: NSObject {
286286 return switch EditorCommands.command(id)?.action {
287287 case .text(let command): command.applies(in: context)
288288 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
289 case .edit, .mark, .app: true
289 case .edit, .mark, .app, .find: true
290290 case nil: true
291291 }
292292 }
@@ -311,13 +311,13 @@ public final class OrgEditor: NSObject {
311311 if markActive, let extending {
312312 keepMark = true
313313 textView.doCommand(by: NSSelectorFromString(extending))
314 } else if selector == "performTextFinderAction:" {
315 let item = NSMenuItem()
316 item.tag = NSTextFinder.Action.showFindInterface.rawValue
317 textView.performTextFinderAction(item)
318314 } else {
319315 textView.doCommand(by: NSSelectorFromString(selector))
320316 }
317 case .find(let action):
318 let item = NSMenuItem()
319 item.tag = action.rawValue
320 textView.performTextFinderAction(item)
321321 case .mark:
322322 if id == "edit.set-mark" {
323323 let caret = textView.selectedRange().location
@@ -421,14 +421,20 @@ public final class OrgEditor: NSObject {
421421 /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
422422 public func reveal(_ offset: Int) {
423423 let target = max(0, min(offset, utf16Length))
424 unfold(around: target)
425 setCaret(target)
426 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
427 }
428
429 /// Unfolds the headings whose folds hide `offset`, leaving the selection alone.
430 func unfold(around offset: Int) {
424431 var state = foldState
425 for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end {
432 for entry in Presentation.outline(document.tree) where entry.start < offset && offset < entry.end {
426433 state.folds.remove(entry.start)
427434 state.bodyFolds.remove(entry.start)
428435 }
436 guard state != foldState else { return }
429437 setFolds(state)
430 setCaret(target)
431 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
432438 }
433439
434440 /// Applies edits through the text view as one undo step, not merged with typing around it.
@@ -653,7 +659,15 @@ public final class OrgEditor: NSObject {
653659 let selection = textView.selectedRange()
654660 defer { previousSelection = selection.location }
655661 onSelectionChange?(selection)
656 guard selection.length == 0, let range = hidden.containing(selection.location) else { return }
662 // A selection reaching into folded text, as a find match does, unfolds it.
663 if selection.length > 0 {
664 if hidden.containing(selection.location) != nil || hidden.containing(NSMaxRange(selection) - 1) != nil {
665 unfold(around: selection.location)
666 unfold(around: NSMaxRange(selection) - 1)
667 }
668 return
669 }
670 guard let range = hidden.containing(selection.location) else { return }
657671 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
658672 }
659673}
Sources/Orgstar/OrgstarApp.swift +25
@@ -223,6 +223,10 @@ struct OrgstarApp: App {
223223 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
224224 .keyboardShortcut("o", modifiers: [.command, .option])
225225 }
226 CommandGroup(after: .pasteboard) {
227 Divider()
228 FindMenu(commands: commands)
229 }
226230 CommandGroup(after: .textEditing) {
227231 Button("Search Notes") { commands.focusSearch = true }
228232 .keyboardShortcut("f", modifiers: [.command, .shift])
@@ -263,6 +267,27 @@ struct OrgMenuItems: View {
263267 }
264268}
265269
270/// Edit ▸ Find: the editor's find bar.
271struct FindMenu: View {
272 let commands: AppCommands
273
274 var body: some View {
275 Menu("Find") {
276 item("Find…", "edit.find", "f")
277 item("Find and Replace…", "edit.replace", "f", [.command, .option])
278 item("Find Next", "edit.find-next", "g")
279 item("Find Previous", "edit.find-previous", "g", [.command, .shift])
280 item("Use Selection for Find", "edit.use-selection-for-find", "e")
281 }
282 }
283
284 private func item(_ title: String, _ id: String, _ key: KeyEquivalent, _ modifiers: EventModifiers = .command) -> some View {
285 Button(title) { commands.editor?.run(id) }
286 .keyboardShortcut(key, modifiers: modifiers)
287 .disabled(commands.editor == nil)
288 }
289}
290
266291/// Window ▸ Agenda.
267292struct AgendaMenuItem: View {
268293 @Environment(\.openWindow) private var openWindow
Tests/OrgEditorAppKitTests/FindTests.swift added +26
@@ -0,0 +1,26 @@
1import AppKit
2import OrgCore
3import Testing
4@testable import OrgEditorAppKit
5
6@MainActor
7struct FindTests {
8 @Test func replaceShowsTheFindBar() {
9 let h = Harness("* Heading\nbody\n")
10 #expect(h.textView.enclosingScrollView?.isFindBarVisible != true)
11 h.editor.run("edit.replace")
12 #expect(h.textView.enclosingScrollView?.isFindBarVisible == true)
13 }
14
15 @Test func aMatchInFoldedTextUnfoldsIt() {
16 let text = "* Heading\nhidden needle\n* Next\n"
17 let h = Harness(text)
18 h.caret(at: 0)
19 h.editor.cycleAtCaret()
20 #expect(!h.editor.foldState.folds.isEmpty)
21 let needle = (text as NSString).range(of: "needle")
22 h.textView.setSelectedRange(needle)
23 #expect(h.editor.foldState.folds.isEmpty)
24 #expect(h.textView.selectedRange() == needle)
25 }
26}