mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
* feat: Enhance file search functionality with type filtering and Quick Look support - Introduced FileSearchFilter to allow users to filter search results by type (All Types, Folders, Documents, Images, Audio, Video, Archives). - Updated FileSearchScreen to display Recently Used files when the search query is empty. - Implemented Quick Look functionality within the file search panel, allowing users to preview files without leaving the search interface. - Enhanced the UI to include a preview pane alongside search results, displaying relevant file information. - Added keyboard shortcuts for file actions (copy, paste, trash) and Quick Look toggle. - Improved the handling of empty states and error messages during file searches. - Updated documentation to reflect new features and usage instructions. * Read one attribute per Spotlight result, not four Opening Search Files sat for half a second before its Recently Used rows appeared, and a broad query took the best part of a second. Neither was Spotlight: `MDQueryExecute` returns in 15–75 ms. It was `MDItemCopyAttribute`, which costs about half a millisecond per attribute per result — reading the content type, the invisible flag and both date stamps over the ~430 candidates a recents query matches was 430 ms of the 550. `kMDItemPath` is the exception: `MDQuery` hands it back from its own cache, so a thousand of them read in 2.8 ms. Everything else a row needs — is it a folder, is it hidden, is it an application — now comes from one `resourceValues` stat, taken only for the candidates the ignore list did not already drop. Two hundred URLs stat in 13 ms where two hundred metadata fetches cost 200 ms. Recents no longer date every candidate either. Spotlight sorts on one attribute, so the service runs one sorted query per stamp and merges their heads; only the first twenty rows of each list can reach the merged one, and only those are dated. The sort attribute has to be named in `MDQueryCreate` — set afterwards through `MDQuerySetSortOrder` it is ignored, which is why the first version had to sort locally. The blank screen also skips the typing debounce, having no next keystroke to coalesce with. Measured on the developer home, release-optimized: recents 41 ms on a repeat against ~550 ms, and the five benchmark queries 54–107 ms against 192–831 ms. Building the expressions moved with the queries into the service, where the policy that shapes them already is; the session now owns only when a search runs. Move to Trash takes ⌃X, the chord the clipboard's delete already uses, and Paste File moves up beside Copy File in the Actions menu. * Let the preview play, in the pane as well as the overlay The overlay's dismissing tap gesture was laid over the whole card, preview included, so the click that should have hit a movie's play button closed the overlay instead — the transport drew, took the press and never saw it. Only the margin around the card dismisses now; anything over the preview belongs to the preview. The pane beside the list shows the file itself for the same reason it was worth having Quick Look at all: a still says nothing a movie or a long document needs said. `QuickLookSurface` moves out of the overlay into its own file and mounts in both, and the pane's copy waits 180 ms for the selection to hold, so arrow-keying a list of two hundred rows opens no preview it is about to drop. The thumbnail stands in until then. The live view is torn down whenever the palette is ordered out, the overlay covers it, or a folder is selected — one key over all three, so no `onChange` races the task, and never two preview extensions running for one file. * Give the preview its own player, 16:9, and an Escape AVKit cannot eat Three things the preview pane still got wrong. A movie never played. `QLPreviewView` draws a first frame and hands out a transport, but inside a non-activating panel it does not play, so movies and audio now go to an `AVPlayerView` of File Search's own. It is a copy of the shape the clipboard's preview uses rather than a share of it: that pane is a different surface with its own sizing and its own lifetime, and forty lines of teardown is the cheaper trade against coupling the two. The stage is 16:9 and sized before the block beneath it. Without the layout priority the aspect ratio fits itself into whatever height the Information rows left over, which is backwards — it shrank the preview to 328×173 instead of filling the pane's width at 436×234. The rows now scroll in what is left, through the plain scroll view the clipboard's preview uses, since a thin scrollbar over a document is chrome on chrome. Escape did not close Quick Look once a player was in it: a focused `AVPlayerView` answers the key window's Escape before SwiftUI's handler ever sees it. `PalettePanel.sendEvent` is the one place ahead of the responder chain, so the panel now owns that press while the overlay is up, and `PaletteEscapeAction` goes back to the shape it had. The Close chip is a button now too, for the pointer. * feat: Add FileSearchFileView for unified file preview handling in search results * feat: Replace FileSearchFileView with FileSearchSurface for unified file handling in search results * Hand the preview another file rather than building it a new one Clicking a row went blank before it went live. The stage gated the whole surface on a flag that flipped false on every selection, so each move dismantled the `QLPreviewView`, closed it, showed `Color.clear`, then built a fresh one — a teardown and a rebuild to show the next file. The surface now outlives the selection: only the settled URL changes, so a move hands the same view another item. The settle drops to 80 ms, which still coalesces a held arrow key and no longer reads as a wait on a click. Measured against the real QuickLook machinery: the first load in a process is ~130 ms and every load after it ~10 ms, so what a click waits for is the settle, not the preview. Text goes back to QuickLook with it. Drawing it here fixed the wrong thing — its scroll view is configured exactly like the palette's own, overlay style and autohiding, and QuickLook renders a document better than a monospaced `Text` in a `ScrollView` does. * Say in the feature doc what the preview surface now does * Answer a click on the press, and drop the timer in front of the preview The second a click took was SwiftUI waiting: `.onTapGesture(count: 2)` cannot deliver the single tap until the system's double-click interval has passed without a second press, so the selection — and the preview that follows it — sat still for that whole window. The clipboard's rows never felt this because they select in `mouseDown`. File Search now does the same, through an `onRowClick` catcher beside the right-click one already in `DesignSystem`: select on the press, open when the press is the second. The settle goes with it, and the state it needed. It was guarding a cost that is not there: measured against the real QuickLook machinery inside a panel shaped like the palette's — borderless, floating, non-activating, never key — handing a live `QLPreviewView` another file paints in about 8 ms, and the first load in a process in about 130 ms. Debouncing 8 ms of work bought nothing and spent 80. What is left is a surface mounted while it should be on screen and unmounted when it should not, which is also the whole of its teardown: no duration, no flag, no task.
203 lines
7.8 KiB
Swift
203 lines
7.8 KiB
Swift
import Foundation
|
|
|
|
actor FileSearchProbe {
|
|
private var active = 0
|
|
private var calls: [String] = []
|
|
private var maximumActive = 0
|
|
private let pausedQuery: String?
|
|
private var pauseEnabled: Bool
|
|
private var pausedContinuation: CheckedContinuation<Void, Never>?
|
|
|
|
init(pausing query: String? = nil) {
|
|
pausedQuery = query
|
|
pauseEnabled = query != nil
|
|
}
|
|
|
|
func search(
|
|
query: String, filter: FileSearchFilter, policy: FileSearchPolicy
|
|
) async -> [FileSearchResult] {
|
|
active += 1
|
|
calls.append(filter == .all ? query : "\(query) [\(filter.title)]")
|
|
maximumActive = max(maximumActive, active)
|
|
if pauseEnabled, query == pausedQuery {
|
|
await withCheckedContinuation { continuation in
|
|
pausedContinuation = continuation
|
|
}
|
|
} else {
|
|
try? await Task.sleep(for: .milliseconds(80))
|
|
}
|
|
active -= 1
|
|
return [
|
|
FileSearchResult(
|
|
url: policy.homeDirectory.appending(path: query), isDirectory: false,
|
|
homeDirectory: policy.homeDirectory)
|
|
]
|
|
}
|
|
|
|
func snapshot() -> (calls: [String], maximumActive: Int) {
|
|
(calls, maximumActive)
|
|
}
|
|
|
|
func waitUntilPaused() async -> Bool {
|
|
let deadline = ContinuousClock.now.advanced(by: .seconds(1))
|
|
while pausedContinuation == nil, ContinuousClock.now < deadline {
|
|
try? await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
guard pausedContinuation != nil else {
|
|
pauseEnabled = false
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func resumePausedSearch() {
|
|
pauseEnabled = false
|
|
pausedContinuation?.resume()
|
|
pausedContinuation = nil
|
|
}
|
|
}
|
|
|
|
@main
|
|
@MainActor
|
|
struct FileSearchSessionTests {
|
|
nonisolated(unsafe) static var failures = 0
|
|
static let home = URL(fileURLWithPath: "/Users/test")
|
|
|
|
static func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
|
|
if !condition() {
|
|
failures += 1
|
|
print("FAIL: \(message)")
|
|
}
|
|
}
|
|
|
|
static func main() async {
|
|
await coalescesDebouncingQueries()
|
|
await serializesRunningQueries()
|
|
await cancellationPreventsPendingWork()
|
|
await policyChangeDiscardsStaleResults()
|
|
await unchangedPolicyKeepsResults()
|
|
await blankQueryLoadsRecents()
|
|
await filterChangeRerunsTheQuery()
|
|
|
|
print(failures == 0 ? "File search session tests passed" : "\(failures) tests failed")
|
|
exit(failures == 0 ? 0 : 1)
|
|
}
|
|
|
|
static func coalescesDebouncingQueries() async {
|
|
let probe = FileSearchProbe()
|
|
let session = makeSession(probe: probe, debounce: .milliseconds(30))
|
|
session.search("annual")
|
|
session.search("annual report")
|
|
await waitUntil { session.state == .ready }
|
|
|
|
let snapshot = await probe.snapshot()
|
|
expect(snapshot.calls == ["annual report"], "the debounce runs only the newest query")
|
|
expect(session.results.first?.name == "annual report", "the newest query publishes")
|
|
}
|
|
|
|
static func serializesRunningQueries() async {
|
|
let probe = FileSearchProbe(pausing: "first")
|
|
let session = makeSession(probe: probe, debounce: .milliseconds(10))
|
|
session.search("first")
|
|
let firstStarted = await probe.waitUntilPaused()
|
|
expect(firstStarted, "the first query starts")
|
|
guard firstStarted else { return }
|
|
session.search("second")
|
|
await probe.resumePausedSearch()
|
|
await waitUntil { session.state == .ready && session.results.first?.name == "second" }
|
|
|
|
let snapshot = await probe.snapshot()
|
|
expect(snapshot.calls == ["first", "second"], "the superseding query still runs")
|
|
expect(snapshot.maximumActive == 1, "Spotlight operations never overlap")
|
|
}
|
|
|
|
static func cancellationPreventsPendingWork() async {
|
|
let probe = FileSearchProbe()
|
|
let session = makeSession(probe: probe, debounce: .milliseconds(30))
|
|
session.search("cancelled")
|
|
session.cancel()
|
|
try? await Task.sleep(for: .milliseconds(60))
|
|
|
|
let snapshot = await probe.snapshot()
|
|
expect(snapshot.calls.isEmpty, "cancelling during the debounce prevents the search")
|
|
expect(session.state == .idle && session.results.isEmpty, "cancellation clears the session")
|
|
}
|
|
|
|
static func policyChangeDiscardsStaleResults() async {
|
|
let probe = FileSearchProbe(pausing: "report")
|
|
let session = makeSession(probe: probe, debounce: .milliseconds(10))
|
|
session.search("report")
|
|
let oldSearchStarted = await probe.waitUntilPaused()
|
|
expect(oldSearchStarted, "the old-policy query starts")
|
|
guard oldSearchStarted else { return }
|
|
session.apply(scopes: FileSearchScope.defaultScopes, ignorePatterns: ["*.log"])
|
|
|
|
expect(
|
|
session.state == .idle && session.results.isEmpty,
|
|
"a result found under the old rules never publishes")
|
|
session.search("report")
|
|
await probe.resumePausedSearch()
|
|
await waitUntil { session.state == .ready && session.results.first?.name == "report" }
|
|
let snapshot = await probe.snapshot()
|
|
expect(snapshot.calls == ["report", "report"], "the same query re-runs under the new rules")
|
|
}
|
|
|
|
static func unchangedPolicyKeepsResults() async {
|
|
let probe = FileSearchProbe()
|
|
let session = makeSession(probe: probe, debounce: .milliseconds(10))
|
|
session.search("report")
|
|
await waitUntil { session.state == .ready }
|
|
session.apply(scopes: FileSearchScope.defaultScopes, ignorePatterns: [])
|
|
|
|
expect(
|
|
session.state == .ready && session.results.first?.name == "report",
|
|
"re-applying identical settings leaves the published results alone")
|
|
}
|
|
|
|
static func blankQueryLoadsRecents() async {
|
|
let probe = FileSearchProbe()
|
|
let session = makeSession(probe: probe, debounce: .milliseconds(10))
|
|
session.search("")
|
|
await waitUntil { session.state == .ready }
|
|
session.search("report")
|
|
await waitUntil { session.state == .ready && session.results.first?.name == "report" }
|
|
|
|
let snapshot = await probe.snapshot()
|
|
expect(
|
|
snapshot.calls == ["", "report"],
|
|
"the blank screen is a request of its own, not the absence of one")
|
|
}
|
|
|
|
static func filterChangeRerunsTheQuery() async {
|
|
let probe = FileSearchProbe()
|
|
let session = makeSession(probe: probe, debounce: .milliseconds(10))
|
|
session.search("report")
|
|
await waitUntil { session.state == .ready }
|
|
session.search("report", filter: .images)
|
|
await waitUntil { session.state == .ready }
|
|
session.search("report", filter: .images)
|
|
try? await Task.sleep(for: .milliseconds(40))
|
|
|
|
let snapshot = await probe.snapshot()
|
|
expect(
|
|
snapshot.calls == ["report", "report [Images]"],
|
|
"narrowing the filter re-runs the same words, and re-stating it runs nothing")
|
|
}
|
|
|
|
static func makeSession(probe: FileSearchProbe, debounce: Duration) -> FileSearchSession {
|
|
let policy = FileSearchPolicy(
|
|
scopes: FileSearchScope.defaultScopes, ignorePatterns: [], homeDirectory: home)
|
|
return FileSearchSession(policy: policy, debounce: debounce) { query, filter, policy in
|
|
await probe.search(query: query, filter: filter, policy: policy)
|
|
}
|
|
}
|
|
|
|
static func waitUntil(_ condition: @escaping @MainActor () -> Bool) async {
|
|
let deadline = ContinuousClock.now.advanced(by: .seconds(1))
|
|
while !condition(), ContinuousClock.now < deadline {
|
|
try? await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
expect(condition(), "the async operation completed before the timeout")
|
|
}
|
|
}
|