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 {
87 } 87 }
88 88
89 private var url: URL? { path.map { URL(fileURLWithPath: $0) } } 89 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
90 /// The caret in the open file. 97 /// The caret in the open file.
91 public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 } 98 public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 }
92 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } 99 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
@@ -121,7 +128,7 @@ public final class DocumentSession {
121 outlineUpdate = Task { [weak self] in 128 outlineUpdate = Task { [weak self] in
122 try? await Task.sleep(for: .milliseconds(300)) 129 try? await Task.sleep(for: .milliseconds(300))
123 guard !Task.isCancelled, let self, let document = self.document else { return } 130 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) : []
125 self.editVersion += 1 132 self.editVersion += 1
126 } 133 }
127 scheduleAutosave() 134 scheduleAutosave()
@@ -134,7 +141,7 @@ public final class DocumentSession {
134 if isDirty != state.isDirty { isDirty = state.isDirty } 141 if isDirty != state.isDirty { isDirty = state.isDirty }
135 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } 142 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
136 if updateOutline { 143 if updateOutline {
137 outline = Presentation.outline(state.tree) 144 outline = isOrg ? Presentation.outline(state.tree) : []
138 editVersion += 1 145 editVersion += 1
139 } 146 }
140 } 147 }
Sources/OrgEditorAppKit/Modal.swift +2 −1
@@ -92,7 +92,8 @@ extension OrgEditor {
92 } 92 }
93 93
94 private func commandApplies(_ id: String, in context: EditContext) -> Bool { 94 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 {
96 case .text(let command): command.applies(in: context) 97 case .text(let command): command.applies(in: context)
97 default: true 98 default: true
98 } 99 }
Sources/OrgEditorAppKit/OrgEditor.swift +36 −3
@@ -75,12 +75,24 @@ public final class OrgEditor: NSObject {
75 /// Highlights code in src blocks; nil leaves code plain. 75 /// Highlights code in src blocks; nil leaves code plain.
76 public let highlighter: (any CodeHighlighter)? 76 public let highlighter: (any CodeHighlighter)?
77 77
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
78 public init( 89 public init(
79 document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true, 90 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
81 ) { 92 ) {
82 self.document = document 93 self.document = document
83 self.highlighter = highlighter 94 self.highlighter = highlighter
95 self.mode = mode
84 let orgTextView = OrgTextView(usingTextLayoutManager: true) 96 let orgTextView = OrgTextView(usingTextLayoutManager: true)
85 textView = orgTextView 97 textView = orgTextView
86 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap) 98 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
@@ -110,7 +122,7 @@ public final class OrgEditor: NSObject {
110 textContentStorage.delegate = self 122 textContentStorage.delegate = self
111 123
112 load(document.text) 124 load(document.text)
113 if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) } 125 if isOrg, let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
114 } 126 }
115 127
116 /// Replaces the storage's text. Setting the attributed string directly is about 100x 128 /// Replaces the storage's text. Setting the attributed string directly is about 100x
@@ -270,7 +282,8 @@ public final class OrgEditor: NSObject {
270 } 282 }
271 283
272 private func applies(_ id: String, in context: EditContext) -> Bool { 284 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 {
274 case .text(let command): command.applies(in: context) 287 case .text(let command): command.applies(in: context)
275 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil 288 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
276 case .edit, .mark, .app: true 289 case .edit, .mark, .app: true
@@ -284,6 +297,10 @@ public final class OrgEditor: NSObject {
284 onMessage?("Unknown command \(id)") 297 onMessage?("Unknown command \(id)")
285 return 298 return
286 } 299 }
300 if !isOrg, id.hasPrefix("org.") {
301 onMessage?("Not an org file")
302 return
303 }
287 var keepMark = false 304 var keepMark = false
288 switch command.action { 305 switch command.action {
289 case .text(let text): 306 case .text(let text):
@@ -450,6 +467,13 @@ public final class OrgEditor: NSObject {
450 guard !range.isEmpty else { return } 467 guard !range.isEmpty else { return }
451 let whole = NSRange(range) 468 let whole = NSRange(range)
452 storage.setAttributes(theme.base, range: whole) 469 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 }
453 let text = storage.string as NSString 477 let text = storage.string as NSString
454 for run in Presentation.indents(document.tree, in: range) { 478 for run in Presentation.indents(document.tree, in: range) {
455 guard let clipped = clip(run.range, to: range) else { continue } 479 guard let clipped = clip(run.range, to: range) else { continue }
@@ -474,6 +498,15 @@ public final class OrgEditor: NSObject {
474 } 498 }
475 } 499 }
476 500
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
477 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle { 510 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
478 let key = IndentKey(firstLine: firstLine, wrapped: wrapped) 511 let key = IndentKey(firstLine: firstLine, wrapped: wrapped)
479 if let style = paragraphStyles[key] { return style } 512 if let style = paragraphStyles[key] { return style }
Sources/OrgIndex/FileRecord.swift +16
@@ -7,6 +7,8 @@ public enum FileKind: String, Sendable {
7 case archive 7 case archive
8 /// A Syncthing conflict copy: listed, never indexed for search, agenda or IDs. 8 /// A Syncthing conflict copy: listed, never indexed for search, agenda or IDs.
9 case conflict 9 case conflict
10 /// Any other file: listed and openable, never read by the index.
11 case other
10} 12}
11 13
12/// Everything that decides what an index row means. Bump `version` when a semantic setting 14/// Everything that decides what an index row means. Bump `version` when a semantic setting
@@ -123,6 +125,20 @@ public struct FileRecord: Sendable, Equatable {
123 headings = kind == .conflict ? [] : Self.headings(source.text, settings: settings) 125 headings = kind == .conflict ? [] : Self.headings(source.text, settings: settings)
124 } 126 }
125 127
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
126 static func headings(_ text: String, settings: IndexSettings) -> [HeadingRecord] { 142 static func headings(_ text: String, settings: IndexSettings) -> [HeadingRecord] {
127 let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic) 143 let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic)
128 let utf16 = text.utf16 144 let utf16 = text.utf16
Sources/OrgWorkspace/Discovery.swift +4 −3
@@ -43,7 +43,8 @@ public enum FileClassification: Sendable, Equatable {
43 43
44public enum WorkspaceScanner { 44public enum WorkspaceScanner {
45 /// What a file name means to the workspace. Syncthing temp files, our own temp and backup 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. 46 /// files, Emacs backups and auto-saves, and other hidden files are ignored; files that
47 /// aren't org files are `.other`.
47 public static func classify(_ name: String) -> FileClassification { 48 public static func classify(_ name: String) -> FileClassification {
48 if name.hasPrefix(".") { 49 if name.hasPrefix(".") {
49 if name.hasSuffix(".icloud") { 50 if name.hasSuffix(".icloud") {
@@ -56,10 +57,10 @@ public enum WorkspaceScanner {
56 let conflict = name.contains(".sync-conflict-") 57 let conflict = name.contains(".sync-conflict-")
57 if name.hasSuffix(".org") { return .file(conflict ? .conflict : .org) } 58 if name.hasSuffix(".org") { return .file(conflict ? .conflict : .org) }
58 if name.hasSuffix(".org_archive") { return .file(conflict ? .conflict : .archive) } 59 if name.hasSuffix(".org_archive") { return .file(conflict ? .conflict : .archive) }
59 return .ignored 60 return .file(.other)
60 } 61 }
61 62
62 /// Every org file under `root`, skipping hidden folders (`.git`, `.stfolder`, `.stversions`). 63 /// Every file under `root`, skipping hidden folders (`.git`, `.stfolder`, `.stversions`).
63 public static func scan(_ root: URL) throws -> Discovery { 64 public static func scan(_ root: URL) throws -> Discovery {
64 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey] 65 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
65 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else { 66 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
Sources/OrgWorkspace/Reconciler.swift +15
@@ -38,7 +38,10 @@ public struct Reconciler: Sendable {
38 let wanted = Set(paths.map(\.standardizedFileURL.path)) 38 let wanted = Set(paths.map(\.standardizedFileURL.path))
39 let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) } 39 let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) }
40 var found: [DiscoveredFile] = [] 40 var found: [DiscoveredFile] = []
41 let rootComponents = root.standardizedFileURL.pathComponents.count
41 for url in paths.map(\.standardizedFileURL) { 42 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 }
42 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent), 45 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent),
43 let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]), 46 let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]),
44 values.isDirectory != true else { continue } 47 values.isDirectory != true else { continue }
@@ -62,6 +65,15 @@ public struct Reconciler: Sendable {
62 report.unchanged += 1 65 report.unchanged += 1
63 continue 66 continue
64 } 67 }
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 }
65 guard let data = try? Data(contentsOf: file.url) else { continue } 77 guard let data = try? Data(contentsOf: file.url) else { continue }
66 let bytes = [UInt8](data) 78 let bytes = [UInt8](data)
67 let hash = FileRecord.hash(bytes) 79 let hash = FileRecord.hash(bytes)
@@ -85,6 +97,9 @@ public struct Reconciler: Sendable {
85 missing[oldPath] = nil 97 missing[oldPath] = nil
86 change.moves.append((oldPath, path, candidate.file.mtime)) 98 change.moves.append((oldPath, path, candidate.file.mtime))
87 report.moved.append(path) 99 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)
88 } else { 103 } else {
89 change.records.append(FileRecord( 104 change.records.append(FileRecord(
90 path: path, root: root.path, kind: candidate.file.kind, 105 path: path, root: root.path, kind: candidate.file.kind,
Sources/Orgstar/ContentView.swift +30 −4
@@ -4,6 +4,7 @@ import OrgCore
4import OrgEditorAppKit 4import OrgEditorAppKit
5import OrgIndex 5import OrgIndex
6import SwiftUI 6import SwiftUI
7import UniformTypeIdentifiers
7 8
8struct ContentView: View { 9struct ContentView: View {
9 let workspace: WorkspaceModel 10 let workspace: WorkspaceModel
@@ -39,6 +40,8 @@ struct ContentView: View {
39 ) 40 )
40 } 41 }
41 @State private var selectedFile: String? 42 @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?
42 @State private var query = "" 45 @State private var query = ""
43 @FocusState private var searchFocused: Bool 46 @FocusState private var searchFocused: Bool
44 @Environment(\.openWindow) private var openWindow 47 @Environment(\.openWindow) private var openWindow
@@ -62,7 +65,9 @@ struct ContentView: View {
62 } 65 }
63 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) 66 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
64 } detail: { 67 } detail: {
65 if session.path != nil { 68 if let previewPath {
69 FilePreview(path: previewPath)
70 } else if session.path != nil {
66 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal) 71 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal)
67 .safeAreaInset(edge: .bottom, spacing: 0) { 72 .safeAreaInset(edge: .bottom, spacing: 0) {
68 VStack(spacing: 0) { 73 VStack(spacing: 0) {
@@ -78,7 +83,7 @@ struct ContentView: View {
78 ) 83 )
79 } 84 }
80 } 85 }
81 .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar") 86 .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
82 .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "") 87 .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
83 .searchable(text: $query, placement: .toolbar, prompt: "Search notes") 88 .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
84 .searchFocused($searchFocused) 89 .searchFocused($searchFocused)
@@ -102,7 +107,13 @@ struct ContentView: View {
102 } 107 }
103 } 108 }
104 .onChange(of: selectedFile) { old, path in 109 .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 }
106 if UnsavedChanges.resolve(session) { 117 if UnsavedChanges.resolve(session) {
107 session.open(path) 118 session.open(path)
108 } else { 119 } else {
@@ -322,6 +333,11 @@ struct ContentView: View {
322 333
323 /// Runs a command from a key, the palette or the Org menu. 334 /// Runs a command from a key, the palette or the Org menu.
324 private func perform(_ id: String) { 335 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 }
325 switch id { 341 switch id {
326 case "app.save": 342 case "app.save":
327 if !session.save(), session.conflict != nil { commands.showingConflict = true } 343 if !session.save(), session.conflict != nil { commands.showingConflict = true }
@@ -456,6 +472,14 @@ struct Sidebar: View {
456 case .org: return "doc.text" 472 case .org: return "doc.text"
457 case .archive: return "archivebox" 473 case .archive: return "archivebox"
458 case .conflict: return "exclamationmark.triangle" 474 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"
459 } 483 }
460 } 484 }
461} 485}
@@ -475,7 +499,9 @@ struct OutlineColumn: View {
475 .buttonStyle(.plain) 499 .buttonStyle(.plain)
476 } 500 }
477 .overlay { 501 .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 {
479 ContentUnavailableView("No headings", systemImage: "list.bullet.indent") 505 ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
480 } 506 }
481 } 507 }
Sources/Orgstar/EditorView.swift +2 −1
@@ -39,7 +39,8 @@ struct EditorView: NSViewRepresentable {
39 container.subviews.forEach { $0.removeFromSuperview() } 39 container.subviews.forEach { $0.removeFromSuperview() }
40 coordinator.editor = nil 40 coordinator.editor = nil
41 if let document = session.document { 41 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)
43 editor.onChange = { [weak session] in session?.bufferChanged() } 44 editor.onChange = { [weak session] in session?.bufferChanged() }
44 editor.onMessage = { [weak commands] in commands?.show($0) } 45 editor.onMessage = { [weak commands] in commands?.show($0) }
45 editor.onKeysPending = { [weak commands] keys, next in 46 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 {
19 } 19 }
20 20
21 /// The editor as a window would show it first: background styling not yet run. 21 /// 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) {
23 _ = NSApplication.shared 23 _ = NSApplication.shared
24 editor = OrgEditor(document: document) 24 editor = OrgEditor(document: document, mode: mode)
25 window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false) 25 window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false)
26 window.contentView = editor.makeScrollView() 26 window.contentView = editor.makeScrollView()
27 window.makeFirstResponder(editor.textView) 27 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 {
13 (".a.org.orgstar-1234", .ignored), 13 (".a.org.orgstar-1234", .ignored),
14 ("a.org~", .ignored), 14 ("a.org~", .ignored),
15 ("#a.org#", .ignored), 15 ("#a.org#", .ignored),
16 ("a.md", .ignored), 16 ("a.md", .file(.other)),
17 ("photo.jpg", .file(.other)),
17 (".hidden.org", .ignored), 18 (".hidden.org", .ignored),
18 ]) 19 ])
19 func classify(name: String, expected: FileClassification) { 20 func classify(name: String, expected: FileClassification) {
@@ -40,7 +41,8 @@ struct ScanTests {
40 try folder.write("sub/.e.org.icloud", "") 41 try folder.write("sub/.e.org.icloud", "")
41 try folder.write("notes.txt", "") 42 try folder.write("notes.txt", "")
42 let discovery = try WorkspaceScanner.scan(folder.url) 43 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])
44 #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"]) 46 #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"])
45 } 47 }
46} 48}
Tests/OrgWorkspaceTests/ReconcileTests.swift +17
@@ -50,6 +50,23 @@ struct ReconcileTests {
50 #expect(try Reconciler(index: index, settings: IndexSettings(version: 2)).reconcile(root: folder.url).indexed.count == 1) 50 #expect(try Reconciler(index: index, settings: IndexSettings(version: 2)).reconcile(root: folder.url).indexed.count == 1)
51 } 51 }
52 52
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
53 @Test func conflictCopiesAreListedButNotSearchable() throws { 70 @Test func conflictCopiesAreListedButNotSearchable() throws {
54 let folder = try Folder() 71 let folder = try Folder()
55 let index = try IndexStore() 72 let index = try IndexStore()