Collapsible outline pane !39

merged merged by cmc on 2026-10-05 18:21 UTC · krz/orgstar:outline-pane into main

3 files changed, +84 −41

Layout: unified · split

Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -37,6 +37,7 @@ public enum EditorCommands {
3737
3838 EditorCommand(id: "app.save", title: "Save", action: .app),
3939 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
40 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
4041 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
4142 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
4243 EditorCommand(id: "app.search", title: "Search Notes", action: .app),
Sources/Orgstar/ContentView.swift +79 −41
@@ -42,54 +42,57 @@ struct ContentView: View {
4242 @State private var selectedFile: String?
4343 /// A file that isn't text, shown as a preview instead of the open document.
4444 @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
4548 @State private var query = ""
4649 @FocusState private var searchFocused: Bool
4750 @Environment(\.openWindow) private var openWindow
4851 @Environment(\.openSettings) private var openSettings
4952
50 private var main: some View {
51 NavigationSplitView {
52 Sidebar(workspace: workspace, selection: $selectedFile) { commands.showingFolderPicker = true }
53 .navigationSplitViewColumnWidth(min: 200, ideal: 260)
54 } content: {
55 Group {
56 if query.isEmpty {
57 OutlineColumn(session: session)
58 } else {
59 SearchResults(results: workspace.search(query, overlay: workspace.overlay(path: session.path, document: session.document))) { location in
60 guard location.path == session.path || UnsavedChanges.resolve(session) else { return }
61 selectedFile = location.path
62 session.show(location)
53 /// Whether the outline pane shows: remembered for org files, hidden by default for
54 /// other files, and always shown while search results are up.
55 private var outlineShown: Bool {
56 !query.isEmpty || (previewPath == nil && (session.isOrg ? orgOutlineShown : plainOutlineShown))
57 }
58
59 private func toggleOutline() {
60 if session.isOrg { orgOutlineShown.toggle() } else { plainOutlineShown.toggle() }
61 }
62
63 @ViewBuilder
64 private var detail: some View {
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)
6373 }
6474 }
65 }
66 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
67 } detail: {
68 if let previewPath {
69 FilePreview(path: previewPath)
70 } else if session.path != nil {
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 }
75 } else {
76 ContentUnavailableView(
77 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
78 systemImage: "doc.text",
79 description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.")
80 )
8581 }
86 .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
87 .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
88 .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
89 .searchFocused($searchFocused)
90 .toolbar {
91 if workspace.isIndexing { ProgressView().controlSize(.small) }
92 if let running = clock.running {
82 }
83
84 @ToolbarContentBuilder
85 private var toolbarItems: some ToolbarContent {
86 ToolbarItem(placement: .navigation) {
87 Button(outlineShown ? "Hide Outline" : "Show Outline", systemImage: "list.bullet.indent") { toggleOutline() }
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 {
9396 Menu {
9497 Button("Clock Out") { perform("app.clock.out") }
9598 Button("Cancel Clock") { perform("app.clock.cancel") }
@@ -101,11 +104,45 @@ struct ContentView: View {
101104 }
102105 .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))")
103106 }
104 if session.conflict != nil {
107 }
108 if session.conflict != nil {
109 ToolbarItem {
105110 Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true }
106111 .help("The file changed on disk, and the changes conflict with yours")
107112 }
108113 }
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 }
109146 .onChange(of: selectedFile) { old, path in
110147 guard let path else { return }
111148 if !DocumentSession.isOrgPath(path), !FileTypes.isText(path) {
@@ -344,6 +381,7 @@ struct ContentView: View {
344381 case "app.save":
345382 if !session.save(), session.conflict != nil { commands.showingConflict = true }
346383 case "app.quit": NSApp.terminate(nil)
384 case "app.toggle-outline": toggleOutline()
347385 case "app.quick-open": commands.showingQuickOpen = true
348386 case "app.palette": commands.showingPalette = true
349387 case "app.search": commands.focusSearch = true
Sources/Orgstar/OrgstarApp.swift +4
@@ -219,6 +219,10 @@ struct OrgstarApp: App {
219219 .keyboardShortcut("s")
220220 .disabled(session.path == nil || session.isReadOnly)
221221 }
222 CommandGroup(after: .sidebar) {
223 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
224 .keyboardShortcut("o", modifiers: [.command, .option])
225 }
222226 CommandGroup(after: .textEditing) {
223227 Button("Search Notes") { commands.focusSearch = true }
224228 .keyboardShortcut("f", modifiers: [.command, .shift])