Files
tinycast/Tests/file-search-session-test.swift
Abue Ammar 5d8dbb77ab Give Search Files a preview pane, a type filter and an Actions menu worth opening (#612)
* 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.
2026-09-12 18:35:01 +06:00

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")
}
}