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