Open files that aren't org !37

merged merged by cmc on 2026-10-05 17:52 UTC · krz/orgstar:other-files into main

14 files changed, +260 −18

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +9 −2
@@ -87,6 +87,13 @@ public final class DocumentSession {
8787 }
8888
8989 private var url: URL? { path.map { URL(fileURLWithPath: $0) } }
90 /// Whether the open file is an org file (`.org`, `.org_archive`), not plain text.
91 public var isOrg: Bool { path.map(Self.isOrgPath) ?? false }
92
93 public static func isOrgPath(_ path: String) -> Bool {
94 path.hasSuffix(".org") || path.hasSuffix(".org_archive")
95 }
96
9097 /// The caret in the open file.
9198 public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 }
9299 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
@@ -121,7 +128,7 @@ public final class DocumentSession {
121128 outlineUpdate = Task { [weak self] in
122129 try? await Task.sleep(for: .milliseconds(300))
123130 guard !Task.isCancelled, let self, let document = self.document else { return }
124 self.outline = Presentation.outline(document.tree)
131 self.outline = self.isOrg ? Presentation.outline(document.tree) : []
125132 self.editVersion += 1
126133 }
127134 scheduleAutosave()
@@ -134,7 +141,7 @@ public final class DocumentSession {
134141 if isDirty != state.isDirty { isDirty = state.isDirty }
135142 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
136143 if updateOutline {
137 outline = Presentation.outline(state.tree)
144 outline = isOrg ? Presentation.outline(state.tree) : []
138145 editVersion += 1
139146 }
140147 }
Sources/OrgEditorAppKit/Modal.swift +2 −1
@@ -92,7 +92,8 @@ extension OrgEditor {
9292 }
9393
9494 private func commandApplies(_ id: String, in context: EditContext) -> Bool {
95 switch EditorCommands.command(id)?.action {
95 if !isOrg, id.hasPrefix("org.") { return false }
96 return switch EditorCommands.command(id)?.action {
9697 case .text(let command): command.applies(in: context)
9798 default: true
9899 }
Sources/OrgEditorAppKit/OrgEditor.swift +36 −3
@@ -75,12 +75,24 @@ public final class OrgEditor: NSObject {
7575 /// Highlights code in src blocks; nil leaves code plain.
7676 public let highlighter: (any CodeHighlighter)?
7777
78 /// What the file is: org, or plain text, highlighted as code when the language is known.
79 public enum Mode: Equatable, Sendable {
80 case org
81 case plain(language: String?)
82 }
83
84 public let mode: Mode
85 public var isOrg: Bool { mode == .org }
86 /// Highlights of the whole file in plain mode, and the revision they were made for.
87 private var codeRuns: (revision: Int, runs: [(range: Range<Int>, category: SyntaxCategory)])?
88
7889 public init(
7990 document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true,
80 highlighter: (any CodeHighlighter)? = nil
91 highlighter: (any CodeHighlighter)? = nil, mode: Mode = .org
8192 ) {
8293 self.document = document
8394 self.highlighter = highlighter
95 self.mode = mode
8496 let orgTextView = OrgTextView(usingTextLayoutManager: true)
8597 textView = orgTextView
8698 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
@@ -110,7 +122,7 @@ public final class OrgEditor: NSObject {
110122 textContentStorage.delegate = self
111123
112124 load(document.text)
113 if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
125 if isOrg, let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
114126 }
115127
116128 /// Replaces the storage's text. Setting the attributed string directly is about 100x
@@ -270,7 +282,8 @@ public final class OrgEditor: NSObject {
270282 }
271283
272284 private func applies(_ id: String, in context: EditContext) -> Bool {
273 switch EditorCommands.command(id)?.action {
285 if !isOrg, id.hasPrefix("org.") { return false }
286 return switch EditorCommands.command(id)?.action {
274287 case .text(let command): command.applies(in: context)
275288 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
276289 case .edit, .mark, .app: true
@@ -284,6 +297,10 @@ public final class OrgEditor: NSObject {
284297 onMessage?("Unknown command \(id)")
285298 return
286299 }
300 if !isOrg, id.hasPrefix("org.") {
301 onMessage?("Not an org file")
302 return
303 }
287304 var keepMark = false
288305 switch command.action {
289306 case .text(let text):
@@ -450,6 +467,13 @@ public final class OrgEditor: NSObject {
450467 guard !range.isEmpty else { return }
451468 let whole = NSRange(range)
452469 storage.setAttributes(theme.base, range: whole)
470 guard isOrg else {
471 for run in plainRuns() where run.range.overlaps(range) {
472 guard let clipped = clip(run.range, to: range) else { continue }
473 theme.apply(.syntax(run.category), to: storage, range: clipped)
474 }
475 return
476 }
453477 let text = storage.string as NSString
454478 for run in Presentation.indents(document.tree, in: range) {
455479 guard let clipped = clip(run.range, to: range) else { continue }
@@ -474,6 +498,15 @@ public final class OrgEditor: NSObject {
474498 }
475499 }
476500
501 /// Code highlights for the whole file in plain mode.
502 private func plainRuns() -> [(range: Range<Int>, category: SyntaxCategory)] {
503 guard case .plain(let language?) = mode, let highlighter else { return [] }
504 if let codeRuns, codeRuns.revision == document.revision { return codeRuns.runs }
505 let runs = highlighter.highlights(language: language, code: document.text)
506 codeRuns = (document.revision, runs)
507 return runs
508 }
509
477510 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
478511 let key = IndentKey(firstLine: firstLine, wrapped: wrapped)
479512 if let style = paragraphStyles[key] { return style }
Sources/OrgIndex/FileRecord.swift +16
@@ -7,6 +7,8 @@ public enum FileKind: String, Sendable {
77 case archive
88 /// A Syncthing conflict copy: listed, never indexed for search, agenda or IDs.
99 case conflict
10 /// Any other file: listed and openable, never read by the index.
11 case other
1012}
1113
1214/// Everything that decides what an index row means. Bump `version` when a semantic setting
@@ -123,6 +125,20 @@ public struct FileRecord: Sendable, Equatable {
123125 headings = kind == .conflict ? [] : Self.headings(source.text, settings: settings)
124126 }
125127
128 /// A file the index lists without reading: `hash` stands for its size and mtime.
129 public init(path: String, root: String, unreadSize size: Int, mtime: Double, settings: IndexSettings) {
130 self.path = path
131 self.root = root
132 self.kind = .other
133 self.size = size
134 self.mtime = mtime
135 self.hash = Self.unreadHash(size: size, mtime: mtime)
136 self.settingsVersion = settings.version
137 headings = []
138 }
139
140 public static func unreadHash(size: Int, mtime: Double) -> String { "unread:\(size):\(mtime)" }
141
126142 static func headings(_ text: String, settings: IndexSettings) -> [HeadingRecord] {
127143 let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic)
128144 let utf16 = text.utf16
Sources/OrgWorkspace/Discovery.swift +4 −3
@@ -43,7 +43,8 @@ public enum FileClassification: Sendable, Equatable {
4343
4444public enum WorkspaceScanner {
4545 /// 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.
46 /// files, Emacs backups and auto-saves, and other hidden files are ignored; files that
47 /// aren't org files are `.other`.
4748 public static func classify(_ name: String) -> FileClassification {
4849 if name.hasPrefix(".") {
4950 if name.hasSuffix(".icloud") {
@@ -56,10 +57,10 @@ public enum WorkspaceScanner {
5657 let conflict = name.contains(".sync-conflict-")
5758 if name.hasSuffix(".org") { return .file(conflict ? .conflict : .org) }
5859 if name.hasSuffix(".org_archive") { return .file(conflict ? .conflict : .archive) }
59 return .ignored
60 return .file(.other)
6061 }
6162
62 /// Every org file under `root`, skipping hidden folders (`.git`, `.stfolder`, `.stversions`).
63 /// Every file under `root`, skipping hidden folders (`.git`, `.stfolder`, `.stversions`).
6364 public static func scan(_ root: URL) throws -> Discovery {
6465 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
6566 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
Sources/OrgWorkspace/Reconciler.swift +15
@@ -38,7 +38,10 @@ public struct Reconciler: Sendable {
3838 let wanted = Set(paths.map(\.standardizedFileURL.path))
3939 let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) }
4040 var found: [DiscoveredFile] = []
41 let rootComponents = root.standardizedFileURL.pathComponents.count
4142 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 }
4245 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent),
4346 let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]),
4447 values.isDirectory != true else { continue }
@@ -62,6 +65,15 @@ public struct Reconciler: Sendable {
6265 report.unchanged += 1
6366 continue
6467 }
68 if file.kind == .other {
69 let record = FileRecord(path: path, root: root.path, unreadSize: file.size, mtime: file.mtime, settings: settings)
70 if let state = known[path], state.hash == record.hash, state.settingsVersion == settings.version, state.kind == .other {
71 report.unchanged += 1
72 } else {
73 candidates.append((file, [], record.hash))
74 }
75 continue
76 }
6577 guard let data = try? Data(contentsOf: file.url) else { continue }
6678 let bytes = [UInt8](data)
6779 let hash = FileRecord.hash(bytes)
@@ -85,6 +97,9 @@ public struct Reconciler: Sendable {
8597 missing[oldPath] = nil
8698 change.moves.append((oldPath, path, candidate.file.mtime))
8799 report.moved.append(path)
100 } else if candidate.file.kind == .other {
101 change.records.append(FileRecord(path: path, root: root.path, unreadSize: candidate.file.size, mtime: candidate.file.mtime, settings: settings))
102 report.indexed.append(path)
88103 } else {
89104 change.records.append(FileRecord(
90105 path: path, root: root.path, kind: candidate.file.kind,
Sources/Orgstar/ContentView.swift +30 −4
@@ -4,6 +4,7 @@ import OrgCore
44import OrgEditorAppKit
55import OrgIndex
66import SwiftUI
7import UniformTypeIdentifiers
78
89struct ContentView: View {
910 let workspace: WorkspaceModel
@@ -39,6 +40,8 @@ struct ContentView: View {
3940 )
4041 }
4142 @State private var selectedFile: String?
43 /// A file that isn't text, shown as a preview instead of the open document.
44 @State private var previewPath: String?
4245 @State private var query = ""
4346 @FocusState private var searchFocused: Bool
4447 @Environment(\.openWindow) private var openWindow
@@ -62,7 +65,9 @@ struct ContentView: View {
6265 }
6366 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
6467 } detail: {
65 if session.path != nil {
68 if let previewPath {
69 FilePreview(path: previewPath)
70 } else if session.path != nil {
6671 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal)
6772 .safeAreaInset(edge: .bottom, spacing: 0) {
6873 VStack(spacing: 0) {
@@ -78,7 +83,7 @@ struct ContentView: View {
7883 )
7984 }
8085 }
81 .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
86 .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
8287 .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
8388 .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
8489 .searchFocused($searchFocused)
@@ -102,7 +107,13 @@ struct ContentView: View {
102107 }
103108 }
104109 .onChange(of: selectedFile) { old, path in
105 guard let path, path != session.path else { return }
110 guard let path else { return }
111 if !DocumentSession.isOrgPath(path), !FileTypes.isText(path) {
112 previewPath = path
113 return
114 }
115 previewPath = nil
116 guard path != session.path else { return }
106117 if UnsavedChanges.resolve(session) {
107118 session.open(path)
108119 } else {
@@ -322,6 +333,11 @@ struct ContentView: View {
322333
323334 /// Runs a command from a key, the palette or the Org menu.
324335 private func perform(_ id: String) {
336 let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.")
337 if orgOnly, !session.isOrg {
338 commands.show("Not an org file")
339 return
340 }
325341 switch id {
326342 case "app.save":
327343 if !session.save(), session.conflict != nil { commands.showingConflict = true }
@@ -456,6 +472,14 @@ struct Sidebar: View {
456472 case .org: return "doc.text"
457473 case .archive: return "archivebox"
458474 case .conflict: return "exclamationmark.triangle"
475 case .other:
476 guard let type = UTType(filenameExtension: (file.path as NSString).pathExtension) else { return "doc" }
477 if type.conforms(to: .image) { return "photo" }
478 if type.conforms(to: .pdf) { return "doc.richtext" }
479 if type.conforms(to: .audiovisualContent) { return "play.rectangle" }
480 if type.conforms(to: .sourceCode) || type.conforms(to: .script) { return "chevron.left.forwardslash.chevron.right" }
481 if type.conforms(to: .text) { return "doc.plaintext" }
482 return "doc"
459483 }
460484 }
461485}
@@ -475,7 +499,9 @@ struct OutlineColumn: View {
475499 .buttonStyle(.plain)
476500 }
477501 .overlay {
478 if session.path != nil, session.outline.isEmpty {
502 if session.path != nil, !session.isOrg {
503 ContentUnavailableView("No outline", systemImage: "doc.plaintext", description: Text("Only org files have an outline."))
504 } else if session.path != nil, session.outline.isEmpty {
479505 ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
480506 }
481507 }
Sources/Orgstar/EditorView.swift +2 −1
@@ -39,7 +39,8 @@ struct EditorView: NSViewRepresentable {
3939 container.subviews.forEach { $0.removeFromSuperview() }
4040 coordinator.editor = nil
4141 if let document = session.document {
42 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter)
42 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language))
43 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode)
4344 editor.onChange = { [weak session] in session?.bufferChanged() }
4445 editor.onMessage = { [weak commands] in commands?.show($0) }
4546 editor.onKeysPending = { [weak commands] keys, next in
Sources/Orgstar/FilePreview.swift added +35
@@ -0,0 +1,35 @@
1import AppKit
2import Quartz
3import SwiftUI
4
5/// A file that isn't text: Quick Look's preview, and buttons to open it elsewhere.
6struct FilePreview: View {
7 let path: String
8
9 var body: some View {
10 QuickLookView(url: URL(fileURLWithPath: path))
11 .safeAreaInset(edge: .bottom, spacing: 0) {
12 HStack {
13 Button("Open with Default App") { NSWorkspace.shared.open(URL(fileURLWithPath: path)) }
14 Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) }
15 Spacer()
16 }
17 .padding(8)
18 .background(.bar)
19 }
20 }
21}
22
23struct QuickLookView: NSViewRepresentable {
24 let url: URL
25
26 func makeNSView(context: Context) -> QLPreviewView {
27 let view = QLPreviewView(frame: .zero, style: .normal)!
28 view.autostarts = true
29 return view
30 }
31
32 func updateNSView(_ view: QLPreviewView, context: Context) {
33 if (view.previewItem as? URL) != url { view.previewItem = url as NSURL }
34 }
35}
Sources/Orgstar/FileTypes.swift added +52
@@ -0,0 +1,52 @@
1import Foundation
2import UniformTypeIdentifiers
3
4/// How a file that isn't org opens: as text in the editor, or as a preview.
5enum FileTypes {
6 /// Whether the file is text: a text type, or, for unknown types, UTF-8 without NUL
7 /// bytes in its first 8 KB.
8 static func isText(_ path: String) -> Bool {
9 let url = URL(fileURLWithPath: path)
10 if let type = UTType(filenameExtension: url.pathExtension), !type.isDynamic {
11 if type.conforms(to: .text) { return true }
12 if type.conforms(to: .image) || type.conforms(to: .audiovisualContent) || type.conforms(to: .pdf) || type.conforms(to: .archive) {
13 return false
14 }
15 }
16 guard let handle = try? FileHandle(forReadingFrom: url) else { return false }
17 defer { try? handle.close() }
18 let head = (try? handle.read(upToCount: 8192)) ?? Data()
19 if head.contains(0) { return false }
20 // A cut multibyte character at the end is still text.
21 for trim in 0...3 where head.count >= trim {
22 if String(data: head.dropLast(trim), encoding: .utf8) != nil { return true }
23 }
24 return false
25 }
26
27 /// The src block language name the highlighter knows for a file's extension.
28 static func language(_ path: String) -> String? {
29 let url = URL(fileURLWithPath: path)
30 switch url.pathExtension.lowercased() {
31 case "sh", "bash", "zsh": return "sh"
32 case "py": return "python"
33 case "el": return "emacs-lisp"
34 case "c", "h": return "c"
35 case "cc", "cpp", "cxx", "hpp", "hh": return "cpp"
36 case "r": return "r"
37 case "js", "mjs", "cjs": return "js"
38 case "java": return "java"
39 case "scm", "ss": return "scheme"
40 case "clj", "cljs", "cljc", "edn": return "clojure"
41 case "hs": return "haskell"
42 case "rs": return "rust"
43 case "go": return "go"
44 case "rb": return "ruby"
45 case "json": return "json"
46 case "yaml", "yml": return "yaml"
47 case "lua": return "lua"
48 default:
49 return ["zshrc", ".zshrc", "bashrc", ".bashrc", ".profile"].contains(url.lastPathComponent) ? "sh" : nil
50 }
51 }
52}
Tests/OrgEditorAppKitTests/EditorTests.swift +2 −2
@@ -19,9 +19,9 @@ final class Harness {
1919 }
2020
2121 /// The editor as a window would show it first: background styling not yet run.
22 init(_ document: DocumentState, layout: Bool = true) {
22 init(_ document: DocumentState, layout: Bool = true, mode: OrgEditor.Mode = .org) {
2323 _ = NSApplication.shared
24 editor = OrgEditor(document: document)
24 editor = OrgEditor(document: document, mode: mode)
2525 window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false)
2626 window.contentView = editor.makeScrollView()
2727 window.makeFirstResponder(editor.textView)
Tests/OrgEditorAppKitTests/PlainModeTests.swift added +36
@@ -0,0 +1,36 @@
1import AppKit
2import OrgCore
3import OrgDocument
4import Testing
5@testable import OrgEditorAppKit
6
7/// Files that aren't org: plain text, without org styling, folding or commands.
8@MainActor
9struct PlainModeTests {
10 func plain(_ text: String) -> Harness {
11 let h = Harness(DocumentState(bytes: Array(text.utf8)), mode: .plain(language: nil))
12 h.drainStyling()
13 h.layout()
14 return h
15 }
16
17 @Test func orgCommandsDoNothing() {
18 let h = plain("* TODO Not a heading\n")
19 var messages: [String] = []
20 h.editor.onMessage = { messages.append($0) }
21 h.caret(at: 2)
22 h.editor.run("org.todo.cycle")
23 #expect(h.editor.document.text == "* TODO Not a heading\n")
24 #expect(messages == ["Not an org file"])
25 KeyTests().send(h, characters: "\t", ignoring: "\t", code: 48)
26 #expect(h.editor.document.text == "* \tTODO Not a heading\n")
27 }
28
29 @Test func noOrgStyling() {
30 let h = plain("* Heading *bold*\n")
31 let org = Harness("* Heading *bold*\n")
32 let font = { (h: Harness) in h.textView.textStorage!.attribute(.font, at: 2, effectiveRange: nil) as? NSFont }
33 #expect(font(h) == h.editor.theme.font)
34 #expect(font(org) != org.editor.theme.font)
35 }
36}
Tests/OrgWorkspaceTests/DiscoveryTests.swift +4 −2
@@ -13,7 +13,8 @@ struct ClassificationTests {
1313 (".a.org.orgstar-1234", .ignored),
1414 ("a.org~", .ignored),
1515 ("#a.org#", .ignored),
16 ("a.md", .ignored),
16 ("a.md", .file(.other)),
17 ("photo.jpg", .file(.other)),
1718 (".hidden.org", .ignored),
1819 ])
1920 func classify(name: String, expected: FileClassification) {
@@ -40,7 +41,8 @@ struct ScanTests {
4041 try folder.write("sub/.e.org.icloud", "")
4142 try folder.write("notes.txt", "")
4243 let discovery = try WorkspaceScanner.scan(folder.url)
43 #expect(discovery.files.map(\.url.lastPathComponent) == ["a.org", "b.org_archive"])
44 #expect(discovery.files.map(\.url.lastPathComponent) == ["a.org", "notes.txt", "b.org_archive"])
45 #expect(discovery.files.map(\.kind) == [.org, .other, .archive])
4446 #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"])
4547 }
4648}
Tests/OrgWorkspaceTests/ReconcileTests.swift +17
@@ -50,6 +50,23 @@ struct ReconcileTests {
5050 #expect(try Reconciler(index: index, settings: IndexSettings(version: 2)).reconcile(root: folder.url).indexed.count == 1)
5151 }
5252
53 @Test func otherFilesAreListedUnreadAndHiddenFoldersSkipped() throws {
54 let folder = try Folder()
55 let index = try IndexStore()
56 let reconciler = Reconciler(index: index, settings: IndexSettings())
57 let notes = try folder.write("notes.md", "* Alpha\n")
58 try folder.write(".git/HEAD", "ref: refs/heads/main\n")
59 _ = try reconciler.reconcile(root: folder.url)
60 #expect(try index.files().map(\.kind) == [.other])
61 #expect(try index.search("alpha").isEmpty)
62 #expect(try reconciler.reconcile(root: folder.url).unchanged == 1)
63
64 let head = folder.url.appendingPathComponent(".git/HEAD")
65 let report = try reconciler.reconcile(root: folder.url, paths: [head, notes])
66 #expect(report.indexed.isEmpty && report.unchanged == 1)
67 #expect(try index.files().map(\.path) == [notes.path])
68 }
69
5370 @Test func conflictCopiesAreListedButNotSearchable() throws {
5471 let folder = try Folder()
5572 let index = try IndexStore()