aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-23 11:24:06 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-23 11:24:06 -0700
commit5b4d615792a951979ff79506657e8407d2a48016 (patch)
treeaf5fe28ad44c1a4c9b9f66c9036301de9464acb0 /Hotline
parent485e4d981b6cd19a1e00ebf244ed1dd538fba284 (diff)
Improve file search to move down the tree once current depth has been searched with exception now for hot spots where we burst into folders that contain matches.
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Models/Hotline.swift101
-rw-r--r--Hotline/macOS/FilesView.swift7
2 files changed, 96 insertions, 12 deletions
diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift
index 786fb0c..1e2c6c5 100644
--- a/Hotline/Models/Hotline.swift
+++ b/Hotline/Models/Hotline.swift
@@ -1,12 +1,20 @@
import SwiftUI
struct FileSearchConfig: Equatable {
+ /// Number of folders we process before we start applying delay backoff.
var initialBurstCount: Int = 15
+ /// Base delay applied between folder requests during the backoff phase.
var initialDelay: TimeInterval = 0.02
+ /// Multiplier used to increase the delay after each processed folder in backoff.
var backoffMultiplier: Double = 1.1
- var maxDelay: TimeInterval = 1.3
+ /// Maximum delay cap so searches don't stall out during long walks.
+ var maxDelay: TimeInterval = 1.0
+ /// Maximum recursion depth allowed during file search.
var maxDepth: Int = 40
+ /// Limit for repeated folder loops (guards against circular server listings).
var loopRepetitionLimit: Int = 4
+ /// Number of child folders that get prioritized after a matching parent is found.
+ var hotBurstLimit: Int = 2
}
enum FileSearchStatus: Equatable {
@@ -1300,6 +1308,7 @@ final class FileSearchSession {
private struct FolderTask {
let path: [String]
let depth: Int
+ let isHot: Bool
}
private weak var hotline: Hotline?
@@ -1332,18 +1341,20 @@ final class FileSearchSession {
hotline.searchSession(self, didFocusOn: [])
let rootFiles = await hotline.getFileList(path: [], suppressErrors: true)
processedCount = max(processedCount, 1)
- processListing(rootFiles, depth: 0)
+ processListing(rootFiles, depth: 0, parentPath: [], parentIsHot: false)
}
else {
hotline.searchSession(self, didFocusOn: [])
processedCount = max(processedCount, 1)
- processListing(hotline.files, depth: 0)
+ processListing(hotline.files, depth: 0, parentPath: [], parentIsHot: false)
}
while !queue.isEmpty && !isCancelled {
await Task.yield()
- let task = queue.removeFirst()
+ guard let task = dequeueNextTask() else {
+ continue
+ }
if shouldSkip(path: task.path, depth: task.depth) {
hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count)
continue
@@ -1359,7 +1370,7 @@ final class FileSearchSession {
break
}
- processListing(children, depth: task.depth)
+ processListing(children, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
await applyBackoff()
}
@@ -1371,27 +1382,61 @@ final class FileSearchSession {
isCancelled = true
}
- private func processListing(_ items: [FileInfo], depth: Int) {
+ private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) {
guard let hotline else {
return
}
var matches: [FileInfo] = []
+ var folderEntries: [(file: FileInfo, isHot: Bool)] = []
+ var hasFileMatch = false
for file in items {
- if nameMatchesQuery(file.name) {
+ let matchesName = nameMatchesQuery(file.name)
+
+ if matchesName {
matches.append(file)
+ if !file.isFolder {
+ hasFileMatch = true
+ }
}
if file.isFolder {
- enqueueFolder(file, depth: depth + 1)
+ folderEntries.append((file, matchesName))
+ }
+ }
+
+ var remainingBurst = 0
+ if config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) {
+ remainingBurst = config.hotBurstLimit
+ }
+
+ if remainingBurst > 0 {
+ var candidateIndices: [Int] = []
+ for index in folderEntries.indices where !folderEntries[index].isHot {
+ candidateIndices.append(index)
+ }
+
+ if !candidateIndices.isEmpty {
+ candidateIndices.shuffle()
+ for index in candidateIndices {
+ folderEntries[index].isHot = true
+ remainingBurst -= 1
+ if remainingBurst == 0 {
+ break
+ }
+ }
}
}
+ for entry in folderEntries {
+ enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot)
+ }
+
hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count)
}
- private func enqueueFolder(_ folder: FileInfo, depth: Int) {
+ private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) {
guard !isCancelled else { return }
guard depth <= config.maxDepth else { return }
@@ -1403,7 +1448,43 @@ final class FileSearchSession {
return
}
- queue.append(FolderTask(path: path, depth: depth))
+ queue.append(FolderTask(path: path, depth: depth, isHot: markHot))
+ }
+
+ private func dequeueNextTask() -> FolderTask? {
+ guard !queue.isEmpty else {
+ return nil
+ }
+
+ if queue.count == 1 {
+ return queue.removeFirst()
+ }
+
+ let currentDepth = queue[0].depth
+ var lastSameDepthIndex = 0
+ var hotIndices: [Int] = []
+
+ for index in 0..<queue.count {
+ let candidate = queue[index]
+ if candidate.depth == currentDepth {
+ lastSameDepthIndex = index
+ if candidate.isHot {
+ hotIndices.append(index)
+ }
+ } else {
+ break
+ }
+ }
+
+ let selectionPool: [Int]
+ if !hotIndices.isEmpty {
+ selectionPool = hotIndices
+ } else {
+ selectionPool = Array(0...lastSameDepthIndex)
+ }
+
+ let randomIndex = selectionPool.randomElement() ?? 0
+ return queue.remove(at: randomIndex)
}
private func shouldSkip(path: [String], depth: Int) -> Bool {
diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift
index 4498dc9..5d06b02 100644
--- a/Hotline/macOS/FilesView.swift
+++ b/Hotline/macOS/FilesView.swift
@@ -263,7 +263,7 @@ struct FilesView: View {
if path.isEmpty {
return "/"
}
- return "/" + path.joined(separator: "/") + "/"
+ return path.joined(separator: "/")
}
private func openPreviewWindow(_ previewInfo: PreviewFileInfo) {
@@ -464,7 +464,10 @@ struct FilesView: View {
Label("Upload", systemImage: "arrow.up")
}
.help("Upload")
- .disabled(model.access?.contains(.canUploadFiles) != true)
+ .disabled(
+ model.access?.contains(.canUploadFiles) != true ||
+ (model.fileSearchStatus.isActive && !(selection?.isFolder ?? false))
+ )
Button {
if let selectedFile = selection {