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