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 {
11 func authorize() async -> Bool 11 func authorize() async -> Bool
12 /// IDs of pending requests. 12 /// IDs of pending requests.
13 func pendingIDs() async -> [String] 13 func pendingIDs() async -> [String]
14 /// IDs of notifications shown and still in Notification Center.
15 func deliveredIDs() async -> [String]
14 func add(_ reminder: Reminder) async throws 16 func add(_ reminder: Reminder) async throws
15 func remove(_ ids: [String]) 17 func remove(_ ids: [String])
16} 18}
@@ -25,6 +27,8 @@ public final class ReminderScheduler {
25 public private(set) var isAuthorized = true 27 public private(set) var isAuthorized = true
26 28
27 private let center: ReminderCenter 29 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> = []
28 32
29 public init(center: ReminderCenter) { 33 public init(center: ReminderCenter) {
30 self.center = center 34 self.center = center
@@ -41,8 +45,12 @@ public final class ReminderScheduler {
41 let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }) 45 let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") })
42 let stale = pending.subtracting(wanted) 46 let stale = pending.subtracting(wanted)
43 if !stale.isEmpty { center.remove(Array(stale)) } 47 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) {
45 try? await center.add(reminder) 52 try? await center.add(reminder)
53 scheduled.insert(reminder.id)
46 } 54 }
47 scheduledThrough = reminders.map(\.eventDate).max() 55 scheduledThrough = reminders.map(\.eventDate).max()
48 } 56 }
@@ -51,6 +59,7 @@ public final class ReminderScheduler {
51 public func clear() async { 59 public func clear() async {
52 let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") } 60 let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }
53 if !ours.isEmpty { center.remove(ours) } 61 if !ours.isEmpty { center.remove(ours) }
62 scheduled = []
54 scheduledThrough = nil 63 scheduledThrough = nil
55 } 64 }
56} 65}
@@ -88,6 +97,10 @@ public struct SystemReminderCenter: ReminderCenter {
88 try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: reminder.id, content: content, trigger: trigger)) 97 try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: reminder.id, content: content, trigger: trigger))
89 } 98 }
90 99
100 public func deliveredIDs() async -> [String] {
101 await UNUserNotificationCenter.current().deliveredNotifications().map(\.request.identifier)
102 }
103
91 public func remove(_ ids: [String]) { 104 public func remove(_ ids: [String]) {
92 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) 105 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
93 } 106 }
Sources/OrgApp/WorkspaceModel.swift +9 −1
@@ -69,7 +69,8 @@ public final class WorkspaceModel {
69 69
70 public func addRoot(_ url: URL) async throws { 70 public func addRoot(_ url: URL) async throws {
71 let root = try WorkspaceRoot(url: url) 71 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 }
73 roots.append(root) 74 roots.append(root)
74 try saveRoots() 75 try saveRoots()
75 resolveRoots() 76 resolveRoots()
@@ -145,6 +146,13 @@ public final class WorkspaceModel {
145 lastError = "Indexing \(url.path) failed: \(error.localizedDescription)" 146 lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
146 } 147 }
147 } 148 }
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 }
148 try? reloadFiles() 156 try? reloadFiles()
149 setDownloading(pending) 157 setDownloading(pending)
150 } 158 }
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 {
105 } 105 }
106 106
107 private func row(_ item: AgendaItem) -> some SwiftUI.View { 107 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 {
108 VStack(alignment: .leading, spacing: 2) { 123 VStack(alignment: .leading, spacing: 2) {
109 HStack(alignment: .firstTextBaseline, spacing: 6) { 124 HStack(alignment: .firstTextBaseline, spacing: 6) {
110 if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) { 125 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 {
121 .font(.caption) 136 .font(.caption)
122 .foregroundStyle(.secondary) 137 .foregroundStyle(.secondary)
123 } 138 }
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 }
129 } 139 }
130 140
131 /// The first done keyword of the item's TODO sequence. 141 /// The first done keyword of the item's TODO sequence.
Sources/OrgstarMobile/FoldersScreen.swift +3 −1
@@ -14,7 +14,9 @@ struct FoldersScreen: View {
14 Section((root as NSString).lastPathComponent) { 14 Section((root as NSString).lastPathComponent) {
15 let files = workspace.files.filter { $0.root == root && $0.kind == .org } 15 let files = workspace.files.filter { $0.root == root && $0.kind == .org }
16 ForEach(files) { file in 16 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 }
18 } 20 }
19 let pending = workspace.downloading.filter { $0.hasPrefix(root + "/") }.sorted() 21 let pending = workspace.downloading.filter { $0.hasPrefix(root + "/") }.sorted()
20 ForEach(pending, id: \.self) { path in 22 ForEach(pending, id: \.self) { path in
Sources/OrgstarMobile/MobileModel.swift +2
@@ -18,6 +18,8 @@ final class MobileModel {
18 let scheduler = ReminderScheduler(center: SystemReminderCenter()) 18 let scheduler = ReminderScheduler(center: SystemReminderCenter())
19 private(set) var configFolder: URL? 19 private(set) var configFolder: URL?
20 private(set) var configProblems: [String] = [] 20 private(set) var configProblems: [String] = []
21 /// An entry to show, from a tapped reminder.
22 var openRequest: ReaderRoute?
21 /// Bumped when settings apply, so views that read them update. 23 /// Bumped when settings apply, so views that read them update.
22 private(set) var settingsVersion = 0 24 private(set) var settingsVersion = 0
23 @ObservationIgnored private var presenter: FolderPresenter? 25 @ObservationIgnored private var presenter: FolderPresenter?
Sources/OrgstarMobile/OrgstarMobileApp.swift +46 −6
@@ -14,6 +14,7 @@ struct OrgstarMobileApp: App {
14 WindowGroup { 14 WindowGroup {
15 if let workspace = model.workspace, let agenda = model.agenda { 15 if let workspace = model.workspace, let agenda = model.agenda {
16 RootView(model: model, workspace: workspace, agenda: agenda) 16 RootView(model: model, workspace: workspace, agenda: agenda)
17 .onAppear { delegate.model = model }
17 } else { 18 } else {
18 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? "")) 19 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? ""))
19 } 20 }
@@ -21,8 +22,10 @@ struct OrgstarMobileApp: App {
21 } 22 }
22} 23}
23 24
24/// Reminders show while the app is open too. 25/// Reminders show while the app is open too, and open their entry when tapped.
25final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { 26final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
27 @MainActor var model: MobileModel?
28
26 func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 29 func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
27 UNUserNotificationCenter.current().delegate = self 30 UNUserNotificationCenter.current().delegate = self
28 return true 31 return true
@@ -31,6 +34,23 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
31 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { 34 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
32 [.banner, .sound, .list] 35 [.banner, .sound, .list]
33 } 36 }
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 }
34} 54}
35 55
36/// An org-protocol capture, or a plain one. 56/// An org-protocol capture, or a plain one.
@@ -45,6 +65,8 @@ struct RootView: View {
45 let workspace: WorkspaceModel 65 let workspace: WorkspaceModel
46 let agenda: AgendaModel 66 let agenda: AgendaModel
47 @State private var capture: CaptureRequest? 67 @State private var capture: CaptureRequest?
68 @State private var tab = AppTab.agenda
69 @State private var agendaPath = NavigationPath()
48 @State private var hour = 0 70 @State private var hour = 0
49 @Environment(\.scenePhase) private var scenePhase 71 @Environment(\.scenePhase) private var scenePhase
50 72
@@ -60,22 +82,40 @@ struct RootView: View {
60 } 82 }
61 83
62 var body: some View { 84 var body: some View {
63 TabView { 85 TabView(selection: $tab) {
64 Tab("Agenda", systemImage: "calendar") { 86 Tab("Agenda", systemImage: "calendar", value: .agenda) {
65 NavigationStack { 87 NavigationStack(path: $agendaPath) {
66 AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler) 88 AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler)
67 .toolbar { captureButton } 89 .toolbar { captureButton }
90 .readerDestination(workspace: workspace, session: session)
68 } 91 }
69 } 92 }
70 Tab("Folders", systemImage: "folder") { 93 Tab("Folders", systemImage: "folder", value: .folders) {
71 NavigationStack { 94 NavigationStack {
72 FoldersScreen(workspace: workspace) 95 FoldersScreen(workspace: workspace)
73 .toolbar { captureButton } 96 .toolbar { captureButton }
97 .readerDestination(workspace: workspace, session: session)
74 } 98 }
75 } 99 }
76 Tab("Settings", systemImage: "gearshape") { 100 Tab("Settings", systemImage: "gearshape", value: .settings) {
77 NavigationStack { SettingsScreen(model: model) } 101 NavigationStack { SettingsScreen(model: model) }
78 } 102 }
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) }
79 } 119 }
80 .sheet(item: $capture) { request in 120 .sheet(item: $capture) { request in
81 CaptureScreen(workspace: workspace, session: session, request: request) 121 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 {
74 #expect(reopened.search("apples").isEmpty) 74 #expect(reopened.search("apples").isEmpty)
75 } 75 }
76 76
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
77 @Test func listsDownloadsInTheTree() { 98 @Test func listsDownloadsInTheTree() {
78 let files = [FileEntry(path: "/r/a.org", root: "/r", kind: .org)] 99 let files = [FileEntry(path: "/r/a.org", root: "/r", kind: .org)]
79 let tree = FileTreeNode.build(root: "/r", files: files, downloading: ["/r/b.org", "/r/sub/c.org"]) 100 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 {
535 556
536 func authorize() async -> Bool { authorized } 557 func authorize() async -> Bool { authorized }
537 func pendingIDs() async -> [String] { lock.withLock { pending } } 558 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 }
538 func add(_ reminder: Reminder) async throws { 568 func add(_ reminder: Reminder) async throws {
539 lock.withLock { 569 lock.withLock {
540 pending.append(reminder.id) 570 pending.append(reminder.id)
@@ -567,6 +597,17 @@ struct ReminderSchedulerTests {
567 #expect(center.ids == ["someone.else"]) 597 #expect(center.ids == ["someone.else"])
568 #expect(scheduler.scheduledThrough == nil) 598 #expect(scheduler.scheduledThrough == nil)
569 599
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
570 center.authorized = false 611 center.authorized = false
571 await scheduler.reconcile([reminder("d", 4)]) 612 await scheduler.reconcile([reminder("d", 4)])
572 #expect(!scheduler.isAuthorized) 613 #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}