Sources/Orgstar/ContentView.swift
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}