krz/orgstar

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

Sources/Orgstar/ContentView.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/Orgstar/ContentView.swift history · blame · raw

1024 lines · 50063 bytes

   1import AppKit
   2import OrgApp
   3import OrgCore
   4import OrgEditorAppKit
   5import OrgPresentation
   6import OrgWorkspace
   7import OrgIndex
   8import SwiftUI
   9import UniformTypeIdentifiers
  10
  11struct ContentView: View {
  12    let workspace: WorkspaceModel
  13    let session: DocumentSession
  14    @Bindable var commands: AppCommands
  15    let scheduler: ReminderScheduler?
  16    let clock: ClockModel
  17    @AppStorage("reminders") private var remindersEnabled = true
  18    @AppStorage("reminderLead") private var reminderLead = 12
  19    @AppStorage("clockIdleTime") private var clockIdleTime = 0
  20    @AppStorage("agendaSubfolders") private var agendaSubfolders = false
  21    @State private var reminderAgenda: AgendaModel?
  22    @State private var trust = TrustStore()
  23    @State private var hotKey: GlobalHotKey?
  24    @State private var statusItem: ClockStatusItem?
  25    @AppStorage("globalCapture") private var globalCapture = true
  26    @State private var hour = 0
  27    @AppStorage("saveMode") private var saveMode = SaveMode.automatic
  28    @AppStorage("keymap") private var preset = KeymapPreset.emacs
  29    @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
  30    @State private var keymap = KeymapPreset.emacs.keymap
  31    @AppStorage("tagsColumn") private var tagsColumn = -77
  32    @AppStorage("headingAfterSubtree") private var headingAfterSubtree = true
  33    @AppStorage("splitLine") private var splitLine = false
  34    @AppStorage("alphabeticalLists") private var alphabeticalLists = true
  35    @AppStorage("fillColumn") private var fillColumn = 80
  36    @AppStorage("hiddenMarkers") private var hiddenMarkers = true
  37    @AppStorage("prettyEntities") private var prettyEntities = true
  38    @AppStorage("logDone") private var logDone = "nil"
  39    @AppStorage("logIntoDrawer") private var logIntoDrawer = ""
  40    @AppStorage("logReschedule") private var logReschedule = "nil"
  41    @AppStorage("logRedeadline") private var logRedeadline = "nil"
  42    @AppStorage("useSpeedCommands") private var useSpeedCommands = false
  43    @AppStorage("startupWithInlineImages") private var startupWithInlineImages = false
  44    @AppStorage("startupIndented") private var startupIndented = true
  45    @AppStorage("hideLeadingStars") private var hideLeadingStars = false
  46    @AppStorage("startupAlignAllTables") private var startupAlignAllTables = false
  47    @AppStorage("startupTruncated") private var startupTruncated = false
  48    @AppStorage("spellCheck") private var spellCheck = false
  49    @AppStorage("electricPair") private var electricPair = true
  50    @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true
  51    @AppStorage("hideBlockStartup") private var hideBlockStartup = false
  52    @AppStorage("enforceTodoDependencies") private var enforceTodoDependencies = false
  53    @AppStorage("enforceTodoCheckboxDependencies") private var enforceTodoCheckboxDependencies = false
  54
  55    private var options: EditingOptions {
  56        Preferences.options(
  57            tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists,
  58            fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer,
  59            hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline,
  60            useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages,
  61            startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables,
  62            startupTruncated: startupTruncated, spellCheck: spellCheck, electricPair: electricPair,
  63            enforceTodoDependencies: enforceTodoDependencies, enforceTodoCheckboxDependencies: enforceTodoCheckboxDependencies
  64        )
  65    }
  66    /// A block being edited apart (`org-edit-special`).
  67    @State private var blockEdit: BlockEditRequest?
  68    /// A sheet a command opened: column view or the export dialog.
  69    @State private var panel: Panel?
  70
  71    @State private var selectedFile: String?
  72    /// A file that isn't text, shown as a preview instead of the open document.
  73    @State private var previewPath: String?
  74    @AppStorage("outlineShown") private var orgOutlineShown = true
  75    @AppStorage("outlineWidth") private var outlineWidth = 220.0
  76    @AppStorage("backlinksShown") private var backlinksShown = true
  77    @AppStorage("inspectorShown") private var inspectorShown = false
  78    @AppStorage("showMarkup") private var showMarkup = false
  79    @AppStorage("showLineNumbers") private var showLineNumbers = true
  80    @AppStorage("showHiddenFiles") private var showHiddenFiles = true
  81    @AppStorage("ignoredFolders") private var ignoredFolders = ScanRules.defaultIgnoredFolders.sorted().joined(separator: " ")
  82    @AppStorage("themeFont") private var themeFont = ""
  83    @AppStorage("themeFontSize") private var themeFontSize = 13
  84    @AppStorage("themeLineSpacing") private var themeLineSpacing = 2
  85    @AppStorage("themeHeadingStep") private var themeHeadingStep = 1
  86
  87    /// The theme's colors from config.toml with the type settings.
  88    private var theme: ThemeSpec {
  89        var spec = ConfigSync.shared.theme
  90        spec.font = themeFont
  91        spec.fontSize = themeFontSize
  92        spec.lineSpacing = themeLineSpacing
  93        spec.headingSizeStep = themeHeadingStep
  94        return spec
  95    }
  96    /// The outline for a file that isn't org, off until asked for.
  97    @State private var plainOutlineShown = false
  98    @State private var showingImport = false
  99    @AppStorage("showTabBar") private var showTabBar = false
 100    /// Carets of restored buffers, shown when each first shows.
 101    @State private var pendingCarets: [String: Int] = [:]
 102    @State private var query = ""
 103    @FocusState private var searchFocused: Bool
 104    @Environment(\.openWindow) private var openWindow
 105    @Environment(\.openSettings) private var openSettings
 106
 107    /// Whether the outline pane shows: remembered for org files, hidden by default for
 108    /// other files, and always shown while search results are up.
 109    private var outlineShown: Bool {
 110        !query.isEmpty || (previewPath == nil && (session.isOrg ? orgOutlineShown : plainOutlineShown))
 111    }
 112
 113    private func toggleOutline() {
 114        if session.isOrg { orgOutlineShown.toggle() } else { plainOutlineShown.toggle() }
 115    }
 116
 117    @ViewBuilder
 118    private var detail: some View {
 119        if let previewPath {
 120            FilePreview(path: previewPath)
 121        } else if session.path != nil {
 122            VStack(spacing: 0) {
 123                if showTabBar { BufferTabs(session: session) }
 124                EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, theme: theme, onAppCommand: perform, onExternal: runExternal, knownTags: { [index = workspace.index] in (try? index.tags()) ?? [] })
 125            }
 126            .safeAreaInset(edge: .bottom, spacing: 0) {
 127                VStack(spacing: 0) {
 128                    ModeLine(session: session, commands: commands, clock: clock, perform: perform)
 129                    EchoArea(commands: commands)
 130                }
 131            }
 132        } else {
 133            ContentUnavailableView(
 134                workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
 135                systemImage: "doc.text",
 136                description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.")
 137            )
 138        }
 139    }
 140
 141    @ToolbarContentBuilder
 142    private var toolbarItems: some ToolbarContent {
 143        ToolbarItem(placement: .navigation) {
 144            Button(outlineShown ? "Hide Outline" : "Show Outline", systemImage: "list.bullet.indent") { toggleOutline() }
 145                .help(outlineShown ? "Hide the outline (⌥⌘O)" : "Show the outline (⌥⌘O)")
 146                .disabled(!query.isEmpty || previewPath != nil || session.path == nil)
 147        }
 148        if workspace.isIndexing {
 149            ToolbarItem { ProgressView().controlSize(.small) }
 150        }
 151        if let running = clock.running {
 152            ToolbarItem {
 153                Menu {
 154                    Button("Clock Out") { perform("app.clock.out") }
 155                    Button("Cancel Clock") { perform("app.clock.cancel") }
 156                    Button("Go to Clocked Entry") { perform("app.clock.goto") }
 157                    Divider()
 158                    Button("Clock In to Recent Entry…") { perform("app.clock.in-recent") }
 159                    Button("Go to Recent Clocked Entry…") { perform("app.clock.goto-recent") }
 160                    RecentClocks(clock: clock) { perform($0) }
 161                } label: {
 162                    TimelineView(.periodic(from: .now, by: 30)) { context in
 163                        Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1)
 164                    }
 165                }
 166                .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))")
 167            }
 168        }
 169        if session.conflict != nil {
 170            ToolbarItem {
 171                Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true }
 172                    .help("The file changed on disk, and the changes conflict with yours")
 173            }
 174        }
 175    }
 176
 177    private var main: some View {
 178        NavigationSplitView {
 179            Sidebar(workspace: workspace, session: session, selection: $selectedFile) { commands.showingFolderPicker = true }
 180                .navigationSplitViewColumnWidth(min: 200, ideal: 260)
 181        } detail: {
 182            HStack(spacing: 0) {
 183                if outlineShown {
 184                    Group {
 185                        if query.isEmpty {
 186                            VStack(spacing: 0) {
 187                                OutlineColumn(session: session)
 188                                if backlinksShown, session.isOrg {
 189                                    Divider()
 190                                    BacklinksPane(workspace: workspace, session: session, commands: commands) { location in
 191                                        selectedFile = location.path
 192                                        session.show(location)
 193                                    }
 194                                    .frame(height: 200)
 195                                }
 196                            }
 197                        } else {
 198                            SearchResults(results: workspace.search(query, overlay: workspace.overlay(session.openDocuments))) { location in
 199                                selectedFile = location.path
 200                                session.show(location)
 201                            }
 202                        }
 203                    }
 204                    .frame(width: CGFloat(outlineWidth))
 205                    PaneDivider(width: $outlineWidth, range: 160...400)
 206                }
 207                detail
 208                    .frame(maxWidth: .infinity)
 209                    .inspector(isPresented: $inspectorShown) {
 210                        InspectorPane(session: session, commands: commands)
 211                            .inspectorColumnWidth(min: 240, ideal: 340, max: 600)
 212                    }
 213            }
 214        }
 215        .navigationTitle(previewPath.map { ($0 as NSString).lastPathComponent } ?? session.path.map(session.bufferName) ?? "Orgstar")
 216        .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
 217        .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
 218        .searchFocused($searchFocused)
 219        .toolbar { toolbarItems }
 220        .onChange(of: session.path) { plainOutlineShown = false }
 221        .onChange(of: ScanRules(hiddenFiles: showHiddenFiles, ignoredFolders: Set(ignoredFolders.split(separator: " ").map(String.init))), initial: true) { _, rules in
 222            workspace.scanRules = rules
 223        }
 224        .onChange(of: selectedFile) { old, path in
 225            guard let path else { return }
 226            if !DocumentSession.isOrgPath(path), !FileTypes.isText(path) {
 227                previewPath = path
 228                return
 229            }
 230            previewPath = nil
 231            guard path != session.path else { return }
 232            session.open(path)
 233        }
 234        .onChange(of: session.path) {
 235            // Buffers switched by keys show in the sidebar too; a restored caret shows once.
 236            if let path = session.path, selectedFile != path { selectedFile = path }
 237            if let path = session.path, let caret = pendingCarets.removeValue(forKey: path) { session.reveal(caret) }
 238            BufferRestore.save(session)
 239            // Syncthing's conflict copies of the file.
 240            let copies = session.syncConflictCopies().count
 241            if copies > 0 { commands.show("\(copies) sync conflict \(copies == 1 ? "copy" : "copies") of this file; Resolve Sync Conflicts compares them.") }
 242        }
 243        .onChange(of: session.openPaths) { BufferRestore.save(session) }
 244        .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
 245        .onChange(of: options, initial: true) { _, options in session.options = options }
 246        .onChange(of: preset, initial: true) { loadKeymap() }
 247        .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) }
 248        .sheet(item: $panel) { panel in
 249            switch panel {
 250            case .columns(_, let view):
 251                ColumnViewSheet(view: view) { offset in commands.editor?.reveal(offset) }
 252            case .export(_, let source):
 253                ExportSheet(source: source) { format, destination, open in export(format, to: destination, open: open) }
 254            case .conflict:
 255                ConflictSheet(session: session)
 256            case .syncCopies:
 257                SyncConflictSheet(session: session) { commands.show($0) }
 258            case .recovery:
 259                RecoverySheet(session: session) { commands.show($0) }
 260            }
 261        }
 262        .sheet(item: $blockEdit) { request in
 263            BlockEditorView(edit: request.edit) { content in
 264                blockEdit = nil
 265                guard let content else { return }
 266                commands.editor?.perform(ReplaceBlockLines(edit: request.edit, content: content))
 267            }
 268        }
 269        .sheet(isPresented: $commands.showingPalette) {
 270            PaletteView(commands: commands) { id in
 271                commands.showingPalette = false
 272                // After the sheet closes, so app commands can present their own.
 273                DispatchQueue.main.async { perform(id) }
 274            }
 275        }
 276        .onChange(of: session.conflict) { _, conflict in
 277            if conflict != nil { commands.showingConflict = true }
 278        }
 279        .onChange(of: commands.showingConflict) { _, showing in
 280            guard showing else { return }
 281            commands.showingConflict = false
 282            if session.conflict != nil { panel = .conflict(UUID()) }
 283        }
 284        .onChange(of: workspace.changedFile?.version) {
 285            if let path = workspace.changedFile?.path { session.fileChanged(path) }
 286        }
 287    }
 288
 289    var body: some View {
 290        main
 291        .onChange(of: ClockMenuState(running: clock.running, history: clock.history), initial: true) { _, state in
 292            if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } }
 293            statusItem?.update(state.running, recent: state.history.map(\.title))
 294        }
 295        .onChange(of: globalCapture, initial: true) { _, enabled in
 296            hotKey?.unregister()
 297            hotKey = enabled ? GlobalHotKey { perform("app.capture") } : nil
 298        }
 299        .onChange(of: commands.runRequest?.serial) {
 300            if let id = commands.runRequest?.id { perform(id) }
 301        }
 302        .onChange(of: ConfigSync.shared.problems, initial: true) { _, problems in
 303            if let first = problems.first {
 304                commands.show(first + (problems.count > 1 ? " (and \(problems.count - 1) more)" : ""))
 305            }
 306        }
 307        .onChange(of: ConfigSync.shared.changedFile?.serial) {
 308            if ConfigSync.shared.changedFile?.name == "keymap.toml" { loadKeymap() }
 309        }
 310        .onChange(of: commands.jumpRequest?.id) {
 311            guard let request = commands.jumpRequest else { return }
 312            if request.path != session.path {
 313                session.open(request.path)
 314                selectedFile = request.path
 315            }
 316            session.reveal(request.offset)
 317        }
 318        .onChange(of: commands.focusSearch) { _, focus in
 319            if focus {
 320                searchFocused = true
 321                commands.focusSearch = false
 322            }
 323        }
 324        .fileImporter(isPresented: $commands.showingFolderPicker, allowedContentTypes: [.folder]) { result in
 325            guard case .success(let url) = result else { return }
 326            Task {
 327                do { try await workspace.addRoot(url) } catch { workspace.lastError = error.localizedDescription }
 328            }
 329        }
 330        .sheet(isPresented: $commands.showingQuickOpen) {
 331            QuickOpenView(workspace: workspace) { entry in
 332                selectedFile = entry.path
 333                commands.showingQuickOpen = false
 334            }
 335        }
 336        .alert("Orgstar", isPresented: errorShown) {
 337            Button("OK") {
 338                workspace.lastError = nil
 339                session.lastError = nil
 340            }
 341        } message: {
 342            Text(workspace.lastError ?? session.lastError ?? "")
 343        }
 344        .task(id: ReminderKey(
 345            enabled: remindersEnabled, lead: reminderLead, subfolders: agendaSubfolders, files: workspace.files.count,
 346            changed: workspace.changedFile?.version, edits: session.editVersion, hour: hour
 347        )) {
 348            await updateReminders()
 349        }
 350        .task {
 351            while !Task.isCancelled {
 352                try? await Task.sleep(for: .seconds(3600))
 353                hour += 1
 354            }
 355        }
 356        // `org-resolve-clocks-if-idle`, checked every minute while a clock runs.
 357        .task(id: "\(clock.running?.start ?? "") \(clockIdleTime)") {
 358            guard clock.running != nil, clockIdleTime > 0 else { return }
 359            while !Task.isCancelled {
 360                try? await Task.sleep(for: .seconds(60))
 361                guard !Task.isCancelled, clock.running != nil, !clock.isResolving else { continue }
 362                let idle = ClockIdle.seconds()
 363                guard idle > Double(clockIdleTime * 60) else { continue }
 364                NSApp.requestUserAttention(.informationalRequest)
 365                report(await clock.resolveIdle(session, idleSince: Date().addingTimeInterval(-idle), ask: askClock))
 366            }
 367        }
 368        .task {
 369            DevelopmentHooks.scheduleSnapshot()
 370            pendingCarets = BufferRestore.restore(session)
 371            if let path = session.path, let caret = pendingCarets.removeValue(forKey: path) { session.reveal(caret) }
 372            // Files handed over at launch open after the restored buffers, and show.
 373            for path in AppDelegate.pendingOpens { session.open(path) }
 374            AppDelegate.pendingOpens = []
 375            for link in AppDelegate.pendingLinks + [DevelopmentHooks.urlToOpen].compactMap({ $0 }) { commands.handle(protocolURL: link) }
 376            AppDelegate.pendingLinks = []
 377            if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
 378            if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
 379            if let commandList = DevelopmentHooks.commandToRun {
 380                // The file opened above needs its editor first.
 381                for _ in 0..<20 where DevelopmentHooks.fileToOpen != nil && commands.editor == nil { try? await Task.sleep(for: .milliseconds(100)) }
 382                for command in commandList.split(separator: ",") { perform(String(command)) }
 383                for answer in DevelopmentHooks.answers {
 384                    for _ in 0..<20 where commands.prompt == nil { try? await Task.sleep(for: .milliseconds(100)) }
 385                    commands.answer(answer)
 386                }
 387            }
 388            if let id = DevelopmentHooks.windowToOpen {
 389                if id == "settings" { openSettings() } else { openWindow(id: id) }
 390            }
 391            await workspace.refresh()
 392            workspace.startWatching()
 393        }
 394    }
 395
 396    /// Writes the export beside the file, as Emacs's export dispatcher does, or to
 397    /// `destination`, and opens it with `open`.
 398    private func export(_ format: String, to destination: URL? = nil, open: Bool = false) {
 399        guard let path = session.path, let text = session.document?.text else {
 400            commands.show("No file is open")
 401            return
 402        }
 403        let url = URL(fileURLWithPath: path)
 404        let base = url.deletingPathExtension()
 405        let defaults = Preferences.orgSettings.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile)
 406        let open = open || format == "html-open"
 407        switch format {
 408        case "html", "html-open", "markdown":
 409            let target = destination ?? base.appendingPathExtension(format == "markdown" ? "md" : "html")
 410            let content = format == "markdown"
 411                ? MarkdownExport.document(text, settings: defaults, path: path)
 412                : HTMLExport.page(text, fallbackTitle: base.lastPathComponent, settings: defaults, path: path)
 413            do {
 414                try content.write(to: target, atomically: true, encoding: .utf8)
 415                commands.show("Exported to \(target.lastPathComponent)")
 416                if open { NSWorkspace.shared.open(target) }
 417            } catch {
 418                commands.show("Can't write \(target.lastPathComponent): \(error.localizedDescription)")
 419            }
 420        default:
 421            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"]
 422            guard let function = functions[format] else { return }
 423            commands.show("Exporting with Emacs…")
 424            commands.runInEmacs {
 425                do {
 426                    var written = URL(fileURLWithPath: try await EmacsRunner.export(text: text, path: path, function: function))
 427                    if let destination, destination.standardizedFileURL != written.standardizedFileURL {
 428                        try? FileManager.default.removeItem(at: destination)
 429                        try FileManager.default.moveItem(at: written, to: destination)
 430                        written = destination
 431                    }
 432                    commands.show("Exported to \(written.lastPathComponent)")
 433                    if open { NSWorkspace.shared.open(written) }
 434                } catch is CancellationError {
 435                    commands.show("Export canceled")
 436                } catch {
 437                    commands.show("\(error)")
 438                }
 439            }
 440        }
 441    }
 442
 443    /// Work a command hands to the platform: tables recalculated in Emacs.
 444    private func runExternal(_ request: ExternalRequest) {
 445        switch request {
 446        case .openLink(let link):
 447            if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) }
 448        case .tangle(let scope):
 449            commands.show(session.tangle(scope))
 450        case .agenda(let start, let days):
 451            commands.agendaRequest = (start, days)
 452            openWindow(id: "agenda")
 453        case .editBlock(let edit):
 454            blockEdit = BlockEditRequest(edit: edit)
 455        case .columnView(let view):
 456            panel = .columns(UUID(), view)
 457        case .emacsTable(let table, let point, let form, let reason, let lisp):
 458            let run = {
 459                commands.show("Recalculating in Emacs (\(reason))…")
 460                commands.runInEmacs { commands.show(await session.recalculateInEmacs(table: table, point: point, form: form)) }
 461            }
 462            guard lisp, let path = session.path, let text = session.document?.text else { return run() }
 463            let content = (text as NSString).substring(with: NSRange(location: table.lowerBound, length: min((text as NSString).length, table.upperBound + 2000) - table.lowerBound))
 464            if trust.isTrusted(path: path, content: content) { return run() }
 465            commands.ask(Prompt(key: "confirm", message: "This table's formulas run Lisp. Run them? (yes, no, always)", choices: ["yes", "no", "always"])) { answer in
 466                switch answer {
 467                case "always":
 468                    trust.trust(path: path, content: content)
 469                    run()
 470                case "yes": run()
 471                default: commands.show("Not recalculated")
 472                }
 473            }
 474        case .babel(let job):
 475            let run = {
 476                commands.show("Running \(job.language) block… (⌘. to cancel)")
 477                session.startBabel(job) { commands.show($0) }
 478            }
 479            guard let path = session.path else { return }
 480            if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() }
 481            let choices = job.alwaysAsk ? ["yes", "no"] : ["yes", "no", "always"]
 482            commands.ask(Prompt(key: "confirm", message: "Run this \(job.language) block? (\(choices.joined(separator: ", ")))", choices: choices)) { answer in
 483                switch answer {
 484                case "always":
 485                    trust.trust(path: path, content: job.blockText)
 486                    run()
 487                case "yes": run()
 488                default: commands.show("Not run")
 489                }
 490            }
 491        }
 492    }
 493
 494    private struct ReminderKey: Hashable {
 495        var enabled: Bool
 496        var lead: Int
 497        var subfolders: Bool
 498        var files: Int
 499        var changed: Int?
 500        var edits: Int
 501        var hour: Int
 502    }
 503
 504    /// Sets the notifications for the next week's timed entries, once edits pause.
 505    private func updateReminders() async {
 506        guard let scheduler else { return }
 507        guard remindersEnabled else {
 508            await scheduler.clear()
 509            return
 510        }
 511        try? await Task.sleep(for: .seconds(2))
 512        guard !Task.isCancelled, !workspace.files.isEmpty else { return }
 513        let agenda = reminderAgenda ?? AgendaModel(workspace: workspace)
 514        reminderAgenda = agenda
 515        agenda.includeSubfolders = agendaSubfolders
 516        let open = session.openTexts
 517        let reminders = await agenda.reminders(open: open, lead: reminderLead)
 518        guard !Task.isCancelled else { return }
 519        await scheduler.reconcile(reminders)
 520    }
 521
 522    private func loadKeymap() {
 523        let loaded = KeymapLoader.load(preset, commands: Set(EditorCommands.all.map(\.id)))
 524        keymap = loaded.keymap
 525        commands.keymap = loaded.keymap
 526        commands.modal = preset.isModal
 527        if let first = loaded.problems.first {
 528            let more = loaded.problems.count > 1 ? " (and \(loaded.problems.count - 1) more)" : ""
 529            commands.show("keymap.toml: \(first)\(more)")
 530        }
 531    }
 532
 533    /// `SPC b b`: open buffers, most recent first, by name (with the folder when names repeat).
 534    private func switchBuffer() {
 535        let paths = session.recentPaths.dropFirst() + session.recentPaths.prefix(1)
 536        let labels = paths.map(session.bufferName)
 537        guard !labels.isEmpty else { return commands.show("No buffers are open") }
 538        commands.ask(Prompt(key: "buffer", message: "Switch to buffer:", choices: labels)) { answer in
 539            guard let answer, let index = labels.firstIndex(of: answer) else { return }
 540            session.open(Array(paths)[index])
 541        }
 542    }
 543
 544    /// Runs a command from a key, the palette or the Org menu.
 545    private func perform(_ id: String) {
 546        let orgOnly = ["app.refile", "app.archive", "app.clock.in", "app.clock.default"].contains(id) || id.hasPrefix("app.export.") || id.hasPrefix("org.")
 547        if orgOnly, !session.isOrg {
 548            commands.show("Not an org file")
 549            return
 550        }
 551        switch id {
 552        case "app.save":
 553            if !session.save(), session.conflict != nil { commands.showingConflict = true }
 554        case "app.quit": NSApp.terminate(nil)
 555        case "app.save-all":
 556            if !session.saveAll(), session.conflict != nil { commands.showingConflict = true }
 557        case "app.buffer.switch": switchBuffer()
 558        case "app.buffer.next": session.cycle(1)
 559        case "app.buffer.previous": session.cycle(-1)
 560        case "app.buffer.last": session.showPrevious()
 561        case "app.buffer.kill":
 562            guard let path = session.path else {
 563                NSApp.keyWindow?.performClose(nil)
 564                return
 565            }
 566            if UnsavedChanges.resolve(session) { session.kill(path) }
 567        case "app.buffer.kill-others":
 568            let others = session.openPaths.filter { $0 != session.path }
 569            if UnsavedChanges.resolve(session, paths: others) { others.forEach(session.kill) }
 570        case "app.buffer.kill-all":
 571            let all = session.openPaths
 572            if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) }
 573        case "app.toggle-tab-bar": showTabBar.toggle()
 574        case "app.babel.cancel":
 575            if session.isRunningBabel {
 576                session.cancelBabel()
 577            } else if let task = commands.emacsTask {
 578                task.cancel()
 579            } else {
 580                commands.show("Nothing is running")
 581            }
 582        case "app.toggle-outline": toggleOutline()
 583        case "app.toggle-backlinks": backlinksShown.toggle()
 584        case "app.toggle-inspector": inspectorShown.toggle()
 585        case "app.toggle-markup": showMarkup.toggle()
 586        case "app.toggle-line-numbers": showLineNumbers.toggle()
 587        case "app.import-emacs": showingImport = true
 588        case "app.edit-config": commands.jump(path: UserConfig.configFile.path, offset: 0)
 589        case "app.quick-open": commands.showingQuickOpen = true
 590        case "app.palette": commands.showingPalette = true
 591        case "app.search": commands.focusSearch = true
 592        case "app.reload-keymap":
 593            loadKeymap()
 594            commands.show("Keymap reloaded")
 595        case "app.refile":
 596            let targets = workspace.refileTargets(openPath: session.path, document: session.document)
 597            commands.ask(Prompt(key: "refile", message: "Refile to:", choices: targets.map(\.label))) { answer in
 598                guard let answer, let target = targets.first(where: { $0.label == answer }) else { return }
 599                if let message = session.refile(to: target) { commands.show(message) }
 600            }
 601        case "app.archive":
 602            if let message = session.archive() { commands.show(message) }
 603        case "app.table-import":
 604            // `org-table-import`: a TSV, CSV or space-separated file becomes a table at point.
 605            guard let editor = commands.editor else { return commands.show("No file is open") }
 606            let panel = NSOpenPanel()
 607            panel.allowedContentTypes = [.commaSeparatedText, .tabSeparatedText, .plainText]
 608            guard panel.runModal() == .OK, let url = panel.url else { return }
 609            guard let contents = try? String(contentsOf: url, encoding: .utf8) else { return commands.show("Can't read \(url.lastPathComponent)") }
 610            editor.perform(ImportTable(contents: contents))
 611        case "app.table-export":
 612            // `org-table-export`: CSV for a .csv name, else TSV.
 613            guard let editor = commands.editor else { return commands.show("No file is open") }
 614            let context = editor.context()
 615            guard ImportTable.exportText(context.text, at: context.caret, path: "") != nil else { return commands.show("No table at point") }
 616            let panel = NSSavePanel()
 617            panel.nameFieldStringValue = "table.csv"
 618            guard panel.runModal() == .OK, let url = panel.url,
 619                  let text = ImportTable.exportText(context.text, at: context.caret, path: url.path) else { return }
 620            do {
 621                try text.write(to: url, atomically: true, encoding: .utf8)
 622                commands.show("Export done.")
 623            } catch {
 624                commands.show("File not written: \(error.localizedDescription)")
 625            }
 626        case "org.subtree.paste":
 627            guard let text = NSPasteboard.general.string(forType: .string) else { return commands.show("The clipboard is empty") }
 628            commands.editor?.perform(PasteSubtree(text: text))
 629        case "org.link.insert":
 630            commands.editor?.perform(InsertLink(stored: commands.storedLinks, file: session.path))
 631        case "org.link.store":
 632            guard let editor = commands.editor, let path = session.path else { return commands.show("No method for storing a link from this buffer") }
 633            commands.storeLink(Links.store(in: editor.context(), path: Links.abbreviate(path, home: NSHomeDirectory())))
 634        case "org.id.store-link":
 635            guard let editor = commands.editor, let path = session.path else { return }
 636            if case .commit = editor.perform(CreateID()),
 637               let link = Links.storeID(in: editor.context(), fileName: (path as NSString).lastPathComponent) {
 638                commands.storeLink(link)
 639            }
 640        case "app.agenda":
 641            openWindow(id: "agenda")
 642        case "app.clock.in":
 643            clockIn(.caret)
 644        case "app.clock.in-recent":
 645            clockIn(.select)
 646        case "app.clock.in-last":
 647            clockIn(.last)
 648        case let id where id.hasPrefix("app.clock.history."):
 649            if let index = Int(id.dropFirst("app.clock.history.".count)), index < clock.history.count { clockIn(.task(clock.history[index])) }
 650        case "app.clock.out":
 651            commands.show(clock.clockOut(session))
 652        case "app.clock.cancel":
 653            commands.show(clock.clockOut(session, cancel: true))
 654        case "app.clock.goto":
 655            let found = clock.goto(session)
 656            if let location = found.location { commands.jump(path: location.path, offset: location.offset) }
 657            if let message = found.message { commands.show(message) }
 658        case "app.clock.goto-recent":
 659            Task {
 660                let found = await clock.gotoRecent(session, ask: askClock)
 661                if let location = found.location { commands.jump(path: location.path, offset: location.offset) }
 662                if let message = found.message { commands.show(message) }
 663            }
 664        case "app.clock.default":
 665            commands.show(clock.markDefault(session))
 666        case "app.clock.resolve":
 667            Task { report(await clock.resolveOpenClocks(session, paths: orgFiles, ask: askClock)) }
 668        case "app.clock.report":
 669            openWindow(id: "clock-report")
 670        case "app.board":
 671            openWindow(id: "board")
 672        case "app.capture":
 673            NSApp.activate()
 674            openWindow(id: "capture")
 675        case "app.revert":
 676            // `:e`: the file as it is on disk; unsaved changes go to recovery.
 677            guard session.path != nil else { return commands.show("No file is open") }
 678            session.useDiskVersion()
 679            commands.show("Reverted to the file on disk")
 680        case "app.sync-conflicts":
 681            guard session.path != nil else { return commands.show("No file is open") }
 682            panel = .syncCopies(UUID())
 683        case "app.recovery":
 684            guard session.path != nil else { return commands.show("No file is open") }
 685            panel = .recovery(UUID())
 686        case "app.export-dialog":
 687            guard let path = session.path else { return commands.show("No file is open") }
 688            panel = .export(UUID(), URL(fileURLWithPath: path))
 689        case let id where id.hasPrefix("app.export."):
 690            export(String(id.dropFirst("app.export.".count)))
 691        default:
 692            if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") }
 693        }
 694    }
 695
 696    private var askClock: ClockModel.Ask { commands.askClock }
 697
 698    /// The files `org-resolve-clocks` looks through: the workspace's org files.
 699    private var orgFiles: [String] { workspace.files.filter { $0.kind == .org }.map(\.path) }
 700
 701    private func clockIn(_ target: ClockModel.Target) {
 702        Task { report(await clock.clockIn(session, target: target, paths: orgFiles, ask: askClock)) }
 703    }
 704
 705    /// The last message of a clock command, and the clock to show for `j`.
 706    private func report(_ outcome: ClockModel.Outcome) {
 707        if let jump = outcome.jump { commands.jump(path: jump.path, offset: jump.offset) }
 708        if let message = outcome.messages.last(where: { !$0.isEmpty }) { commands.show(message) }
 709    }
 710
 711    private var errorShown: Binding<Bool> {
 712        Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
 713    }
 714}
 715
 716struct Sidebar: View {
 717    let workspace: WorkspaceModel
 718    let session: DocumentSession
 719    @Binding var selection: String?
 720    var addFolder: () -> Void = {}
 721    /// Folders shown collapsed, by path, one per line.
 722    @AppStorage("collapsedFolders") private var collapsedText = ""
 723    @State private var removing: String?
 724    /// A file operation waiting for a name or a confirmation.
 725    @Environment(\.orgTheme) private var theme
 726    @State var pending: FileOperation?
 727    @State var name = ""
 728    @State var failure: String?
 729
 730    private var collapsed: Set<String> { Set(collapsedText.split(separator: "\n").map(String.init)) }
 731
 732    private func expanded(_ root: String) -> Binding<Bool> {
 733        Binding {
 734            !collapsed.contains(root)
 735        } set: { isExpanded in
 736            var set = collapsed
 737            if isExpanded { set.remove(root) } else { set.insert(root) }
 738            collapsedText = set.sorted().joined(separator: "\n")
 739        }
 740    }
 741
 742    var body: some View {
 743        List(selection: $selection) {
 744            ForEach(workspace.rootPaths, id: \.self) { root in
 745                Section(isExpanded: expanded(root)) {
 746                    OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
 747                        Label(node.name, systemImage: icon(node))
 748                            .foregroundStyle(node.file?.kind == .conflict ? .orange : node.isDownloading ? .secondary : theme.color("sidebar-foreground") ?? .primary)
 749                            .help(node.isDownloading ? "Downloading from iCloud" : "")
 750                            .tag(node.file?.path)
 751                            .contextMenu { fileMenu(node) }
 752                    }
 753                } header: {
 754                    Text((root as NSString).lastPathComponent)
 755                        .foregroundStyle(theme.color("sidebar-header") ?? .secondary)
 756                        .help(root)
 757                        .contextMenu { folderMenu(root) }
 758                }
 759            }
 760        }
 761        .listStyle(.sidebar)
 762        .themedListBackground(theme.color("sidebar-background"))
 763        .safeAreaInset(edge: .bottom, spacing: 0) {
 764            HStack(spacing: 4) {
 765                Button("Add Folder…", systemImage: "plus", action: addFolder)
 766                    .help("Add a folder (⇧⌘O)")
 767                Menu {
 768                    ForEach(workspace.rootPaths, id: \.self) { root in
 769                        Button((root as NSString).lastPathComponent) { removing = root }
 770                    }
 771                } label: {
 772                    Label("Remove Folder", systemImage: "minus")
 773                }
 774                .menuIndicator(.hidden)
 775                .fixedSize()
 776                .disabled(workspace.rootPaths.isEmpty)
 777                .help("Remove a folder from the sidebar")
 778                Spacer()
 779            }
 780            .labelStyle(.iconOnly)
 781            .buttonStyle(.borderless)
 782            .menuStyle(.borderlessButton)
 783            .padding(.horizontal, 10)
 784            .padding(.vertical, 6)
 785        }
 786        .confirmationDialog(
 787            "Remove “\(removing.map { ($0 as NSString).lastPathComponent } ?? "")” from the sidebar?",
 788            isPresented: Binding { removing != nil } set: { if !$0 { removing = nil } }
 789        ) {
 790            Button("Remove") {
 791                if let root = removing { try? workspace.removeRoot(path: root) }
 792                removing = nil
 793            }
 794        } message: {
 795            Text("The folder and its files stay on disk.")
 796        }
 797        .fileOperations(self)
 798    }
 799
 800    @ViewBuilder
 801    private func folderMenu(_ root: String) -> some View {
 802        Button("New File…") { ask(.create(folder: root)) }
 803        Divider()
 804        Button(collapsed.contains(root) ? "Expand" : "Collapse") { expanded(root).wrappedValue.toggle() }
 805        Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: root)]) }
 806        Divider()
 807        Button("Remove from Sidebar…") { removing = root }
 808    }
 809
 810    private func icon(_ node: FileTreeNode) -> String {
 811        if node.isDownloading { return "icloud.and.arrow.down" }
 812        guard let file = node.file else { return "folder" }
 813        switch file.kind {
 814        case .org: return "doc.text"
 815        case .archive: return "archivebox"
 816        case .conflict: return "exclamationmark.triangle"
 817        case .other:
 818            guard let type = UTType(filenameExtension: (file.path as NSString).pathExtension) else { return "doc" }
 819            if type.conforms(to: .image) { return "photo" }
 820            if type.conforms(to: .pdf) { return "doc.richtext" }
 821            if type.conforms(to: .audiovisualContent) { return "play.rectangle" }
 822            if type.conforms(to: .sourceCode) || type.conforms(to: .script) { return "chevron.left.forwardslash.chevron.right" }
 823            if type.conforms(to: .text) { return "doc.plaintext" }
 824            return "doc"
 825        }
 826    }
 827}
 828
 829struct OutlineColumn: View {
 830    let session: DocumentSession
 831    @Environment(\.orgTheme) private var theme
 832
 833    var body: some View {
 834        List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in
 835            Button {
 836                session.reveal(entry.start)
 837            } label: {
 838                Text(entry.title.isEmpty ? "(untitled)" : entry.title)
 839                    .foregroundStyle(theme.color("sidebar-foreground") ?? .primary)
 840                    .lineLimit(1)
 841                    .padding(.leading, CGFloat(entry.level - 1) * 12)
 842            }
 843            .buttonStyle(.plain)
 844        }
 845        .themedListBackground(theme.color("sidebar-background"))
 846        .overlay {
 847            if session.path != nil, !session.isOrg {
 848                ContentUnavailableView("No outline", systemImage: "doc.plaintext", description: Text("Only org files have an outline."))
 849            } else if session.path != nil, session.outline.isEmpty {
 850                ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
 851            }
 852        }
 853    }
 854}
 855
 856struct SearchResults: View {
 857    let results: [HeadingLocation]
 858    let open: (HeadingLocation) -> Void
 859
 860    var body: some View {
 861        List(results, id: \.self) { location in
 862            Button {
 863                open(location)
 864            } label: {
 865                VStack(alignment: .leading, spacing: 2) {
 866                    Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(1)
 867                    Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary)
 868                }
 869            }
 870            .buttonStyle(.plain)
 871        }
 872        .overlay {
 873            if results.isEmpty { ContentUnavailableView.search }
 874        }
 875    }
 876}
 877
 878struct QuickOpenView: View {
 879    let workspace: WorkspaceModel
 880    let open: (FileEntry) -> Void
 881    @State private var query = ""
 882    /// The highlighted match, moved by ↑/↓ or C-p/C-n and opened by Return.
 883    @State private var selected = 0
 884    @Environment(\.dismiss) private var dismiss
 885
 886    var body: some View {
 887        let matches = workspace.quickOpen(query)
 888        let current = min(selected, matches.count - 1)
 889        VStack(spacing: 0) {
 890            TextField("Open file", text: $query)
 891                .textFieldStyle(.plain)
 892                .font(.title3)
 893                .padding(12)
 894                .onSubmit { if current >= 0 { open(matches[current]) } }
 895                .onKeyPress(phases: .down) { press in
 896                    let control = press.modifiers == .control
 897                    let step = if press.key == .downArrow || control && press.characters == "n" { 1 }
 898                        else if press.key == .upArrow || control && press.characters == "p" { -1 } else { 0 }
 899                    guard step != 0 else { return .ignored }
 900                    selected = max(0, min(current + step, matches.count - 1))
 901                    return .handled
 902                }
 903                .onChange(of: query) { selected = 0 }
 904            Divider()
 905            ScrollViewReader { proxy in
 906                List(matches.indices, id: \.self) { index in
 907                    let entry = matches[index]
 908                    Button {
 909                        open(entry)
 910                    } label: {
 911                        VStack(alignment: .leading, spacing: 2) {
 912                            Text(entry.name)
 913                            Text(entry.relativePath).font(.caption).foregroundStyle(.secondary)
 914                        }
 915                        .frame(maxWidth: .infinity, alignment: .leading)
 916                        .contentShape(Rectangle())
 917                    }
 918                    .buttonStyle(.plain)
 919                    .listRowBackground(index == current ? Color.accentColor.opacity(0.25) : Color.clear)
 920                    .accessibilityAddTraits(index == current ? .isSelected : [])
 921                }
 922                .onChange(of: current) { if current >= 0 { proxy.scrollTo(current) } }
 923            }
 924        }
 925        .frame(width: 520, height: 360)
 926        .onExitCommand { dismiss() }
 927    }
 928}
 929
 930/// A divider that resizes the pane before it by dragging. An `HSplitView` inside the window's
 931/// split view loops on its size constraints in short windows, so this sets a width instead.
 932struct PaneDivider: View {
 933    @Binding var width: Double
 934    let range: ClosedRange<Double>
 935    @State private var start: Double?
 936
 937    var body: some View {
 938        Rectangle()
 939            .fill(Color(nsColor: .separatorColor))
 940            .frame(width: 1)
 941            .frame(maxHeight: .infinity)
 942            .overlay(Color.clear.frame(width: 7).contentShape(Rectangle()).onHover { inside in
 943                if inside { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() }
 944            }
 945            .gesture(DragGesture(minimumDistance: 1).onChanged { drag in
 946                let base = start ?? width
 947                start = base
 948                width = min(max(base + drag.translation.width, range.lowerBound), range.upperBound)
 949            }.onEnded { _ in start = nil }))
 950            .accessibilityHidden(true)
 951    }
 952}
 953
 954/// A tab for each open buffer: click to show it, × to close it, a dot for unsaved edits.
 955struct BufferTabs: View {
 956    let session: DocumentSession
 957    @Environment(\.orgTheme) private var theme
 958
 959    var body: some View {
 960        ScrollView(.horizontal, showsIndicators: false) {
 961            HStack(spacing: 0) {
 962                ForEach(session.openPaths, id: \.self) { path in
 963                    tab(path)
 964                }
 965            }
 966        }
 967        .frame(height: 28)
 968        .background { Rectangle().fill(theme.color("modeline-background").map(AnyShapeStyle.init) ?? AnyShapeStyle(.bar)) }
 969        .overlay(alignment: .bottom) { Divider() }
 970    }
 971
 972    private func tab(_ path: String) -> some View {
 973        let current = path == session.path
 974        return HStack(spacing: 6) {
 975            Text(session.bufferName(path))
 976                .lineLimit(1)
 977                .foregroundStyle(current ? .primary : .secondary)
 978                .accessibilityAddTraits(current ? [.isButton, .isSelected] : .isButton)
 979                .accessibilityAction { session.open(path) }
 980            Button {
 981                if path == session.path {
 982                    if UnsavedChanges.resolve(session) { session.kill(path) }
 983                } else if UnsavedChanges.resolve(session, paths: [path]) {
 984                    session.kill(path)
 985                }
 986            } label: {
 987                Image(systemName: session.dirtyPaths.contains(path) ? "circle.fill" : "xmark")
 988                    .font(.system(size: session.dirtyPaths.contains(path) ? 6 : 9, weight: .semibold))
 989                    .frame(width: 12, height: 12)
 990            }
 991            .buttonStyle(.borderless)
 992            .help("Close")
 993            .accessibilityLabel("Close \(session.bufferName(path))" + (session.dirtyPaths.contains(path) ? ", unsaved" : ""))
 994        }
 995        .font(.callout)
 996        .padding(.horizontal, 12)
 997        .frame(maxHeight: .infinity)
 998        // A shape, not a color: a color background runs up under the toolbar.
 999        .background { Rectangle().fill(current ? theme.color("background") ?? Color(nsColor: .textBackgroundColor) : .clear) }
1000        .overlay(alignment: .trailing) { Divider() }
1001        .contentShape(Rectangle())
1002        .onTapGesture { session.open(path) }
1003        .help(path)
1004    }
1005}
1006
1007enum Panel: Identifiable {
1008    case columns(UUID, ColumnView.View)
1009    case export(UUID, URL)
1010    case conflict(UUID)
1011    case syncCopies(UUID)
1012    case recovery(UUID)
1013
1014    var id: UUID {
1015        switch self {
1016        case .columns(let id, _), .export(let id, _), .conflict(let id), .syncCopies(let id), .recovery(let id): id
1017        }
1018    }
1019}
1020
1021struct BlockEditRequest: Identifiable {
1022    let id = UUID()
1023    let edit: BlockEdit
1024}