krz/orgstar

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

Commit a6139aad41

a6139aad417b2a36bcbaca055d07319ffb0226da

parent: 997e6c12fc

Verified · cmc

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

Dotfiles, and distinct number color

Folders list dotfiles and dot folders (show-hidden-files, on by
default), except folders named in ignored-folders: version control,
Syncthing, Trash, caches and build output by default. Syncthing temp
files, Emacs lock files, Orgstar's temp files and .DS_Store stay out,
and dotfiles stay out of the agenda as org-agenda-file-regexp has it.
The default theme gives code numbers and constants their own color,
apart from keys and properties.

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +2 −1
@@ -35,7 +35,8 @@ public final class AgendaModel {
35 /// Emacs lists a directory in `org-agenda-files`. 35 /// Emacs lists a directory in `org-agenda-files`.
36 public var files: [String] { 36 public var files: [String] {
37 workspace.files 37 workspace.files
38 .filter { $0.kind == .org && (includeSubfolders || !$0.relativePath.contains("/")) } 38 // Dotfiles stay out, as `org-agenda-file-regexp` leaves them out.
39 .filter { $0.kind == .org && !$0.name.hasPrefix(".") && (includeSubfolders || !$0.relativePath.contains("/")) }
39 .map(\.path) 40 .map(\.path)
40 .sorted() 41 .sorted()
41 } 42 }
Sources/OrgApp/UserConfig.swift +6
@@ -1,6 +1,7 @@
1import Foundation 1import Foundation
2import OrgCore 2import OrgCore
3import OrgPresentation 3import OrgPresentation
4import OrgWorkspace
4 5
5/// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and 6/// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and
6/// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a 7/// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a
@@ -100,6 +101,11 @@ public enum UserConfig {
100 comment: "left, right, both or none", legacy: "general.option-as-meta"), 101 comment: "left, right, both or none", legacy: "general.option-as-meta"),
101 Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false), 102 Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
102 comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"), 103 comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"),
104 Setting(section: "orgstar", key: "show-hidden-files", defaultsKey: "showHiddenFiles", kind: .bool, fallback: .bool(true),
105 comment: "list dotfiles and dot folders in your folders", legacy: ""),
106 Setting(section: "orgstar", key: "ignored-folders", defaultsKey: "ignoredFolders", kind: .string,
107 fallback: .string(ScanRules.defaultIgnoredFolders.sorted().joined(separator: " ")),
108 comment: "folder names never listed or searched, separated by spaces", legacy: ""),
103 Setting(section: "orgstar", key: "agenda-include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false), 109 Setting(section: "orgstar", key: "agenda-include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false),
104 comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"), 110 comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"),
105 Setting(section: "orgstar", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true), 111 Setting(section: "orgstar", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true),
Sources/OrgApp/WorkspaceModel.swift +10 −2
@@ -104,11 +104,19 @@ public final class WorkspaceModel {
104 104
105 // MARK: - Indexing 105 // MARK: - Indexing
106 106
107 /// Which files the folders show; a change rescans them.
108 public var scanRules = ScanRules() {
109 didSet {
110 guard scanRules != oldValue else { return }
111 Task { await refresh() }
112 }
113 }
114
107 /// Reconciles every root off the main thread, then reloads the file list. 115 /// Reconciles every root off the main thread, then reloads the file list.
108 public func refresh() async { 116 public func refresh() async {
109 isIndexing = true 117 isIndexing = true
110 defer { isIndexing = false } 118 defer { isIndexing = false }
111 let reconciler = Reconciler(index: index, settings: settings) 119 let reconciler = Reconciler(index: index, settings: settings, rules: scanRules)
112 for url in resolvedRoots.values { 120 for url in resolvedRoots.values {
113 do { 121 do {
114 _ = try await Task.detached { try reconciler.reconcile(root: url) }.value 122 _ = try await Task.detached { try reconciler.reconcile(root: url) }.value
@@ -129,7 +137,7 @@ public final class WorkspaceModel {
129 137
130 /// Reconciles the paths a batch of file events named, or whole roots when events were lost. 138 /// Reconciles the paths a batch of file events named, or whole roots when events were lost.
131 public func filesChanged(_ paths: [URL], rescan roots: [URL]) async { 139 public func filesChanged(_ paths: [URL], rescan roots: [URL]) async {
132 let reconciler = Reconciler(index: index, settings: settings) 140 let reconciler = Reconciler(index: index, settings: settings, rules: scanRules)
133 for rootURL in roots { 141 for rootURL in roots {
134 _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value 142 _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value
135 } 143 }
Sources/OrgPresentation/ThemeSpec.swift +2 −2
@@ -143,7 +143,7 @@ public struct ThemeSpec: Equatable, Sendable {
143 "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328", 143 "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328",
144 "line-number": "#8c959f", "line-number-current": "#1f2328", 144 "line-number": "#8c959f", "line-number-current": "#1f2328",
145 "syntax-keyword": "#cf222e", "syntax-string": "#0a3069", "syntax-comment": "#6e7781", "syntax-function": "#8250df", 145 "syntax-keyword": "#cf222e", "syntax-string": "#0a3069", "syntax-comment": "#6e7781", "syntax-function": "#8250df",
146 "syntax-type": "#953800", "syntax-number": "#0550ae", "syntax-property": "#0550ae", "syntax-label": "#8250df", 146 "syntax-type": "#953800", "syntax-number": "#1b7c83", "syntax-property": "#0550ae", "syntax-label": "#8250df",
147 "modeline-highlight": "#1f2328", "state-normal": "#0969da", "state-insert": "#1a7f37", "state-visual": "#bc4c00", 147 "modeline-highlight": "#1f2328", "state-normal": "#0969da", "state-insert": "#1a7f37", "state-visual": "#bc4c00",
148 "agenda-date": "#1f2328", "agenda-today": "#0969da", "agenda-time": "#6e7781", "agenda-category": "#6e7781", 148 "agenda-date": "#1f2328", "agenda-today": "#0969da", "agenda-time": "#6e7781", "agenda-category": "#6e7781",
149 "agenda-deadline": "#cf222e", "agenda-upcoming": "#bc4c00", "agenda-scheduled": "#6e7781", "agenda-scheduled-past": "#953800", 149 "agenda-deadline": "#cf222e", "agenda-upcoming": "#bc4c00", "agenda-scheduled": "#6e7781", "agenda-scheduled-past": "#953800",
@@ -159,7 +159,7 @@ public struct ThemeSpec: Equatable, Sendable {
159 "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3", 159 "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3",
160 "line-number": "#6e7681", "line-number-current": "#e6edf3", 160 "line-number": "#6e7681", "line-number-current": "#e6edf3",
161 "syntax-keyword": "#ff7b72", "syntax-string": "#a5d6ff", "syntax-comment": "#8b949e", "syntax-function": "#d2a8ff", 161 "syntax-keyword": "#ff7b72", "syntax-string": "#a5d6ff", "syntax-comment": "#8b949e", "syntax-function": "#d2a8ff",
162 "syntax-type": "#ffa657", "syntax-number": "#79c0ff", "syntax-property": "#79c0ff", "syntax-label": "#d2a8ff", 162 "syntax-type": "#ffa657", "syntax-number": "#56d4dd", "syntax-property": "#79c0ff", "syntax-label": "#d2a8ff",
163 "modeline-highlight": "#e6edf3", "state-normal": "#1f6feb", "state-insert": "#238636", "state-visual": "#9e6a03", 163 "modeline-highlight": "#e6edf3", "state-normal": "#1f6feb", "state-insert": "#238636", "state-visual": "#9e6a03",
164 "agenda-date": "#e6edf3", "agenda-today": "#58a6ff", "agenda-time": "#8b949e", "agenda-category": "#8b949e", 164 "agenda-date": "#e6edf3", "agenda-today": "#58a6ff", "agenda-time": "#8b949e", "agenda-category": "#8b949e",
165 "agenda-deadline": "#ff7b72", "agenda-upcoming": "#ffa657", "agenda-scheduled": "#8b949e", "agenda-scheduled-past": "#d29922", 165 "agenda-deadline": "#ff7b72", "agenda-upcoming": "#ffa657", "agenda-scheduled": "#8b949e", "agenda-scheduled-past": "#d29922",
Sources/OrgWorkspace/Discovery.swift +37 −10
@@ -41,17 +41,43 @@ public enum FileClassification: Sendable, Equatable {
41 case ignored 41 case ignored
42} 42}
43 43
44/// Which files and folders a scan takes in.
45public struct ScanRules: Sendable, Equatable {
46 /// Dotfiles and dot folders, other than ignored folders.
47 public var hiddenFiles: Bool
48 /// Folder names never looked into: version control, caches, build output.
49 public var ignoredFolders: Set<String>
50
51 public static let defaultIgnoredFolders: Set<String> = [
52 ".git", ".hg", ".svn", ".jj", ".bzr", ".stfolder", ".stversions", ".Trash", ".Spotlight-V100", ".fseventsd",
53 ".build", ".venv", ".cache", ".tox", ".mypy_cache", ".pytest_cache", ".gradle", ".next", ".terraform", "node_modules",
54 ]
55
56 public init(hiddenFiles: Bool = true, ignoredFolders: Set<String> = defaultIgnoredFolders) {
57 self.hiddenFiles = hiddenFiles
58 self.ignoredFolders = ignoredFolders
59 }
60
61 public func skipsFolder(_ name: String) -> Bool {
62 ignoredFolders.contains(name) || (!hiddenFiles && name.hasPrefix("."))
63 }
64}
65
44public enum WorkspaceScanner { 66public enum WorkspaceScanner {
45 /// What a file name means to the workspace. Syncthing temp files, our own temp and backup 67 /// 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; files that 68 /// files, Emacs backups, auto-saves and lock files, and Finder's files are ignored, and
47 /// aren't org files are `.other`. 69 /// other dotfiles unless `rules` takes them in; files that aren't org files are `.other`.
48 public static func classify(_ name: String) -> FileClassification { 70 public static func classify(_ name: String, rules: ScanRules = ScanRules()) -> FileClassification {
49 if name.hasPrefix(".") { 71 if name.hasPrefix(".") {
50 if name.hasSuffix(".icloud") { 72 if name.hasSuffix(".icloud") {
51 let inner = String(name.dropFirst().dropLast(".icloud".count)) 73 let inner = String(name.dropFirst().dropLast(".icloud".count))
52 if case .file = classify(inner) { return .placeholder(inner) } 74 if case .file = classify(inner, rules: rules) { return .placeholder(inner) }
75 return .ignored
76 }
77 if !rules.hiddenFiles || name.hasPrefix(".#") || name.hasPrefix(".syncthing.") || name.contains(".orgstar-")
78 || name == ".DS_Store" || name == ".localized" {
79 return .ignored
53 } 80 }
54 return .ignored
55 } 81 }
56 if name.hasSuffix("~") || name.hasPrefix("#") { return .ignored } 82 if name.hasSuffix("~") || name.hasPrefix("#") { return .ignored }
57 let conflict = name.contains(".sync-conflict-") 83 let conflict = name.contains(".sync-conflict-")
@@ -60,8 +86,8 @@ public enum WorkspaceScanner {
60 return .file(.other) 86 return .file(.other)
61 } 87 }
62 88
63 /// Every file under `root`, skipping hidden folders (`.git`, `.stfolder`, `.stversions`). 89 /// Every file under `root`, skipping the folders `rules` ignores.
64 public static func scan(_ root: URL) throws -> Discovery { 90 public static func scan(_ root: URL, rules: ScanRules = ScanRules()) throws -> Discovery {
65 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey] 91 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
66 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else { 92 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
67 return Discovery() 93 return Discovery()
@@ -71,10 +97,10 @@ public enum WorkspaceScanner {
71 let values = try url.resourceValues(forKeys: Set(keys)) 97 let values = try url.resourceValues(forKeys: Set(keys))
72 let name = url.lastPathComponent 98 let name = url.lastPathComponent
73 if values.isDirectory == true { 99 if values.isDirectory == true {
74 if name.hasPrefix(".") { enumerator.skipDescendants() } 100 if rules.skipsFolder(name) { enumerator.skipDescendants() }
75 continue 101 continue
76 } 102 }
77 switch classify(name) { 103 switch classify(name, rules: rules) {
78 case .file(let kind): 104 case .file(let kind):
79 discovery.files.append(DiscoveredFile( 105 discovery.files.append(DiscoveredFile(
80 url: url.standardizedFileURL, 106 url: url.standardizedFileURL,
@@ -103,7 +129,8 @@ public struct AgendaScope: Sendable, Equatable {
103 } 129 }
104 130
105 public func contains(relativePath: String, kind: FileKind) -> Bool { 131 public func contains(relativePath: String, kind: FileKind) -> Bool {
106 guard kind == .org else { return false } 132 // `org-agenda-file-regexp` leaves out dotfiles.
133 guard kind == .org, !(relativePath as NSString).lastPathComponent.hasPrefix(".") else { return false }
107 guard !globs.isEmpty else { return true } 134 guard !globs.isEmpty else { return true }
108 // Without FNM_PATHNAME, `*` also matches `/`, so `work/*` covers nested folders. 135 // Without FNM_PATHNAME, `*` also matches `/`, so `work/*` covers nested folders.
109 return globs.contains { fnmatch($0, relativePath, 0) == 0 } 136 return globs.contains { fnmatch($0, relativePath, 0) == 0 }
Sources/OrgWorkspace/Reconciler.swift +7 −5
@@ -16,15 +16,17 @@ public struct ReconcileReport: Sendable, Equatable {
16public struct Reconciler: Sendable { 16public struct Reconciler: Sendable {
17 public let index: IndexStore 17 public let index: IndexStore
18 public let settings: IndexSettings 18 public let settings: IndexSettings
19 public let rules: ScanRules
19 20
20 public init(index: IndexStore, settings: IndexSettings) { 21 public init(index: IndexStore, settings: IndexSettings, rules: ScanRules = ScanRules()) {
21 self.index = index 22 self.index = index
22 self.settings = settings 23 self.settings = settings
24 self.rules = rules
23 } 25 }
24 26
25 /// Reconciles the whole root. 27 /// Reconciles the whole root.
26 public func reconcile(root: URL) throws -> ReconcileReport { 28 public func reconcile(root: URL) throws -> ReconcileReport {
27 let discovery = try WorkspaceScanner.scan(root) 29 let discovery = try WorkspaceScanner.scan(root, rules: rules)
28 for placeholder in discovery.placeholders { 30 for placeholder in discovery.placeholders {
29 try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder) 31 try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder)
30 } 32 }
@@ -40,9 +42,9 @@ public struct Reconciler: Sendable {
40 var found: [DiscoveredFile] = [] 42 var found: [DiscoveredFile] = []
41 let rootComponents = root.standardizedFileURL.pathComponents.count 43 let rootComponents = root.standardizedFileURL.pathComponents.count
42 for url in paths.map(\.standardizedFileURL) { 44 for url in paths.map(\.standardizedFileURL) {
43 // Files in hidden folders (`.git`) aren't part of the workspace, as in `scan`. 45 // Files in ignored folders (`.git`) aren't part of the workspace, as in `scan`.
44 if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: { $0.hasPrefix(".") }) { continue } 46 if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: rules.skipsFolder) { continue }
45 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent), 47 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent, rules: rules),
46 let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]), 48 let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]),
47 values.isDirectory != true else { continue } 49 values.isDirectory != true else { continue }
48 found.append(DiscoveredFile( 50 found.append(DiscoveredFile(
Sources/Orgstar/ContentView.swift +6
@@ -3,6 +3,7 @@ import OrgApp
3import OrgCore 3import OrgCore
4import OrgEditorAppKit 4import OrgEditorAppKit
5import OrgPresentation 5import OrgPresentation
6import OrgWorkspace
6import OrgIndex 7import OrgIndex
7import SwiftUI 8import SwiftUI
8import UniformTypeIdentifiers 9import UniformTypeIdentifiers
@@ -47,6 +48,8 @@ struct ContentView: View {
47 @AppStorage("outlineWidth") private var outlineWidth = 220.0 48 @AppStorage("outlineWidth") private var outlineWidth = 220.0
48 @AppStorage("showMarkup") private var showMarkup = false 49 @AppStorage("showMarkup") private var showMarkup = false
49 @AppStorage("showLineNumbers") private var showLineNumbers = true 50 @AppStorage("showLineNumbers") private var showLineNumbers = true
51 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
52 @AppStorage("ignoredFolders") private var ignoredFolders = ScanRules.defaultIgnoredFolders.sorted().joined(separator: " ")
50 @AppStorage("themeFont") private var themeFont = "" 53 @AppStorage("themeFont") private var themeFont = ""
51 @AppStorage("themeFontSize") private var themeFontSize = 13 54 @AppStorage("themeFontSize") private var themeFontSize = 13
52 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2 55 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2
@@ -163,6 +166,9 @@ struct ContentView: View {
163 .searchFocused($searchFocused) 166 .searchFocused($searchFocused)
164 .toolbar { toolbarItems } 167 .toolbar { toolbarItems }
165 .onChange(of: session.path) { plainOutlineShown = false } 168 .onChange(of: session.path) { plainOutlineShown = false }
169 .onChange(of: ScanRules(hiddenFiles: showHiddenFiles, ignoredFolders: Set(ignoredFolders.split(separator: " ").map(String.init))), initial: true) { _, rules in
170 workspace.scanRules = rules
171 }
166 .onChange(of: selectedFile) { old, path in 172 .onChange(of: selectedFile) { old, path in
167 guard let path else { return } 173 guard let path else { return }
168 if !DocumentSession.isOrgPath(path), !FileTypes.isText(path) { 174 if !DocumentSession.isOrgPath(path), !FileTypes.isText(path) {
Sources/Orgstar/OrgstarApp.swift +2
@@ -346,6 +346,7 @@ struct SettingsView: View {
346 @AppStorage("reminderLead") private var reminderLead = 12 346 @AppStorage("reminderLead") private var reminderLead = 12
347 @AppStorage("globalCapture") private var globalCapture = true 347 @AppStorage("globalCapture") private var globalCapture = true
348 @AppStorage("settingsTab") private var tab = "general" 348 @AppStorage("settingsTab") private var tab = "general"
349 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
349 @AppStorage("themeFont") private var themeFont = "" 350 @AppStorage("themeFont") private var themeFont = ""
350 @AppStorage("themeFontSize") private var themeFontSize = 13 351 @AppStorage("themeFontSize") private var themeFontSize = 13
351 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2 352 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2
@@ -401,6 +402,7 @@ struct SettingsView: View {
401 Picker("Keys", selection: $preset) { 402 Picker("Keys", selection: $preset) {
402 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) } 403 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
403 } 404 }
405 Toggle("Show hidden files and folders", isOn: $showHiddenFiles)
404 Picker("Option as Meta", selection: $optionAsMeta) { 406 Picker("Option as Meta", selection: $optionAsMeta) {
405 Text("Left Option").tag(OptionAsMeta.left.rawValue) 407 Text("Left Option").tag(OptionAsMeta.left.rawValue)
406 Text("Right Option").tag(OptionAsMeta.right.rawValue) 408 Text("Right Option").tag(OptionAsMeta.right.rawValue)
Tests/OrgWorkspaceTests/DiscoveryTests.swift +13 −3
@@ -15,7 +15,11 @@ struct ClassificationTests {
15 ("#a.org#", .ignored), 15 ("#a.org#", .ignored),
16 ("a.md", .file(.other)), 16 ("a.md", .file(.other)),
17 ("photo.jpg", .file(.other)), 17 ("photo.jpg", .file(.other)),
18 (".hidden.org", .ignored), 18 (".hidden.org", .file(.org)),
19 (".zshrc", .file(.other)),
20 (".#a.org", .ignored),
21 (".DS_Store", .ignored),
22 (".a.org.orgstar-backup-1", .ignored),
19 ]) 23 ])
20 func classify(name: String, expected: FileClassification) { 24 func classify(name: String, expected: FileClassification) {
21 #expect(WorkspaceScanner.classify(name) == expected) 25 #expect(WorkspaceScanner.classify(name) == expected)
@@ -25,6 +29,7 @@ struct ClassificationTests {
25 #expect(AgendaScope().contains(relativePath: "a.org", kind: .org)) 29 #expect(AgendaScope().contains(relativePath: "a.org", kind: .org))
26 #expect(!AgendaScope().contains(relativePath: "a.org_archive", kind: .archive)) 30 #expect(!AgendaScope().contains(relativePath: "a.org_archive", kind: .archive))
27 #expect(!AgendaScope().contains(relativePath: "a.org", kind: .conflict)) 31 #expect(!AgendaScope().contains(relativePath: "a.org", kind: .conflict))
32 #expect(!AgendaScope().contains(relativePath: "sub/.draft.org", kind: .org))
28 let work = AgendaScope(globs: ["work/*"]) 33 let work = AgendaScope(globs: ["work/*"])
29 #expect(work.contains(relativePath: "work/deep/a.org", kind: .org)) 34 #expect(work.contains(relativePath: "work/deep/a.org", kind: .org))
30 #expect(!work.contains(relativePath: "home/a.org", kind: .org)) 35 #expect(!work.contains(relativePath: "home/a.org", kind: .org))
@@ -40,9 +45,14 @@ struct ScanTests {
40 try folder.write(".stversions/d.org", "* d\n") 45 try folder.write(".stversions/d.org", "* d\n")
41 try folder.write("sub/.e.org.icloud", "") 46 try folder.write("sub/.e.org.icloud", "")
42 try folder.write("notes.txt", "") 47 try folder.write("notes.txt", "")
48 try folder.write(".config/app.toml", "")
49 try folder.write(".notes.org", "* n\n")
50 try folder.write("node_modules/x/index.js", "")
43 let discovery = try WorkspaceScanner.scan(folder.url) 51 let discovery = try WorkspaceScanner.scan(folder.url)
44 #expect(discovery.files.map(\.url.lastPathComponent) == ["a.org", "notes.txt", "b.org_archive"]) 52 #expect(discovery.files.map(\.url.lastPathComponent) == ["app.toml", ".notes.org", "a.org", "notes.txt", "b.org_archive"])
45 #expect(discovery.files.map(\.kind) == [.org, .other, .archive]) 53 #expect(discovery.files.map(\.kind) == [.other, .org, .org, .other, .archive])
54 let visible = try WorkspaceScanner.scan(folder.url, rules: ScanRules(hiddenFiles: false))
55 #expect(visible.files.map(\.url.lastPathComponent) == ["a.org", "notes.txt", "b.org_archive"])
46 #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"]) 56 #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"])
47 } 57 }
48} 58}