krz/orgstar

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

Commit 6dc096637d

6dc096637db6e263db38fad845d46bc852cd3962

parent: 99566e2d13

Verified · cmc

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

Collapsible outline pane

The outline (and search results) sit in a pane beside the editor that
View ▸ Show or Hide Outline (⌥⌘O), the toolbar button or the palette
toggles. Org files remember the choice; other files start with it
hidden.

Layout: unified · split

Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -37,6 +37,7 @@ public enum EditorCommands {
37 37
38 EditorCommand(id: "app.save", title: "Save", action: .app), 38 EditorCommand(id: "app.save", title: "Save", action: .app),
39 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), 39 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
40 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
40 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), 41 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
41 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), 42 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
42 EditorCommand(id: "app.search", title: "Search Notes", action: .app), 43 EditorCommand(id: "app.search", title: "Search Notes", action: .app),
Sources/Orgstar/ContentView.swift +79 −41
@@ -42,54 +42,57 @@ struct ContentView: View {
42 @State private var selectedFile: String? 42 @State private var selectedFile: String?
43 /// A file that isn't text, shown as a preview instead of the open document. 43 /// A file that isn't text, shown as a preview instead of the open document.
44 @State private var previewPath: String? 44 @State private var previewPath: String?
45 @AppStorage("outlineShown") private var orgOutlineShown = true
46 /// The outline for a file that isn't org, off until asked for.
47 @State private var plainOutlineShown = false
45 @State private var query = "" 48 @State private var query = ""
46 @FocusState private var searchFocused: Bool 49 @FocusState private var searchFocused: Bool
47 @Environment(\.openWindow) private var openWindow 50 @Environment(\.openWindow) private var openWindow
48 @Environment(\.openSettings) private var openSettings 51 @Environment(\.openSettings) private var openSettings
49 52
50 private var main: some View { 53 /// Whether the outline pane shows: remembered for org files, hidden by default for
51 NavigationSplitView { 54 /// other files, and always shown while search results are up.
52 Sidebar(workspace: workspace, selection: $selectedFile) { commands.showingFolderPicker = true } 55 private var outlineShown: Bool {
53 .navigationSplitViewColumnWidth(min: 200, ideal: 260) 56 !query.isEmpty || (previewPath == nil && (session.isOrg ? orgOutlineShown : plainOutlineShown))
54 } content: { 57 }
55 Group { 58
56 if query.isEmpty { 59 private func toggleOutline() {
57 OutlineColumn(session: session) 60 if session.isOrg { orgOutlineShown.toggle() } else { plainOutlineShown.toggle() }
58 } else { 61 }
59 SearchResults(results: workspace.search(query, overlay: workspace.overlay(path: session.path, document: session.document))) { location in 62
60 guard location.path == session.path || UnsavedChanges.resolve(session) else { return } 63 @ViewBuilder
61 selectedFile = location.path 64 private var detail: some View {
62 session.show(location) 65 if let previewPath {
66 FilePreview(path: previewPath)
67 } else if session.path != nil {
68 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal)
69 .safeAreaInset(edge: .bottom, spacing: 0) {
70 VStack(spacing: 0) {
71 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
72 EchoArea(commands: commands)
63 } 73 }
64 } 74 }
65 } 75 } else {
66 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) 76 ContentUnavailableView(
67 } detail: { 77 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
68 if let previewPath { 78 systemImage: "doc.text",
69 FilePreview(path: previewPath) 79 description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.")
70 } else if session.path != nil { 80 )
71 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal)
72 .safeAreaInset(edge: .bottom, spacing: 0) {
73 VStack(spacing: 0) {
74 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
75 EchoArea(commands: commands)
76 }
77 }
78 } else {
79 ContentUnavailableView(
80 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
81 systemImage: "doc.text",
82 description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.")
83 )
84 }
85 } 81 }
86 .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar") 82 }
87 .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "") 83
88 .searchable(text: $query, placement: .toolbar, prompt: "Search notes") 84 @ToolbarContentBuilder
89 .searchFocused($searchFocused) 85 private var toolbarItems: some ToolbarContent {
90 .toolbar { 86 ToolbarItem(placement: .navigation) {
91 if workspace.isIndexing { ProgressView().controlSize(.small) } 87 Button(outlineShown ? "Hide Outline" : "Show Outline", systemImage: "list.bullet.indent") { toggleOutline() }
92 if let running = clock.running { 88 .help(outlineShown ? "Hide the outline (⌥⌘O)" : "Show the outline (⌥⌘O)")
89 .disabled(!query.isEmpty || previewPath != nil || session.path == nil)
90 }
91 if workspace.isIndexing {
92 ToolbarItem { ProgressView().controlSize(.small) }
93 }
94 if let running = clock.running {
95 ToolbarItem {
93 Menu { 96 Menu {
94 Button("Clock Out") { perform("app.clock.out") } 97 Button("Clock Out") { perform("app.clock.out") }
95 Button("Cancel Clock") { perform("app.clock.cancel") } 98 Button("Cancel Clock") { perform("app.clock.cancel") }
@@ -101,11 +104,45 @@ struct ContentView: View {
101 } 104 }
102 .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))") 105 .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))")
103 } 106 }
104 if session.conflict != nil { 107 }
108 if session.conflict != nil {
109 ToolbarItem {
105 Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true } 110 Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true }
106 .help("The file changed on disk, and the changes conflict with yours") 111 .help("The file changed on disk, and the changes conflict with yours")
107 } 112 }
108 } 113 }
114 }
115
116 private var main: some View {
117 NavigationSplitView {
118 Sidebar(workspace: workspace, selection: $selectedFile) { commands.showingFolderPicker = true }
119 .navigationSplitViewColumnWidth(min: 200, ideal: 260)
120 } detail: {
121 HSplitView {
122 if outlineShown {
123 Group {
124 if query.isEmpty {
125 OutlineColumn(session: session)
126 } else {
127 SearchResults(results: workspace.search(query, overlay: workspace.overlay(path: session.path, document: session.document))) { location in
128 guard location.path == session.path || UnsavedChanges.resolve(session) else { return }
129 selectedFile = location.path
130 session.show(location)
131 }
132 }
133 }
134 .frame(minWidth: 160, idealWidth: 220, maxWidth: 360)
135 }
136 detail
137 .frame(minWidth: 300, maxWidth: .infinity)
138 }
139 }
140 .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
141 .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
142 .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
143 .searchFocused($searchFocused)
144 .toolbar { toolbarItems }
145 .onChange(of: session.path) { plainOutlineShown = false }
109 .onChange(of: selectedFile) { old, path in 146 .onChange(of: selectedFile) { old, path in
110 guard let path else { return } 147 guard let path else { return }
111 if !DocumentSession.isOrgPath(path), !FileTypes.isText(path) { 148 if !DocumentSession.isOrgPath(path), !FileTypes.isText(path) {
@@ -344,6 +381,7 @@ struct ContentView: View {
344 case "app.save": 381 case "app.save":
345 if !session.save(), session.conflict != nil { commands.showingConflict = true } 382 if !session.save(), session.conflict != nil { commands.showingConflict = true }
346 case "app.quit": NSApp.terminate(nil) 383 case "app.quit": NSApp.terminate(nil)
384 case "app.toggle-outline": toggleOutline()
347 case "app.quick-open": commands.showingQuickOpen = true 385 case "app.quick-open": commands.showingQuickOpen = true
348 case "app.palette": commands.showingPalette = true 386 case "app.palette": commands.showingPalette = true
349 case "app.search": commands.focusSearch = true 387 case "app.search": commands.focusSearch = true
Sources/Orgstar/OrgstarApp.swift +4
@@ -219,6 +219,10 @@ struct OrgstarApp: App {
219 .keyboardShortcut("s") 219 .keyboardShortcut("s")
220 .disabled(session.path == nil || session.isReadOnly) 220 .disabled(session.path == nil || session.isReadOnly)
221 } 221 }
222 CommandGroup(after: .sidebar) {
223 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
224 .keyboardShortcut("o", modifiers: [.command, .option])
225 }
222 CommandGroup(after: .textEditing) { 226 CommandGroup(after: .textEditing) {
223 Button("Search Notes") { commands.focusSearch = true } 227 Button("Search Notes") { commands.focusSearch = true }
224 .keyboardShortcut("f", modifiers: [.command, .shift]) 228 .keyboardShortcut("f", modifiers: [.command, .shift])