Commit a6139aad41
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +2 −1
| @@ -35,7 +35,8 @@ public final class AgendaModel { | ||
| 35 | 35 | /// Emacs lists a directory in `org-agenda-files`. |
| 36 | 36 | public var files: [String] { |
| 37 | 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 | 40 | .map(\.path) |
| 40 | 41 | .sorted() |
| 41 | 42 | } |
Sources/OrgApp/UserConfig.swift +6
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import OrgCore |
| 3 | 3 | import OrgPresentation |
| 4 | import OrgWorkspace | |
| 4 | 5 | |
| 5 | 6 | /// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and |
| 6 | 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 | 101 | comment: "left, right, both or none", legacy: "general.option-as-meta"), |
| 101 | 102 | Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false), |
| 102 | 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 | 109 | Setting(section: "orgstar", key: "agenda-include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false), |
| 104 | 110 | comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"), |
| 105 | 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 | 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 | 115 | /// Reconciles every root off the main thread, then reloads the file list. |
| 108 | 116 | public func refresh() async { |
| 109 | 117 | isIndexing = true |
| 110 | 118 | defer { isIndexing = false } |
| 111 | let reconciler = Reconciler(index: index, settings: settings) | |
| 119 | let reconciler = Reconciler(index: index, settings: settings, rules: scanRules) | |
| 112 | 120 | for url in resolvedRoots.values { |
| 113 | 121 | do { |
| 114 | 122 | _ = try await Task.detached { try reconciler.reconcile(root: url) }.value |
| @@ -129,7 +137,7 @@ public final class WorkspaceModel { | ||
| 129 | 137 | |
| 130 | 138 | /// Reconciles the paths a batch of file events named, or whole roots when events were lost. |
| 131 | 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 | 141 | for rootURL in roots { |
| 134 | 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 | 143 | "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328", |
| 144 | 144 | "line-number": "#8c959f", "line-number-current": "#1f2328", |
| 145 | 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 | 147 | "modeline-highlight": "#1f2328", "state-normal": "#0969da", "state-insert": "#1a7f37", "state-visual": "#bc4c00", |
| 148 | 148 | "agenda-date": "#1f2328", "agenda-today": "#0969da", "agenda-time": "#6e7781", "agenda-category": "#6e7781", |
| 149 | 149 | "agenda-deadline": "#cf222e", "agenda-upcoming": "#bc4c00", "agenda-scheduled": "#6e7781", "agenda-scheduled-past": "#953800", |
| @@ -159,7 +159,7 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 159 | 159 | "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3", |
| 160 | 160 | "line-number": "#6e7681", "line-number-current": "#e6edf3", |
| 161 | 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 | 163 | "modeline-highlight": "#e6edf3", "state-normal": "#1f6feb", "state-insert": "#238636", "state-visual": "#9e6a03", |
| 164 | 164 | "agenda-date": "#e6edf3", "agenda-today": "#58a6ff", "agenda-time": "#8b949e", "agenda-category": "#8b949e", |
| 165 | 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 | 41 | case ignored |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | /// Which files and folders a scan takes in. | |
| 45 | public 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 | ||
| 44 | 66 | public enum WorkspaceScanner { |
| 45 | 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 | |
| 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 { | |
| 49 | 71 | if name.hasPrefix(".") { |
| 50 | 72 | if name.hasSuffix(".icloud") { |
| 51 | 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 | 82 | if name.hasSuffix("~") || name.hasPrefix("#") { return .ignored } |
| 57 | 83 | let conflict = name.contains(".sync-conflict-") |
| @@ -60,8 +86,8 @@ public enum WorkspaceScanner { | ||
| 60 | 86 | return .file(.other) |
| 61 | 87 | } |
| 62 | 88 | |
| 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 { | |
| 65 | 91 | let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey] |
| 66 | 92 | guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else { |
| 67 | 93 | return Discovery() |
| @@ -71,10 +97,10 @@ public enum WorkspaceScanner { | ||
| 71 | 97 | let values = try url.resourceValues(forKeys: Set(keys)) |
| 72 | 98 | let name = url.lastPathComponent |
| 73 | 99 | if values.isDirectory == true { |
| 74 | if name.hasPrefix(".") { enumerator.skipDescendants() } | |
| 100 | if rules.skipsFolder(name) { enumerator.skipDescendants() } | |
| 75 | 101 | continue |
| 76 | 102 | } |
| 77 | switch classify(name) { | |
| 103 | switch classify(name, rules: rules) { | |
| 78 | 104 | case .file(let kind): |
| 79 | 105 | discovery.files.append(DiscoveredFile( |
| 80 | 106 | url: url.standardizedFileURL, |
| @@ -103,7 +129,8 @@ public struct AgendaScope: Sendable, Equatable { | ||
| 103 | 129 | } |
| 104 | 130 | |
| 105 | 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 | 134 | guard !globs.isEmpty else { return true } |
| 108 | 135 | // Without FNM_PATHNAME, `*` also matches `/`, so `work/*` covers nested folders. |
| 109 | 136 | return globs.contains { fnmatch($0, relativePath, 0) == 0 } |
Sources/OrgWorkspace/Reconciler.swift +7 −5
| @@ -16,15 +16,17 @@ public struct ReconcileReport: Sendable, Equatable { | ||
| 16 | 16 | public struct Reconciler: Sendable { |
| 17 | 17 | public let index: IndexStore |
| 18 | 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 | 22 | self.index = index |
| 22 | 23 | self.settings = settings |
| 24 | self.rules = rules | |
| 23 | 25 | } |
| 24 | 26 | |
| 25 | 27 | /// Reconciles the whole root. |
| 26 | 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 | 30 | for placeholder in discovery.placeholders { |
| 29 | 31 | try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder) |
| 30 | 32 | } |
| @@ -40,9 +42,9 @@ public struct Reconciler: Sendable { | ||
| 40 | 42 | var found: [DiscoveredFile] = [] |
| 41 | 43 | let rootComponents = root.standardizedFileURL.pathComponents.count |
| 42 | 44 | 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), | |
| 46 | 48 | let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]), |
| 47 | 49 | values.isDirectory != true else { continue } |
| 48 | 50 | found.append(DiscoveredFile( |
Sources/Orgstar/ContentView.swift +6
| @@ -3,6 +3,7 @@ import OrgApp | ||
| 3 | 3 | import OrgCore |
| 4 | 4 | import OrgEditorAppKit |
| 5 | 5 | import OrgPresentation |
| 6 | import OrgWorkspace | |
| 6 | 7 | import OrgIndex |
| 7 | 8 | import SwiftUI |
| 8 | 9 | import UniformTypeIdentifiers |
| @@ -47,6 +48,8 @@ struct ContentView: View { | ||
| 47 | 48 | @AppStorage("outlineWidth") private var outlineWidth = 220.0 |
| 48 | 49 | @AppStorage("showMarkup") private var showMarkup = false |
| 49 | 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 | 53 | @AppStorage("themeFont") private var themeFont = "" |
| 51 | 54 | @AppStorage("themeFontSize") private var themeFontSize = 13 |
| 52 | 55 | @AppStorage("themeLineSpacing") private var themeLineSpacing = 2 |
| @@ -163,6 +166,9 @@ struct ContentView: View { | ||
| 163 | 166 | .searchFocused($searchFocused) |
| 164 | 167 | .toolbar { toolbarItems } |
| 165 | 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 | 172 | .onChange(of: selectedFile) { old, path in |
| 167 | 173 | guard let path else { return } |
| 168 | 174 | if !DocumentSession.isOrgPath(path), !FileTypes.isText(path) { |
Sources/Orgstar/OrgstarApp.swift +2
| @@ -346,6 +346,7 @@ struct SettingsView: View { | ||
| 346 | 346 | @AppStorage("reminderLead") private var reminderLead = 12 |
| 347 | 347 | @AppStorage("globalCapture") private var globalCapture = true |
| 348 | 348 | @AppStorage("settingsTab") private var tab = "general" |
| 349 | @AppStorage("showHiddenFiles") private var showHiddenFiles = true | |
| 349 | 350 | @AppStorage("themeFont") private var themeFont = "" |
| 350 | 351 | @AppStorage("themeFontSize") private var themeFontSize = 13 |
| 351 | 352 | @AppStorage("themeLineSpacing") private var themeLineSpacing = 2 |
| @@ -401,6 +402,7 @@ struct SettingsView: View { | ||
| 401 | 402 | Picker("Keys", selection: $preset) { |
| 402 | 403 | ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) } |
| 403 | 404 | } |
| 405 | Toggle("Show hidden files and folders", isOn: $showHiddenFiles) | |
| 404 | 406 | Picker("Option as Meta", selection: $optionAsMeta) { |
| 405 | 407 | Text("Left Option").tag(OptionAsMeta.left.rawValue) |
| 406 | 408 | Text("Right Option").tag(OptionAsMeta.right.rawValue) |
Tests/OrgWorkspaceTests/DiscoveryTests.swift +13 −3
| @@ -15,7 +15,11 @@ struct ClassificationTests { | ||
| 15 | 15 | ("#a.org#", .ignored), |
| 16 | 16 | ("a.md", .file(.other)), |
| 17 | 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 | 24 | func classify(name: String, expected: FileClassification) { |
| 21 | 25 | #expect(WorkspaceScanner.classify(name) == expected) |
| @@ -25,6 +29,7 @@ struct ClassificationTests { | ||
| 25 | 29 | #expect(AgendaScope().contains(relativePath: "a.org", kind: .org)) |
| 26 | 30 | #expect(!AgendaScope().contains(relativePath: "a.org_archive", kind: .archive)) |
| 27 | 31 | #expect(!AgendaScope().contains(relativePath: "a.org", kind: .conflict)) |
| 32 | #expect(!AgendaScope().contains(relativePath: "sub/.draft.org", kind: .org)) | |
| 28 | 33 | let work = AgendaScope(globs: ["work/*"]) |
| 29 | 34 | #expect(work.contains(relativePath: "work/deep/a.org", kind: .org)) |
| 30 | 35 | #expect(!work.contains(relativePath: "home/a.org", kind: .org)) |
| @@ -40,9 +45,14 @@ struct ScanTests { | ||
| 40 | 45 | try folder.write(".stversions/d.org", "* d\n") |
| 41 | 46 | try folder.write("sub/.e.org.icloud", "") |
| 42 | 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 | 51 | 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"]) | |
| 46 | 56 | #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"]) |
| 47 | 57 | } |
| 48 | 58 | } |