krz/orgstar

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

Commit 3f183dc5a6

3f183dc5a69542840ae20a73ec755d1148b354dd

parent: 618504036c

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 23:18 UTC

iOS: read files, search and outline; fix repeated reminders and moved folders

The iOS app opens files from the agenda, folders, search and reminders in a
reader: headings fold and unfold as TAB cycles them, starting from #+STARTUP
and VISIBILITY, text is styled as in the editor (ReadingText in
OrgPresentation hides link markup and folded drawers), links are followed as
on the Mac, and an outline sheet jumps to a heading. Search finds files by
name and headings by their words.

Both apps: a reminder whose warning time had passed fired again on every
refresh; the scheduler no longer sets one that fired. Index rows of a folder
that moved stayed in search; they're removed, and adding the moved folder
again adds nothing.

Layout: unified · split

Sources/OrgApp/ReminderScheduler.swift +14 −1
@@ -11,6 +11,8 @@ public protocol ReminderCenter: Sendable {
1111 func authorize() async -> Bool
1212 /// IDs of pending requests.
1313 func pendingIDs() async -> [String]
14 /// IDs of notifications shown and still in Notification Center.
15 func deliveredIDs() async -> [String]
1416 func add(_ reminder: Reminder) async throws
1517 func remove(_ ids: [String])
1618}
@@ -25,6 +27,8 @@ public final class ReminderScheduler {
2527 public private(set) var isAuthorized = true
2628
2729 private let center: ReminderCenter
30 /// What this run set; one no longer pending has fired and isn't set again.
31 private var scheduled: Set<String> = []
2832
2933 public init(center: ReminderCenter) {
3034 self.center = center
@@ -41,8 +45,12 @@ public final class ReminderScheduler {
4145 let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") })
4246 let stale = pending.subtracting(wanted)
4347 if !stale.isEmpty { center.remove(Array(stale)) }
44 for reminder in reminders where !pending.contains(reminder.id) {
48 scheduled.subtract(stale)
49 // A reminder whose warning time has passed fires at once; once shown, it stays shown.
50 let delivered = Set(await center.deliveredIDs())
51 for reminder in reminders where !pending.contains(reminder.id) && !scheduled.contains(reminder.id) && !delivered.contains(reminder.id) {
4552 try? await center.add(reminder)
53 scheduled.insert(reminder.id)
4654 }
4755 scheduledThrough = reminders.map(\.eventDate).max()
4856 }
@@ -51,6 +59,7 @@ public final class ReminderScheduler {
5159 public func clear() async {
5260 let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }
5361 if !ours.isEmpty { center.remove(ours) }
62 scheduled = []
5463 scheduledThrough = nil
5564 }
5665}
@@ -88,6 +97,10 @@ public struct SystemReminderCenter: ReminderCenter {
8897 try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: reminder.id, content: content, trigger: trigger))
8998 }
9099
100 public func deliveredIDs() async -> [String] {
101 await UNUserNotificationCenter.current().deliveredNotifications().map(\.request.identifier)
102 }
103
91104 public func remove(_ ids: [String]) {
92105 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
93106 }
Sources/OrgApp/WorkspaceModel.swift +9 −1
@@ -69,7 +69,8 @@ public final class WorkspaceModel {
6969
7070 public func addRoot(_ url: URL) async throws {
7171 let root = try WorkspaceRoot(url: url)
72 guard !roots.contains(where: { $0.path == root.path }) else { return }
72 // A folder already added, perhaps since moved: its bookmark resolves to it.
73 guard !roots.contains(where: { $0.path == root.path }), resolvedRoots[root.path] == nil else { return }
7374 roots.append(root)
7475 try saveRoots()
7576 resolveRoots()
@@ -145,6 +146,13 @@ public final class WorkspaceModel {
145146 lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
146147 }
147148 }
149 // Rows of folders that moved or went away (indexed under their old paths).
150 let current = Array(resolvedRoots.keys)
151 if let indexed = try? index.files() {
152 var change = IndexChange()
153 change.removals = indexed.map(\.path).filter { path in !current.contains { path.hasPrefix($0 + "/") } }
154 if !change.removals.isEmpty { try? index.apply(change) }
155 }
148156 try? reloadFiles()
149157 setDownloading(pending)
150158 }
Sources/OrgPresentation/ReadingText.swift added +55
@@ -0,0 +1,55 @@
1import Foundation
2import OrgCore
3
4/// A range of a file to read rather than edit: its style runs as segments, with the brackets
5/// and targets of described links hidden (`org-link-descriptive`) and folded drawers shown as
6/// their first line and `…`, as the editor shows them.
7public enum ReadingText {
8 public struct Segment: Sendable, Equatable {
9 public var text: String
10 public var roles: Set<StyleRole>
11 /// The link this text belongs to.
12 public var link: OrgLink?
13 /// Where the text starts in the file.
14 public var offset: Int
15 }
16
17 /// `folded` are drawers' and blocks' hidden ranges (`Wrapper.hidden`).
18 public static func segments(_ tree: OrgTree, in range: Range<Int>, folded: [Range<Int>] = []) -> [Segment] {
19 let text = tree.text as NSString
20 let runs = Presentation.styles(tree, in: range)
21 // A fold hides from the end of the opening line through the closing line, so the
22 // marker sits on the opening line.
23 let folds = folded.compactMap { r -> Range<Int>? in
24 guard r.lowerBound > range.lowerBound, r.lowerBound <= range.upperBound else { return nil }
25 let end = r.upperBound > r.lowerBound && r.upperBound <= text.length && text.character(at: r.upperBound - 1) == 10 ? r.upperBound - 1 : r.upperBound
26 return (r.lowerBound - 1)..<min(end, range.upperBound)
27 }
28 let hidden = runs.filter { $0.role == .linkMarkup }.map(\.range) + folds
29 var bounds: Set<Int> = [range.lowerBound, range.upperBound]
30 for r in runs.map(\.range) + hidden {
31 bounds.insert(max(range.lowerBound, min(range.upperBound, r.lowerBound)))
32 bounds.insert(max(range.lowerBound, min(range.upperBound, r.upperBound)))
33 }
34 let points = bounds.sorted()
35 var segments: [Segment] = []
36 func append(_ segment: Segment) {
37 if var last = segments.last, last.roles == segment.roles, last.link == segment.link, last.offset + (last.text as NSString).length == segment.offset {
38 last.text += segment.text
39 segments[segments.count - 1] = last
40 } else {
41 segments.append(segment)
42 }
43 }
44 for (a, b) in zip(points, points.dropFirst()) where a < b {
45 if folds.contains(where: { $0.lowerBound == a }) {
46 segments.append(Segment(text: "…", roles: [.metadata], link: nil, offset: a))
47 }
48 if hidden.contains(where: { $0.lowerBound <= a && b <= $0.upperBound }) { continue }
49 let roles = Set(runs.filter { $0.range.lowerBound <= a && b <= $0.range.upperBound }.map(\.role))
50 let link = roles.contains(.link) ? Links.link(at: a, text: tree.text, tree: tree)?.link : nil
51 append(Segment(text: text.substring(with: NSRange(location: a, length: b - a)), roles: roles, link: link, offset: a))
52 }
53 return segments
54 }
55}
Sources/OrgstarMobile/AgendaScreen.swift +15 −5
@@ -105,6 +105,21 @@ struct AgendaScreen: View {
105105 }
106106
107107 private func row(_ item: AgendaItem) -> some SwiftUI.View {
108 Group {
109 if let path = item.path, let offset = item.headingOffset {
110 NavigationLink(value: ReaderRoute.offset(path, offset)) { entry(item) }
111 } else {
112 entry(item)
113 }
114 }
115 .swipeActions(edge: .trailing) {
116 if item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
117 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
118 }
119 }
120 }
121
122 private func entry(_ item: AgendaItem) -> some SwiftUI.View {
108123 VStack(alignment: .leading, spacing: 2) {
109124 HStack(alignment: .firstTextBaseline, spacing: 6) {
110125 if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) {
@@ -121,11 +136,6 @@ struct AgendaScreen: View {
121136 .font(.caption)
122137 .foregroundStyle(.secondary)
123138 }
124 .swipeActions(edge: .trailing) {
125 if item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
126 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
127 }
128 }
129139 }
130140
131141 /// The first done keyword of the item's TODO sequence.
Sources/OrgstarMobile/FoldersScreen.swift +3 −1
@@ -14,7 +14,9 @@ struct FoldersScreen: View {
1414 Section((root as NSString).lastPathComponent) {
1515 let files = workspace.files.filter { $0.root == root && $0.kind == .org }
1616 ForEach(files) { file in
17 Label(file.relativePath, systemImage: "doc.text")
17 NavigationLink(value: ReaderRoute.file(file.path)) {
18 Label(file.relativePath, systemImage: "doc.text")
19 }
1820 }
1921 let pending = workspace.downloading.filter { $0.hasPrefix(root + "/") }.sorted()
2022 ForEach(pending, id: \.self) { path in
Sources/OrgstarMobile/MobileModel.swift +2
@@ -18,6 +18,8 @@ final class MobileModel {
1818 let scheduler = ReminderScheduler(center: SystemReminderCenter())
1919 private(set) var configFolder: URL?
2020 private(set) var configProblems: [String] = []
21 /// An entry to show, from a tapped reminder.
22 var openRequest: ReaderRoute?
2123 /// Bumped when settings apply, so views that read them update.
2224 private(set) var settingsVersion = 0
2325 @ObservationIgnored private var presenter: FolderPresenter?
Sources/OrgstarMobile/OrgstarMobileApp.swift +46 −6
@@ -14,6 +14,7 @@ struct OrgstarMobileApp: App {
1414 WindowGroup {
1515 if let workspace = model.workspace, let agenda = model.agenda {
1616 RootView(model: model, workspace: workspace, agenda: agenda)
17 .onAppear { delegate.model = model }
1718 } else {
1819 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? ""))
1920 }
@@ -21,8 +22,10 @@ struct OrgstarMobileApp: App {
2122 }
2223}
2324
24/// Reminders show while the app is open too.
25/// Reminders show while the app is open too, and open their entry when tapped.
2526final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
27 @MainActor var model: MobileModel?
28
2629 func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
2730 UNUserNotificationCenter.current().delegate = self
2831 return true
@@ -31,6 +34,23 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
3134 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
3235 [.banner, .sound, .list]
3336 }
37
38 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
39 let info = response.notification.request.content.userInfo
40 guard let path = info["path"] as? String, let offset = info["offset"] as? Int else { return }
41 await MainActor.run { model?.openRequest = .offset(path, offset) }
42 }
43}
44
45enum AppTab: Hashable { case agenda, folders, settings, search }
46
47extension View {
48 /// Readers pushed for `ReaderRoute`s.
49 func readerDestination(workspace: WorkspaceModel, session: DocumentSession) -> some View {
50 navigationDestination(for: ReaderRoute.self) { route in
51 ReaderScreen(route: route, workspace: workspace, session: session)
52 }
53 }
3454}
3555
3656/// An org-protocol capture, or a plain one.
@@ -45,6 +65,8 @@ struct RootView: View {
4565 let workspace: WorkspaceModel
4666 let agenda: AgendaModel
4767 @State private var capture: CaptureRequest?
68 @State private var tab = AppTab.agenda
69 @State private var agendaPath = NavigationPath()
4870 @State private var hour = 0
4971 @Environment(\.scenePhase) private var scenePhase
5072
@@ -60,22 +82,40 @@ struct RootView: View {
6082 }
6183
6284 var body: some View {
63 TabView {
64 Tab("Agenda", systemImage: "calendar") {
65 NavigationStack {
85 TabView(selection: $tab) {
86 Tab("Agenda", systemImage: "calendar", value: .agenda) {
87 NavigationStack(path: $agendaPath) {
6688 AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler)
6789 .toolbar { captureButton }
90 .readerDestination(workspace: workspace, session: session)
6891 }
6992 }
70 Tab("Folders", systemImage: "folder") {
93 Tab("Folders", systemImage: "folder", value: .folders) {
7194 NavigationStack {
7295 FoldersScreen(workspace: workspace)
7396 .toolbar { captureButton }
97 .readerDestination(workspace: workspace, session: session)
7498 }
7599 }
76 Tab("Settings", systemImage: "gearshape") {
100 Tab("Settings", systemImage: "gearshape", value: .settings) {
77101 NavigationStack { SettingsScreen(model: model) }
78102 }
103 Tab(value: .search, role: .search) {
104 NavigationStack {
105 SearchScreen(workspace: workspace, session: session)
106 .readerDestination(workspace: workspace, session: session)
107 }
108 }
109 }
110 .onChange(of: model.openRequest) {
111 guard let route = model.openRequest else { return }
112 model.openRequest = nil
113 tab = .agenda
114 agendaPath.append(route)
115 }
116 .onChange(of: workspace.changedFile?.version) {
117 // An open file takes in what changed on disk.
118 if let path = workspace.changedFile?.path { session.fileChanged(path) }
79119 }
80120 .sheet(item: $capture) { request in
81121 CaptureScreen(workspace: workspace, session: session, request: request)
Sources/OrgstarMobile/ReaderScreen.swift added +241
@@ -0,0 +1,241 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import OrgIndex
5import OrgPresentation
6import SwiftUI
7
8/// What to read: a file, and where in it.
9enum ReaderRoute: Hashable {
10 case file(String)
11 case offset(String, Int)
12 /// A search result, found again if the file changed since it was indexed.
13 case heading(HeadingLocation)
14}
15
16/// A file to read: headings that fold and unfold with a tap, as TAB cycles them, text styled as
17/// in the editor, and links to follow.
18struct ReaderScreen: View {
19 let route: ReaderRoute
20 let workspace: WorkspaceModel
21 let session: DocumentSession
22 @State private var fold = FoldState()
23 @State private var folded: [Range<Int>] = []
24 @State private var loadedPath: String?
25 @State private var showingOutline = false
26 @State private var message: String?
27 @Environment(\.openURL) private var openURL
28
29 private var outline: [OutlineEntry] { session.outline }
30
31 var body: some View {
32 let _ = session.editVersion
33 ScrollViewReader { proxy in
34 ScrollView {
35 LazyVStack(alignment: .leading, spacing: 8) {
36 if let tree = session.document?.tree {
37 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length
38 if preambleEnd > 0 { textView(tree, 0..<preambleEnd) }
39 ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in
40 if isShown(index) {
41 heading(tree, entry).id(entry.start)
42 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
43 let body = bodyRange(index, tree)
44 if !body.isEmpty { textView(tree, body).padding(.leading, indent(entry)) }
45 }
46 }
47 }
48 }
49 }
50 .padding()
51 .frame(maxWidth: .infinity, alignment: .leading)
52 }
53 .onChange(of: session.revealRequest?.id) {
54 guard let offset = session.revealRequest?.offset else { return }
55 reveal(offset, proxy)
56 }
57 }
58 .navigationTitle(((session.path ?? "") as NSString).lastPathComponent)
59 .navigationBarTitleDisplayMode(.inline)
60 .toolbar {
61 ToolbarItem(placement: .primaryAction) {
62 Button("Outline", systemImage: "list.bullet.indent") { showingOutline = true }.disabled(outline.isEmpty)
63 }
64 }
65 .sheet(isPresented: $showingOutline) { outlineSheet }
66 .safeAreaInset(edge: .bottom) {
67 if let message {
68 Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
69 .onTapGesture { self.message = nil }
70 }
71 }
72 .environment(\.openURL, OpenURLAction { url in follow(url) })
73 .onAppear(perform: open)
74 .onChange(of: session.path) { loadFolds() }
75 .onChange(of: session.editVersion) { loadFolds() }
76 }
77
78 // MARK: - Content
79
80 private func indent(_ entry: OutlineEntry) -> CGFloat { CGFloat(entry.level - 1) * 14 }
81
82 /// Hidden under a folded ancestor.
83 private func isShown(_ index: Int) -> Bool {
84 var parent = outline[index].parent
85 while let p = parent {
86 if fold.folds.contains(outline[p].start) { return false }
87 parent = outline[p].parent
88 }
89 return true
90 }
91
92 /// From after the heading line to the first child, or the section's end.
93 private func bodyRange(_ index: Int, _ tree: OrgTree) -> Range<Int> {
94 let entry = outline[index]
95 let ns = tree.text as NSString
96 let lineEnd = NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
97 let firstChild = outline[(index + 1)...].first { $0.parent == index }?.start ?? entry.end
98 return min(lineEnd, firstChild)..<firstChild
99 }
100
101 private func heading(_ tree: OrgTree, _ entry: OutlineEntry) -> some View {
102 let ns = tree.text as NSString
103 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
104 let title = min(entry.start + entry.level + 1, NSMaxRange(line))
105 line.length = NSMaxRange(line) - title
106 let text = attributed(ReadingText.segments(tree, in: title..<(title + line.length)), heading: entry.level)
107 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
108 let foldedMark = fold.folds.contains(entry.start) && hasContent
109 return HStack(alignment: .firstTextBaseline, spacing: 6) {
110 Image(systemName: foldedMark ? "chevron.right" : "chevron.down")
111 .font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
112 .opacity(hasContent ? 1 : 0)
113 Text(text.trimmingTrailingNewlines()).frame(maxWidth: .infinity, alignment: .leading)
114 }
115 .padding(.leading, indent(entry))
116 .padding(.top, entry.level == 1 ? 6 : 0)
117 .contentShape(Rectangle())
118 .onTapGesture { fold = Presentation.cycle(entry.start, in: tree, fold) }
119 .accessibilityAddTraits(.isButton)
120 .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading")
121 }
122
123 private func textView(_ tree: OrgTree, _ range: Range<Int>) -> some View {
124 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil).trimmingTrailingNewlines())
125 .textSelection(.enabled)
126 .frame(maxWidth: .infinity, alignment: .leading)
127 }
128
129 /// Segments as styled text; links get `orgstar-link:` URLs to follow.
130 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?) -> AttributedString {
131 var result = AttributedString()
132 for segment in segments {
133 var part = AttributedString(segment.text)
134 if let level {
135 part.font = level == 1 ? .title3.weight(.semibold) : level == 2 ? .headline : .body.weight(.semibold)
136 }
137 for role in segment.roles {
138 switch role {
139 case .todo: part.foregroundColor = .red; part.font = (part.font ?? .body).weight(.semibold)
140 case .done: part.foregroundColor = .green
141 case .priority: part.foregroundColor = .orange
142 case .tags: part.foregroundColor = .secondary; part.font = .caption
143 case .bold: part.inlinePresentationIntent = .stronglyEmphasized
144 case .italic: part.inlinePresentationIntent = .emphasized
145 case .underline: part.underlineStyle = .single
146 case .strikeThrough: part.strikethroughStyle = .single
147 case .verbatim, .code, .inlineSource: part.font = .body.monospaced()
148 case .markup, .leadingStars: part.foregroundColor = .secondary
149 case .timestamp: part.foregroundColor = .purple
150 case .metadata, .keyword, .comment, .blockDelimiter:
151 part.foregroundColor = .secondary
152 part.font = .footnote.monospaced()
153 case .block, .table, .syntax: part.font = .callout.monospaced()
154 default: break
155 }
156 }
157 if segment.link != nil {
158 // Found again by position when followed.
159 part.link = URL(string: "orgstar-link:\(segment.offset)")
160 part.foregroundColor = .accentColor
161 }
162 result += part
163 }
164 return result
165 }
166
167 // MARK: - Actions
168
169 private func open() {
170 switch route {
171 case .file(let path): if session.path != path { session.open(path) }
172 case .offset(let path, let offset):
173 if session.path != path { session.open(path) }
174 session.reveal(offset)
175 case .heading(let location): session.show(location)
176 }
177 loadFolds()
178 }
179
180 /// The file's startup visibility, kept while the same file is open.
181 private func loadFolds() {
182 guard let tree = session.document?.tree else { return }
183 folded = Presentation.startupWrappers(tree).map(\.hidden)
184 guard loadedPath != session.path else { return }
185 loadedPath = session.path
186 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
187 }
188
189 /// Unfolds the way to the heading containing `offset` and scrolls to it.
190 private func reveal(_ offset: Int, _ proxy: ScrollViewProxy) {
191 guard let index = outline.lastIndex(where: { $0.start <= offset }) else { return }
192 var parent = outline[index].parent
193 while let p = parent {
194 fold.folds.remove(outline[p].start)
195 parent = outline[p].parent
196 }
197 let start = outline[index].start
198 DispatchQueue.main.async { withAnimation { proxy.scrollTo(start, anchor: .top) } }
199 }
200
201 private func follow(_ url: URL) -> OpenURLAction.Result {
202 guard url.scheme == "orgstar-link", let offset = Int(url.absoluteString.dropFirst("orgstar-link:".count)),
203 let tree = session.document?.tree, let link = Links.link(at: offset, text: tree.text, tree: tree)?.link else {
204 return .systemAction
205 }
206 switch session.follow(link, workspace: workspace, isText: { _ in false }) {
207 case .shown: message = nil
208 case .openExternally(let target): openURL(target)
209 case .message(let text): message = text
210 }
211 return .handled
212 }
213
214 private var outlineSheet: some View {
215 NavigationStack {
216 List(outline, id: \.start) { entry in
217 Button {
218 showingOutline = false
219 session.reveal(entry.start)
220 } label: {
221 Text(entry.title.isEmpty ? "(untitled)" : entry.title)
222 .padding(.leading, indent(entry))
223 .foregroundStyle(.primary)
224 }
225 }
226 .navigationTitle("Outline")
227 .navigationBarTitleDisplayMode(.inline)
228 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { showingOutline = false } } }
229 }
230 }
231}
232
233private extension AttributedString {
234 /// Without the final newline, which `Text` would show as an empty line.
235 func trimmingTrailingNewlines() -> AttributedString {
236 var copy = self
237 while let last = copy.characters.last, last.isNewline { copy.characters.removeLast() }
238 return copy
239 }
240}
241#endif
Sources/OrgstarMobile/SearchScreen.swift added +51
@@ -0,0 +1,51 @@
1#if os(iOS)
2import OrgApp
3import OrgIndex
4import SwiftUI
5
6/// Files by name and headings by their words, as ⌘P and ⇧⌘F find them on the Mac.
7struct SearchScreen: View {
8 let workspace: WorkspaceModel
9 let session: DocumentSession
10 @State private var query = ""
11
12 var body: some View {
13 let trimmed = query.trimmingCharacters(in: .whitespaces)
14 List {
15 if !trimmed.isEmpty {
16 let files = FuzzyMatch.rank(trimmed, workspace.files.filter { $0.kind == .org }, limit: 8)
17 if !files.isEmpty {
18 Section("Files") {
19 ForEach(files) { file in
20 NavigationLink(value: ReaderRoute.file(file.path)) {
21 Label(file.relativePath, systemImage: "doc.text")
22 }
23 }
24 }
25 }
26 let headings = workspace.search(trimmed, overlay: workspace.overlay(session.openDocuments))
27 Section("Headings") {
28 if headings.isEmpty { Text("No headings match").foregroundStyle(.secondary) }
29 ForEach(headings, id: \.self) { location in
30 NavigationLink(value: ReaderRoute.heading(location)) {
31 VStack(alignment: .leading, spacing: 2) {
32 Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(2)
33 Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary)
34 }
35 }
36 }
37 }
38 }
39 }
40 .overlay {
41 if trimmed.isEmpty {
42 ContentUnavailableView("Search notes", systemImage: "magnifyingglass", description: Text("File names and the words of headings and their text."))
43 }
44 }
45 .navigationTitle("Search")
46 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
47 .autocorrectionDisabled()
48 .textInputAutocapitalization(.never)
49 }
50}
51#endif
Tests/OrgAppTests/AppTests.swift +41
@@ -74,6 +74,27 @@ struct WorkspaceModelTests {
7474 #expect(reopened.search("apples").isEmpty)
7575 }
7676
77 @Test func aMovedFolderLeavesNoOldRows() async throws {
78 let parent = try Folder()
79 let original = parent.url.appendingPathComponent("notes")
80 try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true)
81 try "* Plumber\n".write(to: original.appendingPathComponent("a.org"), atomically: true, encoding: .utf8)
82 let data = parent.url.appendingPathComponent("data")
83 let model = try WorkspaceModel(directory: data)
84 try await model.addRoot(original)
85 #expect(model.search("plumber").count == 1)
86
87 let moved = parent.url.appendingPathComponent("moved")
88 try FileManager.default.moveItem(at: original, to: moved)
89 let reopened = try WorkspaceModel(directory: data)
90 await reopened.refresh()
91 let found = reopened.search("plumber").map(\.path)
92 #expect(found.count == 1 && found[0].hasSuffix("/moved/a.org"), "\(found)")
93 // Adding it again, under its new path, adds nothing.
94 try await reopened.addRoot(moved)
95 #expect(reopened.roots.count == 1)
96 }
97
7798 @Test func listsDownloadsInTheTree() {
7899 let files = [FileEntry(path: "/r/a.org", root: "/r", kind: .org)]
79100 let tree = FileTreeNode.build(root: "/r", files: files, downloading: ["/r/b.org", "/r/sub/c.org"])
@@ -535,6 +556,15 @@ final class FakeReminderCenter: ReminderCenter, @unchecked Sendable {
535556
536557 func authorize() async -> Bool { authorized }
537558 func pendingIDs() async -> [String] { lock.withLock { pending } }
559 var delivered: [String] = []
560 func deliveredIDs() async -> [String] { lock.withLock { delivered } }
561 /// The system showing a notification: no longer pending.
562 func fire(_ id: String, keep: Bool) {
563 lock.withLock {
564 pending.removeAll { $0 == id }
565 if keep { delivered.append(id) }
566 }
567 }
538568 func add(_ reminder: Reminder) async throws {
539569 lock.withLock {
540570 pending.append(reminder.id)
@@ -567,6 +597,17 @@ struct ReminderSchedulerTests {
567597 #expect(center.ids == ["someone.else"])
568598 #expect(scheduler.scheduledThrough == nil)
569599
600 // Fired reminders aren't set again, whether or not they're still in Notification Center.
601 await scheduler.reconcile([reminder("e", 5), reminder("f", 6)])
602 center.fire("orgstar.e", keep: false)
603 await scheduler.reconcile([reminder("e", 5), reminder("f", 6)])
604 #expect(center.added.filter { $0 == "orgstar.e" }.count == 1)
605 let relaunched = ReminderScheduler(center: center)
606 center.fire("orgstar.f", keep: true)
607 await relaunched.reconcile([reminder("f", 6)])
608 #expect(center.added.filter { $0 == "orgstar.f" }.count == 1)
609 await scheduler.clear()
610
570611 center.authorized = false
571612 await scheduler.reconcile([reminder("d", 4)])
572613 #expect(!scheduler.isAuthorized)
Tests/OrgPresentationTests/ReadingTextTests.swift added +21
@@ -0,0 +1,21 @@
1import Foundation
2import Testing
3import OrgCore
4@testable import OrgPresentation
5
6struct ReadingTextTests {
7 @Test func hidesLinkMarkupAndFoldedDrawers() {
8 let text = "* TODO H :t:\n:PROPERTIES:\n:ID: x\n:END:\nSee [[https://a.org][the site]] and *b* [[c.org]].\n"
9 let tree = OrgParser.parse(text)
10 let folded = Presentation.startupWrappers(tree).map(\.hidden)
11 let segments = ReadingText.segments(tree, in: 0..<(text as NSString).length, folded: folded)
12 #expect(segments.map(\.text).joined() == "* TODO H :t:\n:PROPERTIES:…\nSee the site and *b* c.org.\n")
13 let site = segments.first { $0.text == "the site" }
14 #expect(site?.link?.raw == "https://a.org" && site?.roles.contains(.link) == true)
15 #expect(segments.first { $0.text == "TODO" }?.roles.contains(.todo) == true)
16 #expect(segments.contains { $0.text == "b" && $0.roles.contains(.bold) })
17 #expect(segments.first { $0.text.hasPrefix("c.org") }?.link?.path == "c.org")
18 // Offsets point into the file.
19 #expect(site.map { (text as NSString).substring(with: NSRange(location: $0.offset, length: 8)) } == "the site")
20 }
21}