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 | 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 | ||
| 44 | public enum WorkspaceScanner { | 44 | public 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 | |||
| 4 | import OrgEditorAppKit | 4 | import OrgEditorAppKit |
| 5 | import OrgIndex | 5 | import OrgIndex |
| 6 | import SwiftUI | 6 | import SwiftUI |
| 7 | import UniformTypeIdentifiers | ||
| 7 | 8 | ||
| 8 | struct ContentView: View { | 9 | struct 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 @@ | |||
| 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 | /// 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 @@ | |||
| 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 | (".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() |