krz/orgstar

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

Commit 176fc78498

176fc78498d9ba962fa963bbeca0dd433d9e0bab

parent: abca8dc581

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 17:46 UTC

Sidebar: collapsible and removable folders

Folder sections collapse (remembered), and a header menu offers
Collapse, Show in Finder and Remove from Sidebar. A footer adds or
removes folders; removing asks first and leaves the files on disk.

Layout: unified · split

Sources/Orgstar/ContentView.swift +64 −5
@@ -46,7 +46,7 @@ struct ContentView: View {
4646
4747 private var main: some View {
4848 NavigationSplitView {
49 Sidebar(workspace: workspace, selection: $selectedFile)
49 Sidebar(workspace: workspace, selection: $selectedFile) { commands.showingFolderPicker = true }
5050 .navigationSplitViewColumnWidth(min: 200, ideal: 260)
5151 } content: {
5252 Group {
@@ -372,23 +372,82 @@ struct ContentView: View {
372372struct Sidebar: View {
373373 let workspace: WorkspaceModel
374374 @Binding var selection: String?
375 var addFolder: () -> Void = {}
376 /// Folders shown collapsed, by path, one per line.
377 @AppStorage("collapsedFolders") private var collapsedText = ""
378 @State private var removing: String?
379
380 private var collapsed: Set<String> { Set(collapsedText.split(separator: "\n").map(String.init)) }
381
382 private func expanded(_ root: String) -> Binding<Bool> {
383 Binding {
384 !collapsed.contains(root)
385 } set: { isExpanded in
386 var set = collapsed
387 if isExpanded { set.remove(root) } else { set.insert(root) }
388 collapsedText = set.sorted().joined(separator: "\n")
389 }
390 }
375391
376392 var body: some View {
377393 List(selection: $selection) {
378394 ForEach(workspace.rootPaths, id: \.self) { root in
379 Section((root as NSString).lastPathComponent) {
395 Section(isExpanded: expanded(root)) {
380396 OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
381397 Label(node.name, systemImage: icon(node))
382398 .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary)
383399 .tag(node.file?.path)
384400 }
385 }
386 .contextMenu {
387 Button("Remove Folder") { try? workspace.removeRoot(path: root) }
401 } header: {
402 Text((root as NSString).lastPathComponent)
403 .help(root)
404 .contextMenu { folderMenu(root) }
388405 }
389406 }
390407 }
391408 .listStyle(.sidebar)
409 .safeAreaInset(edge: .bottom, spacing: 0) {
410 HStack(spacing: 4) {
411 Button("Add Folder…", systemImage: "plus", action: addFolder)
412 .help("Add a folder (⇧⌘O)")
413 Menu {
414 ForEach(workspace.rootPaths, id: \.self) { root in
415 Button((root as NSString).lastPathComponent) { removing = root }
416 }
417 } label: {
418 Label("Remove Folder", systemImage: "minus")
419 }
420 .menuIndicator(.hidden)
421 .fixedSize()
422 .disabled(workspace.rootPaths.isEmpty)
423 .help("Remove a folder from the sidebar")
424 Spacer()
425 }
426 .labelStyle(.iconOnly)
427 .buttonStyle(.borderless)
428 .menuStyle(.borderlessButton)
429 .padding(.horizontal, 10)
430 .padding(.vertical, 6)
431 }
432 .confirmationDialog(
433 "Remove “\(removing.map { ($0 as NSString).lastPathComponent } ?? "")” from the sidebar?",
434 isPresented: Binding { removing != nil } set: { if !$0 { removing = nil } }
435 ) {
436 Button("Remove") {
437 if let root = removing { try? workspace.removeRoot(path: root) }
438 removing = nil
439 }
440 } message: {
441 Text("The folder and its files stay on disk.")
442 }
443 }
444
445 @ViewBuilder
446 private func folderMenu(_ root: String) -> some View {
447 Button(collapsed.contains(root) ? "Expand" : "Collapse") { expanded(root).wrappedValue.toggle() }
448 Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: root)]) }
449 Divider()
450 Button("Remove from Sidebar…") { removing = root }
392451 }
393452
394453 private func icon(_ node: FileTreeNode) -> String {