krz/orgstar

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

Commit 1535a3164e

1535a3164e694fc76fa2f655f4bab564199742b5

parent: 3bc8d0673f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 00:15 UTC

Add workspace discovery and agenda scope

Layout: unified · split

Package.swift +5 −2
@@ -7,7 +7,8 @@ let package = Package(
7 products: [ 7 products: [
8 .library(name: "OrgCore", targets: ["OrgCore"]), 8 .library(name: "OrgCore", targets: ["OrgCore"]),
9 .library(name: "OrgDocument", targets: ["OrgDocument"]), 9 .library(name: "OrgDocument", targets: ["OrgDocument"]),
10 .library(name: "OrgIndex", targets: ["OrgIndex"]) 10 .library(name: "OrgIndex", targets: ["OrgIndex"]),
11 .library(name: "OrgWorkspace", targets: ["OrgWorkspace"])
11 ], 12 ],
12 dependencies: [ 13 dependencies: [
13 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") 14 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1")
@@ -16,8 +17,10 @@ let package = Package(
16 .target(name: "OrgCore"), 17 .target(name: "OrgCore"),
17 .target(name: "OrgDocument", dependencies: ["OrgCore"]), 18 .target(name: "OrgDocument", dependencies: ["OrgCore"]),
18 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]), 19 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]),
20 .target(name: "OrgWorkspace", dependencies: ["OrgIndex"]),
19 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), 21 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
20 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), 22 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
21 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]) 23 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
24 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"])
22 ] 25 ]
23) 26)
Sources/OrgWorkspace/Discovery.swift added +110
@@ -0,0 +1,110 @@
1import Foundation
2import OrgIndex
3
4/// A folder the user added. Identified by its bookmark, so a moved folder is followed.
5public struct WorkspaceRoot: Sendable, Equatable, Codable {
6 public let bookmark: Data
7 /// The path when the bookmark was made or last refreshed, for display.
8 public var path: String
9
10 public init(url: URL) throws {
11 bookmark = try url.bookmarkData(options: [], includingResourceValuesForKeys: nil, relativeTo: nil)
12 path = url.standardizedFileURL.path
13 }
14
15 /// The folder's current location. `isStale` means the bookmark should be made again.
16 public func resolve() throws -> (url: URL, isStale: Bool) {
17 var isStale = false
18 let url = try URL(resolvingBookmarkData: bookmark, options: [], relativeTo: nil, bookmarkDataIsStale: &isStale)
19 _ = url.startAccessingSecurityScopedResource()
20 return (url.standardizedFileURL, isStale)
21 }
22}
23
24public struct DiscoveredFile: Sendable, Equatable {
25 public let url: URL
26 public let kind: FileKind
27 public let size: Int
28 public let mtime: Double
29}
30
31public struct Discovery: Sendable, Equatable {
32 public var files: [DiscoveredFile] = []
33 /// iCloud files not downloaded yet, by the name they will have.
34 public var placeholders: [URL] = []
35}
36
37public enum FileClassification: Sendable, Equatable {
38 case file(FileKind)
39 /// An iCloud placeholder (`.name.org.icloud`) for the file at the given name.
40 case placeholder(String)
41 case ignored
42}
43
44public enum WorkspaceScanner {
45 /// What a file name means to the workspace. Syncthing temp files, our own temp and backup
46 /// files, Emacs backups and auto-saves, and other hidden files are ignored.
47 public static func classify(_ name: String) -> FileClassification {
48 if name.hasPrefix(".") {
49 if name.hasSuffix(".icloud") {
50 let inner = String(name.dropFirst().dropLast(".icloud".count))
51 if case .file = classify(inner) { return .placeholder(inner) }
52 }
53 return .ignored
54 }
55 if name.hasSuffix("~") || name.hasPrefix("#") { return .ignored }
56 let conflict = name.contains(".sync-conflict-")
57 if name.hasSuffix(".org") { return .file(conflict ? .conflict : .org) }
58 if name.hasSuffix(".org_archive") { return .file(conflict ? .conflict : .archive) }
59 return .ignored
60 }
61
62 /// Every org file under `root`, skipping hidden folders (`.git`, `.stfolder`, `.stversions`).
63 public static func scan(_ root: URL) throws -> Discovery {
64 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
65 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
66 return Discovery()
67 }
68 var discovery = Discovery()
69 for case let url as URL in enumerator {
70 let values = try url.resourceValues(forKeys: Set(keys))
71 let name = url.lastPathComponent
72 if values.isDirectory == true {
73 if name.hasPrefix(".") { enumerator.skipDescendants() }
74 continue
75 }
76 switch classify(name) {
77 case .file(let kind):
78 discovery.files.append(DiscoveredFile(
79 url: url.standardizedFileURL,
80 kind: kind,
81 size: values.fileSize ?? 0,
82 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
83 ))
84 case .placeholder(let inner):
85 discovery.placeholders.append(url.deletingLastPathComponent().appendingPathComponent(inner).standardizedFileURL)
86 case .ignored:
87 break
88 }
89 }
90 discovery.files.sort { $0.url.path < $1.url.path }
91 return discovery
92 }
93}
94
95/// Which files feed the agenda: `.org` files (not archives or conflict copies) matching one of
96/// the globs, relative to their root. No globs means every `.org` file.
97public struct AgendaScope: Sendable, Equatable {
98 public var globs: [String]
99
100 public init(globs: [String] = []) {
101 self.globs = globs
102 }
103
104 public func contains(relativePath: String, kind: FileKind) -> Bool {
105 guard kind == .org else { return false }
106 guard !globs.isEmpty else { return true }
107 // Without FNM_PATHNAME, `*` also matches `/`, so `work/*` covers nested folders.
108 return globs.contains { fnmatch($0, relativePath, 0) == 0 }
109 }
110}
Tests/OrgWorkspaceTests/DiscoveryTests.swift added +46
@@ -0,0 +1,46 @@
1import Foundation
2import OrgIndex
3import Testing
4@testable import OrgWorkspace
5
6struct ClassificationTests {
7 @Test(arguments: [
8 ("a.org", FileClassification.file(.org)),
9 ("a.org_archive", .file(.archive)),
10 ("a.sync-conflict-20261004-120000-ABCDEFG.org", .file(.conflict)),
11 (".a.org.icloud", .placeholder("a.org")),
12 (".syncthing.a.org.tmp", .ignored),
13 (".a.org.orgstar-1234", .ignored),
14 ("a.org~", .ignored),
15 ("#a.org#", .ignored),
16 ("a.md", .ignored),
17 (".hidden.org", .ignored),
18 ])
19 func classify(name: String, expected: FileClassification) {
20 #expect(WorkspaceScanner.classify(name) == expected)
21 }
22
23 @Test func agendaScope() {
24 #expect(AgendaScope().contains(relativePath: "a.org", kind: .org))
25 #expect(!AgendaScope().contains(relativePath: "a.org_archive", kind: .archive))
26 #expect(!AgendaScope().contains(relativePath: "a.org", kind: .conflict))
27 let work = AgendaScope(globs: ["work/*"])
28 #expect(work.contains(relativePath: "work/deep/a.org", kind: .org))
29 #expect(!work.contains(relativePath: "home/a.org", kind: .org))
30 }
31}
32
33struct ScanTests {
34 @Test func skipsHiddenFoldersAndFindsPlaceholders() throws {
35 let folder = try Folder()
36 try folder.write("a.org", "* a\n")
37 try folder.write("sub/b.org_archive", "* b\n")
38 try folder.write(".git/c.org", "* c\n")
39 try folder.write(".stversions/d.org", "* d\n")
40 try folder.write("sub/.e.org.icloud", "")
41 try folder.write("notes.txt", "")
42 let discovery = try WorkspaceScanner.scan(folder.url)
43 #expect(discovery.files.map(\.url.lastPathComponent) == ["a.org", "b.org_archive"])
44 #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"])
45 }
46}
Tests/OrgWorkspaceTests/Folder.swift added +23
@@ -0,0 +1,23 @@
1import Foundation
2
3/// A temporary folder that removes itself.
4final class Folder {
5 let url: URL
6
7 init() throws {
8 url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)").standardizedFileURL
9 try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
10 }
11
12 deinit {
13 try? FileManager.default.removeItem(at: url)
14 }
15
16 @discardableResult
17 func write(_ path: String, _ text: String) throws -> URL {
18 let file = url.appendingPathComponent(path)
19 try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
20 try Data(text.utf8).write(to: file)
21 return file.standardizedFileURL
22 }
23}