aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-22 17:32:59 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-22 17:32:59 -0700
commit46213b6e09b8b23bfa039634ff5af4919c758473 (patch)
tree3ce3e722cc918d42062ec8928db70e88a9785acf /Hotline
parentc3d0c44698b542a2b46753531bcf3a2c4d264991 (diff)
First pass at file search in files view.
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Hotline/HotlineClient.swift38
-rw-r--r--Hotline/Models/Hotline.swift295
-rw-r--r--Hotline/macOS/FilesView.swift139
-rw-r--r--Hotline/macOS/ServerView.swift11
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())
}
}
+