krz/orgstar

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

Sources/Orgstar/ContentView.swift

10c33661279a1fb0e8d3dd51509db9637778769e
orgstar/Sources/Orgstar/ContentView.swift history · blame · raw

220 lines · 8385 bytes

  1import OrgApp
  2import OrgIndex
  3import SwiftUI
  4
  5struct ContentView: View {
  6    let workspace: WorkspaceModel
  7    let session: DocumentSession
  8    @Bindable var commands: AppCommands
  9    @AppStorage("saveMode") private var saveMode = SaveMode.automatic
 10    @State private var selectedFile: String?
 11    @State private var query = ""
 12    @FocusState private var searchFocused: Bool
 13
 14    var body: some View {
 15        NavigationSplitView {
 16            Sidebar(workspace: workspace, selection: $selectedFile)
 17                .navigationSplitViewColumnWidth(min: 200, ideal: 260)
 18        } content: {
 19            Group {
 20                if query.isEmpty {
 21                    OutlineColumn(session: session)
 22                } else {
 23                    SearchResults(results: workspace.search(query, overlay: workspace.overlay(path: session.path, document: session.document))) { location in
 24                        guard location.path == session.path || UnsavedChanges.resolve(session) else { return }
 25                        selectedFile = location.path
 26                        session.show(location)
 27                    }
 28                }
 29            }
 30            .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
 31        } detail: {
 32            if session.path != nil {
 33                EditorView(session: session)
 34            } else {
 35                ContentUnavailableView(
 36                    workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
 37                    systemImage: "doc.text",
 38                    description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.")
 39                )
 40            }
 41        }
 42        .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
 43        .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
 44        .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
 45        .searchFocused($searchFocused)
 46        .toolbar {
 47            if workspace.isIndexing { ProgressView().controlSize(.small) }
 48            if session.conflict != nil {
 49                Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true }
 50                    .help("The file changed on disk, and the changes conflict with yours")
 51            }
 52        }
 53        .onChange(of: selectedFile) { old, path in
 54            guard let path, path != session.path else { return }
 55            if UnsavedChanges.resolve(session) {
 56                session.open(path)
 57            } else {
 58                selectedFile = old
 59            }
 60        }
 61        .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
 62        .onChange(of: session.conflict) { _, conflict in
 63            if conflict != nil { commands.showingConflict = true }
 64        }
 65        .alert("Conflicting changes", isPresented: $commands.showingConflict) {
 66            Button("Keep Mine") { session.keepMine() }
 67            Button("Use Disk Version") { session.useDiskVersion() }
 68            Button("Decide Later", role: .cancel) {}
 69        } message: {
 70            Text((session.conflict ?? "") + " The version you don't keep is saved in the recovery folder.")
 71        }
 72        .onChange(of: workspace.changedFile?.version) {
 73            if let path = workspace.changedFile?.path { session.fileChanged(path) }
 74        }
 75        .onChange(of: commands.focusSearch) { _, focus in
 76            if focus {
 77                searchFocused = true
 78                commands.focusSearch = false
 79            }
 80        }
 81        .fileImporter(isPresented: $commands.showingFolderPicker, allowedContentTypes: [.folder]) { result in
 82            guard case .success(let url) = result else { return }
 83            Task {
 84                do { try await workspace.addRoot(url) } catch { workspace.lastError = error.localizedDescription }
 85            }
 86        }
 87        .sheet(isPresented: $commands.showingQuickOpen) {
 88            QuickOpenView(workspace: workspace) { entry in
 89                selectedFile = entry.path
 90                commands.showingQuickOpen = false
 91            }
 92        }
 93        .alert("Orgstar", isPresented: errorShown) {
 94            Button("OK") {
 95                workspace.lastError = nil
 96                session.lastError = nil
 97            }
 98        } message: {
 99            Text(workspace.lastError ?? session.lastError ?? "")
100        }
101        .task {
102            DevelopmentHooks.scheduleSnapshot()
103            if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
104            await workspace.refresh()
105            workspace.startWatching()
106        }
107    }
108
109    private var errorShown: Binding<Bool> {
110        Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
111    }
112}
113
114struct Sidebar: View {
115    let workspace: WorkspaceModel
116    @Binding var selection: String?
117
118    var body: some View {
119        List(selection: $selection) {
120            ForEach(workspace.rootPaths, id: \.self) { root in
121                Section((root as NSString).lastPathComponent) {
122                    OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
123                        Label(node.name, systemImage: icon(node))
124                            .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary)
125                            .tag(node.file?.path)
126                    }
127                }
128                .contextMenu {
129                    Button("Remove Folder") { try? workspace.removeRoot(path: root) }
130                }
131            }
132        }
133        .listStyle(.sidebar)
134    }
135
136    private func icon(_ node: FileTreeNode) -> String {
137        guard let file = node.file else { return "folder" }
138        switch file.kind {
139        case .org: return "doc.text"
140        case .archive: return "archivebox"
141        case .conflict: return "exclamationmark.triangle"
142        }
143    }
144}
145
146struct OutlineColumn: View {
147    let session: DocumentSession
148
149    var body: some View {
150        List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in
151            Button {
152                session.reveal(entry.start)
153            } label: {
154                Text(entry.title.isEmpty ? "(untitled)" : entry.title)
155                    .lineLimit(1)
156                    .padding(.leading, CGFloat(entry.level - 1) * 12)
157            }
158            .buttonStyle(.plain)
159        }
160        .overlay {
161            if session.path != nil, session.outline.isEmpty {
162                ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
163            }
164        }
165    }
166}
167
168struct SearchResults: View {
169    let results: [HeadingLocation]
170    let open: (HeadingLocation) -> Void
171
172    var body: some View {
173        List(results, id: \.self) { location in
174            Button {
175                open(location)
176            } label: {
177                VStack(alignment: .leading, spacing: 2) {
178                    Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(1)
179                    Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary)
180                }
181            }
182            .buttonStyle(.plain)
183        }
184        .overlay {
185            if results.isEmpty { ContentUnavailableView.search }
186        }
187    }
188}
189
190struct QuickOpenView: View {
191    let workspace: WorkspaceModel
192    let open: (FileEntry) -> Void
193    @State private var query = ""
194    @Environment(\.dismiss) private var dismiss
195
196    var body: some View {
197        let matches = workspace.quickOpen(query)
198        VStack(spacing: 0) {
199            TextField("Open file", text: $query)
200                .textFieldStyle(.plain)
201                .font(.title3)
202                .padding(12)
203                .onSubmit { if let first = matches.first { open(first) } }
204            Divider()
205            List(matches) { entry in
206                Button {
207                    open(entry)
208                } label: {
209                    VStack(alignment: .leading, spacing: 2) {
210                        Text(entry.name)
211                        Text(entry.relativePath).font(.caption).foregroundStyle(.secondary)
212                    }
213                }
214                .buttonStyle(.plain)
215            }
216        }
217        .frame(width: 520, height: 360)
218        .onExitCommand { dismiss() }
219    }
220}