diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-10-22 17:32:59 -0700 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-10-22 17:32:59 -0700 |
| commit | 46213b6e09b8b23bfa039634ff5af4919c758473 (patch) | |
| tree | 3ce3e722cc918d42062ec8928db70e88a9785acf /Hotline/Models | |
| parent | c3d0c44698b542a2b46753531bcf3a2c4d264991 (diff) | |
First pass at file search in files view.
Diffstat (limited to 'Hotline/Models')
| -rw-r--r-- | Hotline/Models/Hotline.swift | 295 |
1 files changed, 293 insertions, 2 deletions
diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 48cae54..b93fb55 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,5 +1,29 @@ import SwiftUI +struct FileSearchConfig: Equatable { + var initialBurstCount: Int = 8 + var initialDelay: TimeInterval = 0.05 + var backoffMultiplier: Double = 1.6 + var maxDelay: TimeInterval = 1.5 + var maxDepth: Int = 40 + var loopRepetitionLimit: Int = 4 +} + +enum FileSearchStatus: Equatable { + case idle + case searching(processed: Int, pending: Int) + case completed(processed: Int) + case cancelled(processed: Int) + case failed(String) + + var isActive: Bool { + if case .searching = self { + return true + } + return false + } +} + @Observable class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate, HotlineFolderDownloadClientDelegate { let id: UUID = UUID() @@ -131,6 +155,13 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var messageBoardLoaded: Bool = false var files: [FileInfo] = [] var filesLoaded: Bool = false + var fileSearchResults: [FileInfo] = [] + var fileSearchStatus: FileSearchStatus = .idle + var fileSearchQuery: String = "" + var fileSearchConfig = FileSearchConfig() + var fileSearchScannedFolders: Int = 0 + @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil + @ObservationIgnored private var fileSearchResultKeys: Set<String> = [] var news: [NewsInfo] = [] private var newsLookup: [String:NewsInfo] = [:] var newsLoaded: Bool = false @@ -209,6 +240,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func disconnect() { self.client.disconnect() self.bannerClient?.cancel() + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + self.resetFileSearchState() } @MainActor func sendAgree() { @@ -264,9 +298,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.client.sendPostMessageBoard(text: text) } - @MainActor func getFileList(path: [String] = []) async -> [FileInfo] { + @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false) async -> [FileInfo] { return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetFileList(path: path) { [weak self] files in + self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in let parentFile = self?.findFile(in: self?.files ?? [], at: path) var newFiles: [FileInfo] = [] @@ -289,6 +323,92 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } + @MainActor func startFileSearch(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + cancelFileSearch() + return + } + + fileSearchSession?.cancel() + resetFileSearchState() + fileSearchQuery = trimmed + fileSearchStatus = .searching(processed: 0, pending: 0) + fileSearchScannedFolders = 0 + + let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) + fileSearchSession = session + + Task { await session.start() } + } + + @MainActor func cancelFileSearch(clearResults: Bool = true) { + guard let session = fileSearchSession else { + if clearResults { + resetFileSearchState() + } else if !fileSearchResults.isEmpty { + fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + } + return + } + + session.cancel() + fileSearchSession = nil + if clearResults { + resetFileSearchState() + } + else { + fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + } + } + + @MainActor fileprivate func searchSession(_ session: FileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { + guard fileSearchSession === session else { + return + } + + var appended: [FileInfo] = [] + for match in matches { + let key = searchPathKey(for: match.path) + if fileSearchResultKeys.insert(key).inserted { + appended.append(match) + } + } + + if !appended.isEmpty { + fileSearchResults.append(contentsOf: appended) + } + + fileSearchScannedFolders = processed + fileSearchStatus = .searching(processed: processed, pending: pending) + } + + @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { + guard fileSearchSession === session else { + return + } + + fileSearchScannedFolders = processed + fileSearchSession = nil + if completed { + fileSearchStatus = .completed(processed: processed) + } else { + fileSearchStatus = .cancelled(processed: processed) + } + } + + private func resetFileSearchState() { + fileSearchResults = [] + fileSearchResultKeys.removeAll(keepingCapacity: true) + fileSearchStatus = .idle + fileSearchQuery = "" + fileSearchScannedFolders = 0 + } + + private func searchPathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) { articleText in @@ -809,6 +929,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.serverName = nil self.access = nil + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + self.resetFileSearchState() + self.users = [] self.chat = [] self.messageBoard = [] @@ -1156,3 +1280,170 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } } + +@MainActor +final class FileSearchSession { + private struct FolderTask { + let path: [String] + let depth: Int + } + + private weak var hotline: Hotline? + private let queryTokens: [String] + private let config: FileSearchConfig + + private var queue: [FolderTask] = [] + private var visited: Set<String> = [] + private var loopHistogram: [String: Int] = [:] + + private var processedCount: Int = 0 + private var currentDelay: TimeInterval + private var isCancelled = false + + init(hotline: Hotline, query: String, config: FileSearchConfig) { + self.hotline = hotline + self.queryTokens = query.lowercased().split(separator: " ").map(String.init) + self.config = config + self.currentDelay = config.initialDelay + } + + func start() async { + guard let hotline else { + return + } + + await Task.yield() + + if !hotline.filesLoaded { + let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) + processedCount = max(processedCount, 1) + processListing(rootFiles, depth: 0) + } + else { + processedCount = max(processedCount, 1) + processListing(hotline.files, depth: 0) + } + + while !queue.isEmpty && !isCancelled { + await Task.yield() + + let task = queue.removeFirst() + if shouldSkip(path: task.path, depth: task.depth) { + hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) + continue + } + + visited.insert(pathKey(for: task.path)) + + let children = await hotline.getFileList(path: task.path, suppressErrors: true) + processedCount += 1 + + if isCancelled { + break + } + + processListing(children, depth: task.depth) + + await applyBackoff() + } + + hotline.searchSessionDidFinish(self, processed: processedCount, pending: queue.count, completed: !isCancelled) + } + + func cancel() { + isCancelled = true + } + + private func processListing(_ items: [FileInfo], depth: Int) { + guard let hotline else { + return + } + + var matches: [FileInfo] = [] + + for file in items { + if nameMatchesQuery(file.name) { + matches.append(file) + } + + if file.isFolder { + enqueueFolder(file, depth: depth + 1) + } + } + + hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) + } + + private func enqueueFolder(_ folder: FileInfo, depth: Int) { + guard !isCancelled else { return } + guard depth <= config.maxDepth else { return } + + let path = folder.path + let key = pathKey(for: path) + guard !visited.contains(key) else { return } + + if exceedsLoopThreshold(for: path) { + return + } + + queue.append(FolderTask(path: path, depth: depth)) + } + + private func shouldSkip(path: [String], depth: Int) -> Bool { + if isCancelled { + return true + } + + if depth > config.maxDepth { + return true + } + + let key = pathKey(for: path) + if visited.contains(key) { + return true + } + + return false + } + + private func nameMatchesQuery(_ name: String) -> Bool { + guard !queryTokens.isEmpty else { return false } + let lowercased = name.lowercased() + return queryTokens.allSatisfy { lowercased.contains($0) } + } + + private func exceedsLoopThreshold(for path: [String]) -> Bool { + guard config.loopRepetitionLimit > 0 else { return false } + guard let last = path.last else { return false } + let parent = path.dropLast() + + guard let previousIndex = parent.lastIndex(of: last) else { + return false + } + + let suffix = Array(path[previousIndex...]) + let key = suffix.joined(separator: "\u{001F}") + let count = (loopHistogram[key] ?? 0) + 1 + loopHistogram[key] = count + return count > config.loopRepetitionLimit + } + + private func pathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func applyBackoff() async { + guard !isCancelled else { return } + + if processedCount > config.initialBurstCount { + currentDelay = min(config.maxDelay, max(config.initialDelay, currentDelay * config.backoffMultiplier)) + } + + guard currentDelay > 0 else { + return + } + + let nanoseconds = UInt64(currentDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} |