Open files that aren't org !37
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 | 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 | 97 | /// The caret in the open file. |
| 91 | 98 | public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 } |
| 92 | 99 | private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } |
| @@ -121,7 +128,7 @@ public final class DocumentSession { | ||
| 121 | 128 | outlineUpdate = Task { [weak self] in |
| 122 | 129 | try? await Task.sleep(for: .milliseconds(300)) |
| 123 | 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 | 132 | self.editVersion += 1 |
| 126 | 133 | } |
| 127 | 134 | scheduleAutosave() |
| @@ -134,7 +141,7 @@ public final class DocumentSession { | ||
| 134 | 141 | if isDirty != state.isDirty { isDirty = state.isDirty } |
| 135 | 142 | if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } |
| 136 | 143 | if updateOutline { |
| 137 | outline = Presentation.outline(state.tree) | |
| 144 | outline = isOrg ? Presentation.outline(state.tree) : [] | |
| 138 | 145 | editVersion += 1 |
| 139 | 146 | } |
| 140 | 147 | } |
Sources/OrgEditorAppKit/Modal.swift +2 −1
| @@ -92,7 +92,8 @@ extension OrgEditor { | ||
| 92 | 92 | } |
| 93 | 93 | |
| 94 | 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 | 97 | case .text(let command): command.applies(in: context) |
| 97 | 98 | default: true |
| 98 | 99 | } |
Sources/OrgEditorAppKit/OrgEditor.swift +36 −3
| @@ -75,12 +75,24 @@ public final class OrgEditor: NSObject { | ||
| 75 | 75 | /// Highlights code in src blocks; nil leaves code plain. |
| 76 | 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 | 89 | public init( |
| 79 | 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 | 93 | self.document = document |
| 83 | 94 | self.highlighter = highlighter |
| 95 | self.mode = mode | |
| 84 | 96 | let orgTextView = OrgTextView(usingTextLayoutManager: true) |
| 85 | 97 | textView = orgTextView |
| 86 | 98 | dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap) |
| @@ -110,7 +122,7 @@ public final class OrgEditor: NSObject { | ||
| 110 | 122 | textContentStorage.delegate = self |
| 111 | 123 | |
| 112 | 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 | 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 | 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 | 287 | case .text(let command): command.applies(in: context) |
| 275 | 288 | case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil |
| 276 | 289 | case .edit, .mark, .app: true |
| @@ -284,6 +297,10 @@ public final class OrgEditor: NSObject { | ||
| 284 | 297 | onMessage?("Unknown command \(id)") |
| 285 | 298 | return |
| 286 | 299 | } |
| 300 | if !isOrg, id.hasPrefix("org.") { | |
| 301 | onMessage?("Not an org file") | |
| 302 | return | |
| 303 | } | |
| 287 | 304 | var keepMark = false |
| 288 | 305 | switch command.action { |
| 289 | 306 | case .text(let text): |
| @@ -450,6 +467,13 @@ public final class OrgEditor: NSObject { | ||
| 450 | 467 | guard !range.isEmpty else { return } |
| 451 | 468 | let whole = NSRange(range) |
| 452 | 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 | 477 | let text = storage.string as NSString |
| 454 | 478 | for run in Presentation.indents(document.tree, in: range) { |
| 455 | 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 | 510 | private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle { |
| 478 | 511 | let key = IndentKey(firstLine: firstLine, wrapped: wrapped) |
| 479 | 512 | if let style = paragraphStyles[key] { return style } |
Sources/OrgIndex/FileRecord.swift +16
| @@ -7,6 +7,8 @@ public enum FileKind: String, Sendable { | ||
| 7 | 7 | case archive |
| 8 | 8 | /// A Syncthing conflict copy: listed, never indexed for search, agenda or IDs. |
| 9 | 9 | case conflict |
| 10 | /// Any other file: listed and openable, never read by the index. | |
| 11 | case other | |
| 10 | 12 | } |
| 11 | 13 | |
| 12 | 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 | 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 | 142 | static func headings(_ text: String, settings: IndexSettings) -> [HeadingRecord] { |
| 127 | 143 | let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic) |
| 128 | 144 | let utf16 = text.utf16 |
Sources/OrgWorkspace/Discovery.swift +4 −3
| @@ -43,7 +43,8 @@ public enum FileClassification: Sendable, Equatable { | ||
| 43 | 43 | |
| 44 | 44 | public enum WorkspaceScanner { |
| 45 | 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 | 48 | public static func classify(_ name: String) -> FileClassification { |
| 48 | 49 | if name.hasPrefix(".") { |
| 49 | 50 | if name.hasSuffix(".icloud") { |
| @@ -56,10 +57,10 @@ public enum WorkspaceScanner { | ||
| 56 | 57 | let conflict = name.contains(".sync-conflict-") |
| 57 | 58 | if name.hasSuffix(".org") { return .file(conflict ? .conflict : .org) } |
| 58 | 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 | 64 | public static func scan(_ root: URL) throws -> Discovery { |
| 64 | 65 | let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey] |
| 65 | 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 | 38 | let wanted = Set(paths.map(\.standardizedFileURL.path)) |
| 39 | 39 | let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) } |
| 40 | 40 | var found: [DiscoveredFile] = [] |
| 41 | let rootComponents = root.standardizedFileURL.pathComponents.count | |
| 41 | 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 | 45 | guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent), |
| 43 | 46 | let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]), |
| 44 | 47 | values.isDirectory != true else { continue } |
| @@ -62,6 +65,15 @@ public struct Reconciler: Sendable { | ||
| 62 | 65 | report.unchanged += 1 |
| 63 | 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 | 77 | guard let data = try? Data(contentsOf: file.url) else { continue } |
| 66 | 78 | let bytes = [UInt8](data) |
| 67 | 79 | let hash = FileRecord.hash(bytes) |
| @@ -85,6 +97,9 @@ public struct Reconciler: Sendable { | ||
| 85 | 97 | missing[oldPath] = nil |
| 86 | 98 | change.moves.append((oldPath, path, candidate.file.mtime)) |
| 87 | 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 | 103 | } else { |
| 89 | 104 | change.records.append(FileRecord( |
| 90 | 105 | path: path, root: root.path, kind: candidate.file.kind, |
Sources/Orgstar/ContentView.swift +30 −4
| @@ -4,6 +4,7 @@ import OrgCore | ||
| 4 | 4 | import OrgEditorAppKit |
| 5 | 5 | import OrgIndex |
| 6 | 6 | import SwiftUI |
| 7 | import UniformTypeIdentifiers | |
| 7 | 8 | |
| 8 | 9 | struct ContentView: View { |
| 9 | 10 | let workspace: WorkspaceModel |
| @@ -39,6 +40,8 @@ struct ContentView: View { | ||
| 39 | 40 | ) |
| 40 | 41 | } |
| 41 | 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 | 45 | @State private var query = "" |
| 43 | 46 | @FocusState private var searchFocused: Bool |
| 44 | 47 | @Environment(\.openWindow) private var openWindow |
| @@ -62,7 +65,9 @@ struct ContentView: View { | ||
| 62 | 65 | } |
| 63 | 66 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) |
| 64 | 67 | } detail: { |
| 65 | if session.path != nil { | |
| 68 | if let previewPath { | |
| 69 | FilePreview(path: previewPath) | |
| 70 | } else if session.path != nil { | |
| 66 | 71 | EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal) |
| 67 | 72 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 68 | 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 | 87 | .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "") |
| 83 | 88 | .searchable(text: $query, placement: .toolbar, prompt: "Search notes") |
| 84 | 89 | .searchFocused($searchFocused) |
| @@ -102,7 +107,13 @@ struct ContentView: View { | ||
| 102 | 107 | } |
| 103 | 108 | } |
| 104 | 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 | 117 | if UnsavedChanges.resolve(session) { |
| 107 | 118 | session.open(path) |
| 108 | 119 | } else { |
| @@ -322,6 +333,11 @@ struct ContentView: View { | ||
| 322 | 333 | |
| 323 | 334 | /// Runs a command from a key, the palette or the Org menu. |
| 324 | 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 | 341 | switch id { |
| 326 | 342 | case "app.save": |
| 327 | 343 | if !session.save(), session.conflict != nil { commands.showingConflict = true } |
| @@ -456,6 +472,14 @@ struct Sidebar: View { | ||
| 456 | 472 | case .org: return "doc.text" |
| 457 | 473 | case .archive: return "archivebox" |
| 458 | 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 | 499 | .buttonStyle(.plain) |
| 476 | 500 | } |
| 477 | 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 | 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 | 39 | container.subviews.forEach { $0.removeFromSuperview() } |
| 40 | 40 | coordinator.editor = nil |
| 41 | 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 | 44 | editor.onChange = { [weak session] in session?.bufferChanged() } |
| 44 | 45 | editor.onMessage = { [weak commands] in commands?.show($0) } |
| 45 | 46 | editor.onKeysPending = { [weak commands] keys, next in |
Sources/Orgstar/FilePreview.swift added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | import AppKit | |
| 2 | import Quartz | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | /// A file that isn't text: Quick Look's preview, and buttons to open it elsewhere. | |
| 6 | struct 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 | ||
| 23 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import UniformTypeIdentifiers | |
| 3 | ||
| 4 | /// How a file that isn't org opens: as text in the editor, or as a preview. | |
| 5 | enum 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 | 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 | 23 | _ = NSApplication.shared |
| 24 | editor = OrgEditor(document: document) | |
| 24 | editor = OrgEditor(document: document, mode: mode) | |
| 25 | 25 | window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false) |
| 26 | 26 | window.contentView = editor.makeScrollView() |
| 27 | 27 | window.makeFirstResponder(editor.textView) |
Tests/OrgEditorAppKitTests/PlainModeTests.swift added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | import AppKit | |
| 2 | import OrgCore | |
| 3 | import OrgDocument | |
| 4 | import Testing | |
| 5 | @testable import OrgEditorAppKit | |
| 6 | ||
| 7 | /// Files that aren't org: plain text, without org styling, folding or commands. | |
| 8 | @MainActor | |
| 9 | struct 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 | 13 | (".a.org.orgstar-1234", .ignored), |
| 14 | 14 | ("a.org~", .ignored), |
| 15 | 15 | ("#a.org#", .ignored), |
| 16 | ("a.md", .ignored), | |
| 16 | ("a.md", .file(.other)), | |
| 17 | ("photo.jpg", .file(.other)), | |
| 17 | 18 | (".hidden.org", .ignored), |
| 18 | 19 | ]) |
| 19 | 20 | func classify(name: String, expected: FileClassification) { |
| @@ -40,7 +41,8 @@ struct ScanTests { | ||
| 40 | 41 | try folder.write("sub/.e.org.icloud", "") |
| 41 | 42 | try folder.write("notes.txt", "") |
| 42 | 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 | 46 | #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"]) |
| 45 | 47 | } |
| 46 | 48 | } |
Tests/OrgWorkspaceTests/ReconcileTests.swift +17
| @@ -50,6 +50,23 @@ struct ReconcileTests { | ||
| 50 | 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 | 70 | @Test func conflictCopiesAreListedButNotSearchable() throws { |
| 54 | 71 | let folder = try Folder() |
| 55 | 72 | let index = try IndexStore() |