krz/orgstar

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

Sources/Orgstar/ContentView.swift

85c633a83a4744bef9dde8b52462f9c2a1c037e4
orgstar/Sources/Orgstar/ContentView.swift history · blame · raw

259 lines · 10210 bytes

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