krz/orgstar

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

Sources/Orgstar/ContentView.swift

b8d091bdcbf2889c220b12b2d0640d50cea17c24
orgstar/Sources/Orgstar/ContentView.swift history · blame · raw

478 lines · 20905 bytes

  1import AppKit
  2import OrgApp
  3import OrgCore
  4import OrgEditorAppKit
  5import OrgIndex
  6import SwiftUI
  7
  8struct ContentView: View {
  9    let workspace: WorkspaceModel
 10    let session: DocumentSession
 11    @Bindable var commands: AppCommands
 12    let scheduler: ReminderScheduler?
 13    let clock: ClockModel
 14    @AppStorage("reminders") private var remindersEnabled = true
 15    @AppStorage("reminderLead") private var reminderLead = 12
 16    @AppStorage("agendaSubfolders") private var agendaSubfolders = false
 17    @State private var reminderAgenda: AgendaModel?
 18    @State private var trust = TrustStore()
 19    @State private var hotKey: GlobalHotKey?
 20    @State private var statusItem: ClockStatusItem?
 21    @AppStorage("globalCapture") private var globalCapture = true
 22    @State private var hour = 0
 23    @AppStorage("saveMode") private var saveMode = SaveMode.automatic
 24    @AppStorage("keymap") private var preset = KeymapPreset.emacs
 25    @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
 26    @State private var keymap = KeymapPreset.emacs.keymap
 27    @AppStorage("tagsColumn") private var tagsColumn = -77
 28    @AppStorage("headingAfterSubtree") private var headingAfterSubtree = true
 29    @AppStorage("splitLine") private var splitLine = false
 30    @AppStorage("alphabeticalLists") private var alphabeticalLists = true
 31    @AppStorage("fillColumn") private var fillColumn = 80
 32    @AppStorage("hiddenMarkers") private var hiddenMarkers = true
 33    @AppStorage("prettyEntities") private var prettyEntities = true
 34
 35    private var options: EditingOptions {
 36        Preferences.options(
 37            tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists,
 38            fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities
 39        )
 40    }
 41    @State private var selectedFile: String?
 42    @State private var query = ""
 43    @FocusState private var searchFocused: Bool
 44    @Environment(\.openWindow) private var openWindow
 45    @Environment(\.openSettings) private var openSettings
 46
 47    private var main: some View {
 48        NavigationSplitView {
 49            Sidebar(workspace: workspace, selection: $selectedFile)
 50                .navigationSplitViewColumnWidth(min: 200, ideal: 260)
 51        } content: {
 52            Group {
 53                if query.isEmpty {
 54                    OutlineColumn(session: session)
 55                } else {
 56                    SearchResults(results: workspace.search(query, overlay: workspace.overlay(path: session.path, document: session.document))) { location in
 57                        guard location.path == session.path || UnsavedChanges.resolve(session) else { return }
 58                        selectedFile = location.path
 59                        session.show(location)
 60                    }
 61                }
 62            }
 63            .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
 64        } detail: {
 65            if session.path != nil {
 66                EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal)
 67                    .safeAreaInset(edge: .bottom, spacing: 0) {
 68                        VStack(spacing: 0) {
 69                            ModeLine(session: session, commands: commands)
 70                            EchoArea(commands: commands)
 71                        }
 72                    }
 73            } else {
 74                ContentUnavailableView(
 75                    workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
 76                    systemImage: "doc.text",
 77                    description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.")
 78                )
 79            }
 80        }
 81        .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
 82        .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
 83        .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
 84        .searchFocused($searchFocused)
 85        .toolbar {
 86            if workspace.isIndexing { ProgressView().controlSize(.small) }
 87            if let running = clock.running {
 88                Menu {
 89                    Button("Clock Out") { perform("app.clock.out") }
 90                    Button("Cancel Clock") { perform("app.clock.cancel") }
 91                    Button("Go to Clocked Entry") { perform("app.clock.goto") }
 92                } label: {
 93                    TimelineView(.periodic(from: .now, by: 30)) { context in
 94                        Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1)
 95                    }
 96                }
 97                .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))")
 98            }
 99            if session.conflict != nil {
100                Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true }
101                    .help("The file changed on disk, and the changes conflict with yours")
102            }
103        }
104        .onChange(of: selectedFile) { old, path in
105            guard let path, path != session.path else { return }
106            if UnsavedChanges.resolve(session) {
107                session.open(path)
108            } else {
109                selectedFile = old
110            }
111        }
112        .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
113        .onChange(of: options, initial: true) { _, options in session.options = options }
114        .onChange(of: preset, initial: true) { loadKeymap() }
115        .sheet(isPresented: $commands.showingPalette) {
116            PaletteView(keymap: keymap) { id in
117                commands.showingPalette = false
118                // After the sheet closes, so app commands can present their own.
119                DispatchQueue.main.async { perform(id) }
120            }
121        }
122        .onChange(of: session.conflict) { _, conflict in
123            if conflict != nil { commands.showingConflict = true }
124        }
125        .alert("Conflicting changes", isPresented: $commands.showingConflict) {
126            Button("Keep Mine") { session.keepMine() }
127            Button("Use Disk Version") { session.useDiskVersion() }
128            Button("Decide Later", role: .cancel) {}
129        } message: {
130            Text((session.conflict ?? "") + " The version you don't keep is saved in the recovery folder.")
131        }
132        .onChange(of: workspace.changedFile?.version) {
133            if let path = workspace.changedFile?.path { session.fileChanged(path) }
134        }
135    }
136
137    var body: some View {
138        main
139        .onChange(of: clock.running, initial: true) { _, running in
140            if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } }
141            statusItem?.update(running)
142        }
143        .onChange(of: globalCapture, initial: true) { _, enabled in
144            hotKey?.unregister()
145            hotKey = enabled ? GlobalHotKey { perform("app.capture") } : nil
146        }
147        .onChange(of: commands.runRequest?.serial) {
148            if let id = commands.runRequest?.id { perform(id) }
149        }
150        .onChange(of: commands.jumpRequest?.id) {
151            guard let request = commands.jumpRequest else { return }
152            if request.path != session.path {
153                guard UnsavedChanges.resolve(session) else { return }
154                session.open(request.path)
155                selectedFile = request.path
156            }
157            session.reveal(request.offset)
158        }
159        .onChange(of: commands.focusSearch) { _, focus in
160            if focus {
161                searchFocused = true
162                commands.focusSearch = false
163            }
164        }
165        .fileImporter(isPresented: $commands.showingFolderPicker, allowedContentTypes: [.folder]) { result in
166            guard case .success(let url) = result else { return }
167            Task {
168                do { try await workspace.addRoot(url) } catch { workspace.lastError = error.localizedDescription }
169            }
170        }
171        .sheet(isPresented: $commands.showingQuickOpen) {
172            QuickOpenView(workspace: workspace) { entry in
173                selectedFile = entry.path
174                commands.showingQuickOpen = false
175            }
176        }
177        .alert("Orgstar", isPresented: errorShown) {
178            Button("OK") {
179                workspace.lastError = nil
180                session.lastError = nil
181            }
182        } message: {
183            Text(workspace.lastError ?? session.lastError ?? "")
184        }
185        .task(id: ReminderKey(
186            enabled: remindersEnabled, lead: reminderLead, subfolders: agendaSubfolders, files: workspace.files.count,
187            changed: workspace.changedFile?.version, edits: session.editVersion, hour: hour
188        )) {
189            await updateReminders()
190        }
191        .task {
192            while !Task.isCancelled {
193                try? await Task.sleep(for: .seconds(3600))
194                hour += 1
195            }
196        }
197        .task {
198            DevelopmentHooks.scheduleSnapshot()
199            if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
200            if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
201            if let id = DevelopmentHooks.windowToOpen {
202                if id == "settings" { openSettings() } else { openWindow(id: id) }
203            }
204            await workspace.refresh()
205            workspace.startWatching()
206        }
207    }
208
209    /// Writes the export beside the file, as Emacs's export dispatcher does.
210    private func export(_ format: String) {
211        guard let path = session.path, let text = session.document?.text else {
212            commands.show("No file is open")
213            return
214        }
215        let url = URL(fileURLWithPath: path)
216        let base = url.deletingPathExtension()
217        let defaults = Preferences.orgSettings
218        switch format {
219        case "html", "html-open", "markdown":
220            let target = base.appendingPathExtension(format == "markdown" ? "md" : "html")
221            let content = format == "markdown"
222                ? MarkdownExport.document(text, settings: defaults)
223                : HTMLExport.page(text, fallbackTitle: base.lastPathComponent, settings: defaults)
224            do {
225                try content.write(to: target, atomically: true, encoding: .utf8)
226                commands.show("Exported to \(target.lastPathComponent)")
227                if format == "html-open" { NSWorkspace.shared.open(target) }
228            } catch {
229                commands.show("Can't write \(target.lastPathComponent): \(error.localizedDescription)")
230            }
231        default:
232            let functions = ["pdf": "org-latex-export-to-pdf", "odt": "org-odt-export-to-odt", "latex": "org-latex-export-to-latex", "text": "org-ascii-export-to-ascii"]
233            guard let function = functions[format] else { return }
234            commands.show("Exporting with Emacs…")
235            Task {
236                do {
237                    let written = try await EmacsRunner.export(text: text, path: path, function: function)
238                    commands.show("Exported to \((written as NSString).lastPathComponent)")
239                } catch {
240                    commands.show("\(error)")
241                }
242            }
243        }
244    }
245
246    /// Work a command hands to the platform: tables recalculated in Emacs.
247    private func runExternal(_ request: ExternalRequest) {
248        switch request {
249        case .emacsTable(let table, let point, let form, let reason, let lisp):
250            let run = {
251                commands.show("Recalculating in Emacs (\(reason))…")
252                Task { commands.show(await session.recalculateInEmacs(table: table, point: point, form: form)) }
253            }
254            guard lisp, let path = session.path, let text = session.document?.text else { return run() }
255            let content = (text as NSString).substring(with: NSRange(location: table.lowerBound, length: min((text as NSString).length, table.upperBound + 2000) - table.lowerBound))
256            if trust.isTrusted(path: path, content: content) { return run() }
257            commands.ask(Prompt(key: "confirm", message: "This table's formulas run Lisp. Run them? (yes, no, always)", choices: ["yes", "no", "always"])) { answer in
258                switch answer {
259                case "always":
260                    trust.trust(path: path, content: content)
261                    run()
262                case "yes": run()
263                default: commands.show("Not recalculated")
264                }
265            }
266        case .babel(let job):
267            let run = {
268                commands.show("Running \(job.language) block…")
269                Task { commands.show(await session.runBabel(job)) }
270            }
271            guard let path = session.path else { return }
272            if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() }
273            let choices = job.alwaysAsk ? ["yes", "no"] : ["yes", "no", "always"]
274            commands.ask(Prompt(key: "confirm", message: "Run this \(job.language) block? (\(choices.joined(separator: ", ")))", choices: choices)) { answer in
275                switch answer {
276                case "always":
277                    trust.trust(path: path, content: job.blockText)
278                    run()
279                case "yes": run()
280                default: commands.show("Not run")
281                }
282            }
283        }
284    }
285
286    private struct ReminderKey: Hashable {
287        var enabled: Bool
288        var lead: Int
289        var subfolders: Bool
290        var files: Int
291        var changed: Int?
292        var edits: Int
293        var hour: Int
294    }
295
296    /// Sets the notifications for the next week's timed entries, once edits pause.
297    private func updateReminders() async {
298        guard let scheduler else { return }
299        guard remindersEnabled else {
300            await scheduler.clear()
301            return
302        }
303        try? await Task.sleep(for: .seconds(2))
304        guard !Task.isCancelled, !workspace.files.isEmpty else { return }
305        let agenda = reminderAgenda ?? AgendaModel(workspace: workspace)
306        reminderAgenda = agenda
307        agenda.includeSubfolders = agendaSubfolders
308        let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
309        let reminders = await agenda.reminders(open: open, lead: reminderLead)
310        guard !Task.isCancelled else { return }
311        await scheduler.reconcile(reminders)
312    }
313
314    private func loadKeymap() {
315        let loaded = KeymapLoader.load(preset)
316        keymap = loaded.keymap
317        if let first = loaded.problems.first {
318            let more = loaded.problems.count > 1 ? " (and \(loaded.problems.count - 1) more)" : ""
319            commands.show("keymap.toml: \(first)\(more)")
320        }
321    }
322
323    /// Runs a command from a key, the palette or the Org menu.
324    private func perform(_ id: String) {
325        switch id {
326        case "app.save":
327            if !session.save(), session.conflict != nil { commands.showingConflict = true }
328        case "app.quit": NSApp.terminate(nil)
329        case "app.quick-open": commands.showingQuickOpen = true
330        case "app.palette": commands.showingPalette = true
331        case "app.search": commands.focusSearch = true
332        case "app.reload-keymap":
333            loadKeymap()
334            commands.show("Keymap reloaded")
335        case "app.refile":
336            let targets = workspace.refileTargets(openPath: session.path, document: session.document)
337            commands.ask(Prompt(key: "refile", message: "Refile to:", choices: targets.map(\.label))) { answer in
338                guard let answer, let target = targets.first(where: { $0.label == answer }) else { return }
339                if let message = session.refile(to: target) { commands.show(message) }
340            }
341        case "app.archive":
342            if let message = session.archive() { commands.show(message) }
343        case "app.agenda":
344            openWindow(id: "agenda")
345        case "app.clock.in":
346            commands.show(clock.clockIn(session))
347        case "app.clock.out":
348            commands.show(clock.clockOut(session))
349        case "app.clock.cancel":
350            commands.show(clock.clockOut(session, cancel: true))
351        case "app.clock.goto":
352            if let location = clock.location(session) { commands.jump(path: location.path, offset: location.offset) } else { commands.show("No active clock") }
353        case "app.clock.report":
354            openWindow(id: "clock-report")
355        case "app.board":
356            openWindow(id: "board")
357        case "app.capture":
358            NSApp.activate()
359            openWindow(id: "capture")
360        case let id where id.hasPrefix("app.export."):
361            export(String(id.dropFirst("app.export.".count)))
362        default:
363            if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") }
364        }
365    }
366
367    private var errorShown: Binding<Bool> {
368        Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
369    }
370}
371
372struct Sidebar: View {
373    let workspace: WorkspaceModel
374    @Binding var selection: String?
375
376    var body: some View {
377        List(selection: $selection) {
378            ForEach(workspace.rootPaths, id: \.self) { root in
379                Section((root as NSString).lastPathComponent) {
380                    OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
381                        Label(node.name, systemImage: icon(node))
382                            .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary)
383                            .tag(node.file?.path)
384                    }
385                }
386                .contextMenu {
387                    Button("Remove Folder") { try? workspace.removeRoot(path: root) }
388                }
389            }
390        }
391        .listStyle(.sidebar)
392    }
393
394    private func icon(_ node: FileTreeNode) -> String {
395        guard let file = node.file else { return "folder" }
396        switch file.kind {
397        case .org: return "doc.text"
398        case .archive: return "archivebox"
399        case .conflict: return "exclamationmark.triangle"
400        }
401    }
402}
403
404struct OutlineColumn: View {
405    let session: DocumentSession
406
407    var body: some View {
408        List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in
409            Button {
410                session.reveal(entry.start)
411            } label: {
412                Text(entry.title.isEmpty ? "(untitled)" : entry.title)
413                    .lineLimit(1)
414                    .padding(.leading, CGFloat(entry.level - 1) * 12)
415            }
416            .buttonStyle(.plain)
417        }
418        .overlay {
419            if session.path != nil, session.outline.isEmpty {
420                ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
421            }
422        }
423    }
424}
425
426struct SearchResults: View {
427    let results: [HeadingLocation]
428    let open: (HeadingLocation) -> Void
429
430    var body: some View {
431        List(results, id: \.self) { location in
432            Button {
433                open(location)
434            } label: {
435                VStack(alignment: .leading, spacing: 2) {
436                    Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(1)
437                    Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary)
438                }
439            }
440            .buttonStyle(.plain)
441        }
442        .overlay {
443            if results.isEmpty { ContentUnavailableView.search }
444        }
445    }
446}
447
448struct QuickOpenView: View {
449    let workspace: WorkspaceModel
450    let open: (FileEntry) -> Void
451    @State private var query = ""
452    @Environment(\.dismiss) private var dismiss
453
454    var body: some View {
455        let matches = workspace.quickOpen(query)
456        VStack(spacing: 0) {
457            TextField("Open file", text: $query)
458                .textFieldStyle(.plain)
459                .font(.title3)
460                .padding(12)
461                .onSubmit { if let first = matches.first { open(first) } }
462            Divider()
463            List(matches) { entry in
464                Button {
465                    open(entry)
466                } label: {
467                    VStack(alignment: .leading, spacing: 2) {
468                        Text(entry.name)
469                        Text(entry.relativePath).font(.caption).foregroundStyle(.secondary)
470                    }
471                }
472                .buttonStyle(.plain)
473            }
474        }
475        .frame(width: 520, height: 360)
476        .onExitCommand { dismiss() }
477    }
478}