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 | |
| parent | c3d0c44698b542a2b46753531bcf3a2c4d264991 (diff) | |
First pass at file search in files view.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 38 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 295 | ||||
| -rw-r--r-- | Hotline/macOS/FilesView.swift | 139 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 11 |
4 files changed, 454 insertions, 29 deletions
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 99aa167..3c6a643 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -89,7 +89,13 @@ class HotlineClient: NetSocketDelegate { private var serverAddress: String? = nil private var serverPort: UInt16? = nil - private var transactionLog: [UInt32:(HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] = [:] + private struct TransactionContext { + let type: HotlineTransactionType + let callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? + let suppressErrors: Bool + } + + private var transactionLog: [UInt32: TransactionContext] = [:] private var socket: NetSocket? private var stage: HotlineClientStage = .handshake @@ -208,15 +214,15 @@ class HotlineClient: NetSocketDelegate { // MARK: - Packets - @MainActor private func sendPacket(_ t: HotlineTransaction, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { + @MainActor private func sendPacket(_ t: HotlineTransaction, suppressErrors: Bool = false, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { guard let socket = self.socket else { return } print("HotlineClient => \(t.id) \(t.type)") - if callback != nil { - self.transactionLog[t.id] = (t.type, callback) + if callback != nil || suppressErrors { + self.transactionLog[t.id] = TransactionContext(type: t.type, callback: callback, suppressErrors: suppressErrors) } socket.write(t.encoded()) @@ -375,22 +381,22 @@ class HotlineClient: NetSocketDelegate { return } + let context = self.transactionLog[packet.id] + self.transactionLog[packet.id] = nil + if packet.errorCode != 0 { let errorField: HotlineTransactionField? = packet.getField(type: .errorText) print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")") - self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) + if context?.suppressErrors != true { + self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) + } } - - guard let replyCallbackInfo = self.transactionLog[packet.id] else { - print("Hmm, no reply waiting though") - return + + if let context { + print("HotlineClient reply in response to \(context.type)") } - self.transactionLog[packet.id] = nil - - print("HotlineClient reply in response to \(replyCallbackInfo.0)") - - let replyCallback = replyCallbackInfo.1 + let replyCallback = context?.callback guard packet.errorCode == 0 else { let errorField: HotlineTransactionField? = packet.getField(type: .errorText) @@ -630,13 +636,13 @@ class HotlineClient: NetSocketDelegate { } } - @MainActor func sendGetFileList(path: [String] = [], callback: (([HotlineFile]) -> Void)? = nil) { + @MainActor func sendGetFileList(path: [String] = [], suppressErrors: Bool = false, callback: (([HotlineFile]) -> Void)? = nil) { var t = HotlineTransaction(type: .getFileNameList) if !path.isEmpty { t.setFieldPath(type: .filePath, val: path) } - self.sendPacket(t) { reply, err in + self.sendPacket(t, suppressErrors: suppressErrors) { reply, err in guard err == nil else { callback?([]) return 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) + } +} diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index f399372..206e0a4 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -213,6 +213,48 @@ struct FilesView: View { @State private var selection: FileInfo? @State private var fileDetails: FileDetails? @State private var uploadFileSelectorDisplayed: Bool = false + @State private var searchText: String = "" + + private var isShowingSearchResults: Bool { + switch model.fileSearchStatus { + case .idle: + return !model.fileSearchResults.isEmpty + case .cancelled(_): + return !model.fileSearchResults.isEmpty + default: + return true + } + } + + private var displayedFiles: [FileInfo] { + isShowingSearchResults ? model.fileSearchResults : model.files + } + + private var searchStatusMessage: String? { + switch model.fileSearchStatus { + case .searching(let processed, let pending): + let scanned = processed == 1 ? "folder" : "folders" + return "Searched \(processed) \(scanned)..." + case .completed(let processed): + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + if count == 0 { + return "No files found in \(processed) \(folderWord)" + } + return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" + case .cancelled(let processed): + if model.fileSearchResults.isEmpty { + return nil + } + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { @@ -278,7 +320,7 @@ struct FilesView: View { var body: some View { NavigationStack { - List(model.files, id: \.self, selection: $selection) { file in + List(displayedFiles, id: \.self, selection: $selection) { file in if file.isFolder { FolderView(file: file, depth: 0).tag(file.id) } @@ -383,8 +425,9 @@ struct FilesView: View { .frame(maxWidth: .infinity) } } + .searchable(text: $searchText, placement: .automatic, prompt: "Search") .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .automatic) { Button { if let selectedFile = selection, selectedFile.isPreviewable { previewFile(selectedFile) @@ -394,9 +437,7 @@ struct FilesView: View { } .help("Preview") .disabled(selection == nil || selection?.isPreviewable == false) - } - - ToolbarItem(placement: .primaryAction) { + Button { if let selectedFile = selection { getFileInfo(selectedFile) @@ -406,9 +447,7 @@ struct FilesView: View { } .help("Get Info") .disabled(selection == nil) - } - - ToolbarItem(placement: .primaryAction) { + Button { uploadFileSelectorDisplayed = true } label: { @@ -416,9 +455,7 @@ struct FilesView: View { } .help("Upload") .disabled(model.access?.contains(.canUploadFiles) != true) - } - - ToolbarItem(placement: .primaryAction) { + Button { if let selectedFile = selection { downloadFile(selectedFile) @@ -464,6 +501,86 @@ struct FilesView: View { print(error) } }) + .onSubmit(of: .search) { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + model.cancelFileSearch() + return + } + searchText = trimmed + model.startFileSearch(query: trimmed) + } + .onChange(of: searchText) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if isShowingSearchResults { + model.cancelFileSearch() + } + } + } + .onChange(of: model.fileSearchQuery) { _, newValue in + if newValue != searchText { + searchText = newValue + } + } + .onAppear { + if searchText != model.fileSearchQuery { + searchText = model.fileSearchQuery + } + } + .safeAreaInset(edge: .top) { + if isShowingSearchResults, let message = searchStatusMessage { + HStack { + Spacer() + + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.small) + .tint(.red) + } + else if case .completed = model.fileSearchStatus { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if case .failed = model.fileSearchStatus { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + + Text(message) + .font(.body) + .foregroundStyle(.white) + + Spacer() + } +// .overlay(alignment: .leading) { +// Button { +// model.cancelFileSearch(clearResults: true) +// } label: { +// Image(systemName: "xmark.circle.fill") +// .resizable() +// .scaledToFit() +// .foregroundStyle(.white) +// .opacity(0.8) +// } +// .buttonStyle(.plain) +// } + .padding(.horizontal, 8) + .padding(.leading, 4) + .padding(.vertical, 8) + .background { + Color(nsColor: .controlAccentColor) + .clipShape(.capsule(style: .continuous)) + } + .padding(.horizontal, 8) + } + } } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 81c993a..49413fe 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -392,6 +392,16 @@ struct ServerView: View { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } } + else if menuItem.type == .files { + ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) + .overlay(alignment: .trailing) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.mini) + .padding(.trailing, 4) + } + } + } else { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } @@ -700,3 +710,4 @@ struct TransferItemView: View { .help(formattedProgressHelp()) } } + |