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 {
3535 /// Emacs lists a directory in `org-agenda-files`.
3636 public var files: [String] {
3737 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("/")) }
3940 .map(\.path)
4041 .sorted()
4142 }
Sources/OrgApp/UserConfig.swift +6
@@ -1,6 +1,7 @@
11import Foundation
22import OrgCore
33import OrgPresentation
4import OrgWorkspace
45
56/// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and
67/// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a
@@ -100,6 +101,11 @@ public enum UserConfig {
100101 comment: "left, right, both or none", legacy: "general.option-as-meta"),
101102 Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
102103 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: ""),
103109 Setting(section: "orgstar", key: "agenda-include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false),
104110 comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"),
105111 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 {
104104
105105 // MARK: - Indexing
106106
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
107115 /// Reconciles every root off the main thread, then reloads the file list.
108116 public func refresh() async {
109117 isIndexing = true
110118 defer { isIndexing = false }
111 let reconciler = Reconciler(index: index, settings: settings)
119 let reconciler = Reconciler(index: index, settings: settings, rules: scanRules)
112120 for url in resolvedRoots.values {
113121 do {
114122 _ = try await Task.detached { try reconciler.reconcile(root: url) }.value
@@ -129,7 +137,7 @@ public final class WorkspaceModel {
129137
130138 /// Reconciles the paths a batch of file events named, or whole roots when events were lost.
131139 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)
133141 for rootURL in roots {
134142 _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value
135143 }
Sources/OrgPresentation/ThemeSpec.swift +2 −2
@@ -143,7 +143,7 @@ public struct ThemeSpec: Equatable, Sendable {
143143 "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328",
144144 "line-number": "#8c959f", "line-number-current": "#1f2328",
145145 "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",
147147 "modeline-highlight": "#1f2328", "state-normal": "#0969da", "state-insert": "#1a7f37", "state-visual": "#bc4c00",
148148 "agenda-date": "#1f2328", "agenda-today": "#0969da", "agenda-time": "#6e7781", "agenda-category": "#6e7781",
149149 "agenda-deadline": "#cf222e", "agenda-upcoming": "#bc4c00", "agenda-scheduled": "#6e7781", "agenda-scheduled-past": "#953800",
@@ -159,7 +159,7 @@ public struct ThemeSpec: Equatable, Sendable {
159159 "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3",
160160 "line-number": "#6e7681", "line-number-current": "#e6edf3",
161161 "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",
163163 "modeline-highlight": "#e6edf3", "state-normal": "#1f6feb", "state-insert": "#238636", "state-visual": "#9e6a03",
164164 "agenda-date": "#e6edf3", "agenda-today": "#58a6ff", "agenda-time": "#8b949e", "agenda-category": "#8b949e",
165165 "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 {
4141 case ignored
4242}
4343
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
4466public enum WorkspaceScanner {
4567 /// 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
47 /// aren't org files are `.other`.
48 public static func classify(_ name: String) -> FileClassification {
68 /// files, Emacs backups, auto-saves and lock files, and Finder's files are ignored, and
69 /// other dotfiles unless `rules` takes them in; files that aren't org files are `.other`.
70 public static func classify(_ name: String, rules: ScanRules = ScanRules()) -> FileClassification {
4971 if name.hasPrefix(".") {
5072 if name.hasSuffix(".icloud") {
5173 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
5380 }
54 return .ignored
5581 }
5682 if name.hasSuffix("~") || name.hasPrefix("#") { return .ignored }
5783 let conflict = name.contains(".sync-conflict-")
@@ -60,8 +86,8 @@ public enum WorkspaceScanner {
6086 return .file(.other)
6187 }
6288
63 /// Every file under `root`, skipping hidden folders (`.git`, `.stfolder`, `.stversions`).
64 public static func scan(_ root: URL) throws -> Discovery {
89 /// Every file under `root`, skipping the folders `rules` ignores.
90 public static func scan(_ root: URL, rules: ScanRules = ScanRules()) throws -> Discovery {
6591 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
6692 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
6793 return Discovery()
@@ -71,10 +97,10 @@ public enum WorkspaceScanner {
7197 let values = try url.resourceValues(forKeys: Set(keys))
7298 let name = url.lastPathComponent
7399 if values.isDirectory == true {
74 if name.hasPrefix(".") { enumerator.skipDescendants() }
100 if rules.skipsFolder(name) { enumerator.skipDescendants() }
75101 continue
76102 }
77 switch classify(name) {
103 switch classify(name, rules: rules) {
78104 case .file(let kind):
79105 discovery.files.append(DiscoveredFile(
80106 url: url.standardizedFileURL,
@@ -103,7 +129,8 @@ public struct AgendaScope: Sendable, Equatable {
103129 }
104130
105131 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 }
107134 guard !globs.isEmpty else { return true }
108135 // Without FNM_PATHNAME, `*` also matches `/`, so `work/*` covers nested folders.
109136 return globs.contains { fnmatch($0, relativePath, 0) == 0 }
Sources/OrgWorkspace/Reconciler.swift +7 −5
@@ -16,15 +16,17 @@ public struct ReconcileReport: Sendable, Equatable {
1616public struct Reconciler: Sendable {
1717 public let index: IndexStore
1818 public let settings: IndexSettings
19 public let rules: ScanRules
1920
20 public init(index: IndexStore, settings: IndexSettings) {
21 public init(index: IndexStore, settings: IndexSettings, rules: ScanRules = ScanRules()) {
2122 self.index = index
2223 self.settings = settings
24 self.rules = rules
2325 }
2426
2527 /// Reconciles the whole root.
2628 public func reconcile(root: URL) throws -> ReconcileReport {
27 let discovery = try WorkspaceScanner.scan(root)
29 let discovery = try WorkspaceScanner.scan(root, rules: rules)
2830 for placeholder in discovery.placeholders {
2931 try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder)
3032 }
@@ -40,9 +42,9 @@ public struct Reconciler: Sendable {
4042 var found: [DiscoveredFile] = []
4143 let rootComponents = root.standardizedFileURL.pathComponents.count
4244 for url in paths.map(\.standardizedFileURL) {
43 // Files in hidden folders (`.git`) aren't part of the workspace, as in `scan`.
44 if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: { $0.hasPrefix(".") }) { continue }
45 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent),
45 // Files in ignored folders (`.git`) aren't part of the workspace, as in `scan`.
46 if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: rules.skipsFolder) { continue }
47 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent, rules: rules),
4648 let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]),
4749 values.isDirectory != true else { continue }
4850 found.append(DiscoveredFile(
Sources/Orgstar/ContentView.swift +6
@@ -3,6 +3,7 @@ import OrgApp
33import OrgCore
44import OrgEditorAppKit
55import OrgPresentation
6import OrgWorkspace
67import OrgIndex
78import SwiftUI
89import UniformTypeIdentifiers
@@ -47,6 +48,8 @@ struct ContentView: View {
4748 @AppStorage("outlineWidth") private var outlineWidth = 220.0
4849 @AppStorage("showMarkup") private var showMarkup = false
4950 @AppStorage("showLineNumbers") private var showLineNumbers = true
51 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
52 @AppStorage("ignoredFolders") private var ignoredFolders = ScanRules.defaultIgnoredFolders.sorted().joined(separator: " ")
5053 @AppStorage("themeFont") private var themeFont = ""
5154 @AppStorage("themeFontSize") private var themeFontSize = 13
5255 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2
@@ -163,6 +166,9 @@ struct ContentView: View {
163166 .searchFocused($searchFocused)
164167 .toolbar { toolbarItems }
165168 .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 }
166172 .onChange(of: selectedFile) { old, path in
167173 guard let path else { return }
168174 if !DocumentSession.isOrgPath(path), !FileTypes.isText(path) {
Sources/Orgstar/OrgstarApp.swift +2
@@ -346,6 +346,7 @@ struct SettingsView: View {
346346 @AppStorage("reminderLead") private var reminderLead = 12
347347 @AppStorage("globalCapture") private var globalCapture = true
348348 @AppStorage("settingsTab") private var tab = "general"
349 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
349350 @AppStorage("themeFont") private var themeFont = ""
350351 @AppStorage("themeFontSize") private var themeFontSize = 13
351352 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2
@@ -401,6 +402,7 @@ struct SettingsView: View {
401402 Picker("Keys", selection: $preset) {
402403 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
403404 }
405 Toggle("Show hidden files and folders", isOn: $showHiddenFiles)
404406 Picker("Option as Meta", selection: $optionAsMeta) {
405407 Text("Left Option").tag(OptionAsMeta.left.rawValue)
406408 Text("Right Option").tag(OptionAsMeta.right.rawValue)
Tests/OrgWorkspaceTests/DiscoveryTests.swift +13 −3
@@ -15,7 +15,11 @@ struct ClassificationTests {
1515 ("#a.org#", .ignored),
1616 ("a.md", .file(.other)),
1717 ("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),
1923 ])
2024 func classify(name: String, expected: FileClassification) {
2125 #expect(WorkspaceScanner.classify(name) == expected)
@@ -25,6 +29,7 @@ struct ClassificationTests {
2529 #expect(AgendaScope().contains(relativePath: "a.org", kind: .org))
2630 #expect(!AgendaScope().contains(relativePath: "a.org_archive", kind: .archive))
2731 #expect(!AgendaScope().contains(relativePath: "a.org", kind: .conflict))
32 #expect(!AgendaScope().contains(relativePath: "sub/.draft.org", kind: .org))
2833 let work = AgendaScope(globs: ["work/*"])
2934 #expect(work.contains(relativePath: "work/deep/a.org", kind: .org))
3035 #expect(!work.contains(relativePath: "home/a.org", kind: .org))
@@ -40,9 +45,14 @@ struct ScanTests {
4045 try folder.write(".stversions/d.org", "* d\n")
4146 try folder.write("sub/.e.org.icloud", "")
4247 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", "")
4351 let discovery = try WorkspaceScanner.scan(folder.url)
44 #expect(discovery.files.map(\.url.lastPathComponent) == ["a.org", "notes.txt", "b.org_archive"])
45 #expect(discovery.files.map(\.kind) == [.org, .other, .archive])
52 #expect(discovery.files.map(\.url.lastPathComponent) == ["app.toml", ".notes.org", "a.org", "notes.txt", "b.org_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"])
4656 #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"])
4757 }
4858}