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