krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgApp/FileTree.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgApp/FileTree.swift history · blame · raw

99 lines · 4517 bytes

8 symbols in this file
 1import Foundation
 2import OrgIndex
 3
 4/// One file the workspace knows about.
 5public struct FileEntry: Sendable, Hashable, Identifiable {
 6    public let path: String
 7    public let root: String
 8    public let kind: FileKind
 9
10    public var id: String { path }
11    public var name: String { (path as NSString).lastPathComponent }
12    /// Path below the root, for display and agenda globs.
13    public var relativePath: String {
14        path.hasPrefix(root + "/") ? String(path.dropFirst(root.count + 1)) : path
15    }
16}
17
18/// A folder or file in the sidebar.
19public struct FileTreeNode: Sendable, Hashable, Identifiable {
20    public let name: String
21    /// The file's or folder's absolute path.
22    public let path: String
23    public let file: FileEntry?
24    /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle.
25    public let children: [FileTreeNode]?
26    /// An iCloud file on its way; it opens once it's indexed.
27    public var isDownloading = false
28
29    public var id: String { path }
30
31    /// Folders first, then files, each by name. Conflict copies are listed with their files.
32    public static func build(root: String, files: [FileEntry], downloading: [String] = []) -> FileTreeNode {
33        final class Folder {
34            var folders: [String: Folder] = [:]
35            var files: [FileEntry] = []
36            var downloading: [String] = []
37        }
38        let top = Folder()
39        for path in downloading where path.hasPrefix(root + "/") {
40            var folder = top
41            for component in path.dropFirst(root.count + 1).split(separator: "/").dropLast() {
42                let name = String(component)
43                if folder.folders[name] == nil { folder.folders[name] = Folder() }
44                folder = folder.folders[name]!
45            }
46            folder.downloading.append(path)
47        }
48        for file in files where file.root == root {
49            var folder = top
50            for component in file.relativePath.split(separator: "/").dropLast() {
51                let name = String(component)
52                if folder.folders[name] == nil { folder.folders[name] = Folder() }
53                folder = folder.folders[name]!
54            }
55            folder.files.append(file)
56        }
57        func node(_ folder: Folder, name: String, path: String) -> FileTreeNode {
58            let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map {
59                node(folder.folders[$0]!, name: $0, path: path + "/" + $0)
60            }
61            let files = (folder.files.map {
62                FileTreeNode(name: $0.name, path: $0.path, file: $0, children: nil)
63            } + folder.downloading.map {
64                FileTreeNode(name: ($0 as NSString).lastPathComponent, path: $0, file: nil, children: nil, isDownloading: true)
65            }).sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
66            return FileTreeNode(name: name, path: path, file: nil, children: folders + files)
67        }
68        return node(top, name: (root as NSString).lastPathComponent, path: root)
69    }
70}
71
72/// Subsequence matching for quick open: every query character must appear in order. Matches
73/// at word starts and runs of consecutive characters score higher; shorter names win ties.
74public enum FuzzyMatch {
75    public static func score(_ query: String, _ candidate: String) -> Int? {
76        let query = Array(query.lowercased().filter { !$0.isWhitespace })
77        let text = Array(candidate.lowercased())
78        guard !query.isEmpty else { return 0 }
79        var score = 0
80        var q = 0
81        var previousMatch = -2
82        for (i, c) in text.enumerated() where q < query.count && c == query[q] {
83            score += 1
84            if i == previousMatch + 1 { score += 5 }
85            if i == 0 || "/ -_.".contains(text[i - 1]) { score += 8 }
86            previousMatch = i
87            q += 1
88        }
89        guard q == query.count else { return nil }
90        return score * 100 - text.count
91    }
92
93    /// Entries ranked by `score` against their relative path, best first.
94    public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] {
95        let scored: [(file: FileEntry, score: Int)] = files.compactMap { file in score(query, file.relativePath).map { (file, $0) } }
96        let ranked = scored.sorted { a, b in a.score != b.score ? a.score > b.score : a.file.relativePath < b.file.relativePath }
97        return ranked.prefix(limit).map(\.file)
98    }
99}