Sources/OrgApp/FileTree.swift
86 lines · 3657 bytes
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
27 public var id: String { path }
28
29 /// Folders first, then files, each by name. Conflict copies are listed with their files.
30 public static func build(root: String, files: [FileEntry]) -> FileTreeNode {
31 final class Folder {
32 var folders: [String: Folder] = [:]
33 var files: [FileEntry] = []
34 }
35 let top = Folder()
36 for file in files where file.root == root {
37 var folder = top
38 for component in file.relativePath.split(separator: "/").dropLast() {
39 let name = String(component)
40 if folder.folders[name] == nil { folder.folders[name] = Folder() }
41 folder = folder.folders[name]!
42 }
43 folder.files.append(file)
44 }
45 func node(_ folder: Folder, name: String, path: String) -> FileTreeNode {
46 let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map {
47 node(folder.folders[$0]!, name: $0, path: path + "/" + $0)
48 }
49 let files = folder.files.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.map {
50 FileTreeNode(name: $0.name, path: $0.path, file: $0, children: nil)
51 }
52 return FileTreeNode(name: name, path: path, file: nil, children: folders + files)
53 }
54 return node(top, name: (root as NSString).lastPathComponent, path: root)
55 }
56}
57
58/// Subsequence matching for quick open: every query character must appear in order. Matches
59/// at word starts and runs of consecutive characters score higher; shorter names win ties.
60public enum FuzzyMatch {
61 public static func score(_ query: String, _ candidate: String) -> Int? {
62 let query = Array(query.lowercased().filter { !$0.isWhitespace })
63 let text = Array(candidate.lowercased())
64 guard !query.isEmpty else { return 0 }
65 var score = 0
66 var q = 0
67 var previousMatch = -2
68 for (i, c) in text.enumerated() where q < query.count && c == query[q] {
69 score += 1
70 if i == previousMatch + 1 { score += 5 }
71 if i == 0 || "/ -_.".contains(text[i - 1]) { score += 8 }
72 previousMatch = i
73 q += 1
74 }
75 guard q == query.count else { return nil }
76 return score * 100 - text.count
77 }
78
79 /// Entries ranked by `score` against their relative path, best first.
80 public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] {
81 files.compactMap { file in score(query, file.relativePath).map { (file, $0) } }
82 .sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.relativePath < $1.0.relativePath }
83 .prefix(limit)
84 .map(\.0)
85 }
86}