Fix Quick Open & Add Batch Startup Shortcut

This commit is contained in:
Kaede Akino committed 2026-09-19 11:42:59 +08:00
1 parent 9ef2c9c8cc
commit c283201b33
14 files changed
+397 -127

No files matched your search

+1 -48
View File
@@ -5781,22 +5781,6 @@
}
}
},
"Connect in the same workspace" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在同一工作区中连接"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "在同一工作區中連線"
}
}
}
},
"Connect MCP tools and run the built-in MCP Server" : {
"comment" : "A feature of Termind Pro.",
"isCommentAutoGenerated" : true,
@@ -8265,6 +8249,7 @@
},
"Disable" : {
"comment" : "A button that disables a forwarding rule.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
@@ -16573,22 +16558,6 @@
}
}
},
"Open %lld hosts as panes in one workspace." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在一个工作区中以窗格打开%lld台主机。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "在一個工作區中以窗格開啟%lld台主機。"
}
}
}
},
"Open a page, chat, workspace, host, or command" : {
"localizations" : {
"zh-Hans" : {
@@ -21430,22 +21399,6 @@
}
}
},
"Select at least two hosts. A Workspace can contain one or more hosts." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "请至少选择两台主机。一个工作区可以包含一台或多台主机。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "請至少選擇兩台主機。一個工作區可以包含一台或多台主機。"
}
}
}
},
"Select Devices" : {
"localizations" : {
"zh-Hans" : {
+4
View File
@@ -56,6 +56,10 @@ the latest record counts as unchanged.
- Repository snapshots/preferences are app-wide; shells, quick-open, search, navigation and context
panels are window-owned. Feature Application models expose narrow intents over the shared Vault
snapshot. `VaultStore` owns transactions; sessions receive only `RemoteSessionHostStore`.
- Quick Open claims search focus after mounting in its own active native window; closing retires
pending focus requests. Its presentation releases toolbar Find and suspends terminal autofocus.
Batch Startup's single Connect action opens selected SSH/SFTP Hosts as panes in one new Workspace;
other Host menus retain their explicit layout choices.
- Each desktop scene owns a distinct `WindowModel`. The process coordinator routes UUID-scoped
requests and weakly tracks native windows. New windows start at Welcome; Dock reopening preserves
the destination, Metrics entry selects Metrics. Dock New Window uses the same factory, activates
+8
View File
@@ -486,3 +486,11 @@ Formatting/localization/diff passed; full pinned macOS/iOS pending. Evidence:
environment; production already has no idiom/model layout branches. Remove both reads and consume one shared toolbar
policy, retaining container geometry/active reserved regions/native identity/OS API checks. Estimate −2 production
lines/two duplicate conditions. Native title/menu transitions and fold/session lifetime verification remain pending.
## Batch Startup confirmation, 2026-09-19 — implemented, verification pending
`rg 'showsWorkspaceChoice|workspaceConfirmLabel|workspaceHelp|openChecked' TFM TFMTests` confirmed one chooser-only
secondary action and layout parameter. Keep Connect/Return as the single confirmation, explicitly opening one new
Workspace for one or more selected SSH/SFTP Hosts. Remove the redundant button/policy/help and three unused localized
entries; retain shared BatchLayout for Host context menus. Estimate −45–60 chooser lines; actual −53 including stable
accessibility identifiers for behavioral UI coverage. Focus/menu changes are tracked by the same regression run.
+3 -4
View File
@@ -720,7 +720,7 @@ final class AppModel {
connectionHistory.mostRecentDate(for: hostID)
}
/// Batch Startup, in either of its two shapes.
/// Opens multiple Hosts, with the layout chosen by the calling surface.
///
/// The workspaces path is a loop over the singular `open` rather than its own code
/// path; the Workspace path cannot be, since the whole point is that it makes
@@ -793,9 +793,8 @@ final class AppModel {
/// same sheet again, in its multi-select
/// mode — see `HostChooserPurpose`.
///
/// The sidebar and Welcome page raise this chooser. It is not in the File
/// menu or the workspace bar's `+`: bringing up a dozen machines is a deliberate
/// act, kept on surfaces that name Batch Startup directly.
/// The sidebar, Welcome page and File menu raise this chooser. Confirmation
/// opens the selected Hosts as panes of one new Workspace.
func batchStartup(in window: WindowModel) {
window.hostChooser = .batchStartup
}
+6 -9
View File
@@ -63,22 +63,19 @@ struct TermindCommands: Commands {
Divider()
// Both of these act on the host selected on the Hosts page, and fall
// back to that page when there is none — see `AppModel.newWorkspace`.
//
// Ordered by how often they are reached, not alphabetically: a
// session, then a transfer to the same kind of machine, then a chat
// that is attached to neither.
Button("Connect", systemImage: MenuSymbol.ssh) {
guard let window else { return }
app.connect(in: window)
}
.termindShortcut("t")
Button("Batch Startup", systemImage: MenuSymbol.batchStartup) {
guard let window else { return }
app.batchStartup(in: window)
}
.termindShortcut("b", modifiers: [.command, .shift])
.disabled(window == nil)
Button("New Chat", systemImage: MenuSymbol.newChat) { window?.openNewAssistantChat() }
.termindShortcut("t", modifiers: [.command, .option])
Divider()
Button("New Host", systemImage: MenuSymbol.newHost) { window?.openHostEditor(host: nil) }
.termindShortcut("n", modifiers: [.command, .shift])
+2 -1
View File
@@ -31,6 +31,7 @@ nonisolated enum MenuSymbol {
static let quickConnect = "bolt"
static let connect = "network"
static let batchStartup = "wind"
static let ssh = "apple.terminal"
static let sftp = "folder"
@@ -119,7 +120,7 @@ nonisolated enum MenuSymbol {
/// is exactly the kind of mistake no one notices in review.
static let all: [String] = [
settings, quickOpen, find, actions, support, showAssistant,
quickConnect, connect, ssh, sftp,
quickConnect, connect, batchStartup, ssh, sftp,
newChat, newHost, newGroup, newPackage, newFolder, newSnippet, saveAsSnippet,
newIdentity, generateKey, generateCommand,
importItem, download, export, upload,
+6 -56
View File
@@ -6,17 +6,6 @@ import TermindPresentation
import struct TermindDomain.Host
/// Which verb the chooser was raised for.
///
/// One sheet, four verbs. "Which machine?" is the same question whether the
/// answer opens a terminal or a file browser, and whether it wants one machine
/// or a dozen; the list, the search, the keyboard handling, and the row are the
/// same work in every case — so the difference is carried as data rather than as
/// a second view.
///
/// The two batch cases both title themselves "Batch Startup", after the Rayon
/// row they came from. What tells them apart is the confirm button, which is the
/// last thing read before anything happens anyway.
nonisolated struct SnippetHostChooserRequest: Hashable, Sendable {
enum Strategy: Hashable, Sendable {
case fillMissingTargets
@@ -71,7 +60,7 @@ nonisolated enum HostChooserPurpose: Hashable, Sendable, Identifiable {
var symbol: String {
switch self {
case .connect: "network"
case .batchStartup: "wind"
case .batchStartup: MenuSymbol.batchStartup
case .metrics: "waveform.path.ecg"
case .snippet: "text.badge.play"
}
@@ -87,17 +76,6 @@ nonisolated enum HostChooserPurpose: Hashable, Sendable, Identifiable {
}
}
/// The batch sheet's second verb. Spelled out the same way as
/// `confirmLabel`: the button is the last thing read before something
/// connects, so it says which thing *and* where it lands.
var workspaceConfirmLabel: LocalizedStringKey {
switch self {
case .connect, .batchStartup: "Connect in the same workspace"
case .metrics: "Add Server"
case .snippet: "Execute"
}
}
/// Whether the sheet ticks boxes instead of picking one row. The only thing
/// the view branches on.
var allowsMultiple: Bool {
@@ -116,13 +94,6 @@ nonisolated enum HostChooserPurpose: Hashable, Sendable, Identifiable {
}
}
var showsWorkspaceChoice: Bool {
switch self {
case .batchStartup: true
case .connect, .snippet, .metrics: false
}
}
var choosesConnectionProtocol: Bool {
switch self {
case .connect, .batchStartup: true
@@ -333,6 +304,7 @@ struct HostChooserView: View {
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("host-chooser.host.\(host.id)")
.id(host.id)
.listRowBackground(
chooser.cursor == host.id
@@ -441,16 +413,12 @@ struct HostChooserView: View {
Button("Cancel", action: close)
.keyboardShortcut(.cancelAction)
if purpose.showsWorkspaceChoice {
Button(purpose.workspaceConfirmLabel) { openChecked(as: .oneWorkspace) }
.disabled(chooser.selected.count < 2)
.help(workspaceHelp)
}
.accessibilityIdentifier("host-chooser.cancel")
Button(purpose.confirmLabel, action: openSelected)
.keyboardShortcut(.defaultAction)
.disabled(connectIsDisabled)
.accessibilityIdentifier("host-chooser.confirm")
}
.padding(Metrics.Space.l)
}
@@ -512,7 +480,7 @@ struct HostChooserView: View {
dismiss()
}
private func openChecked(as layout: BatchLayout = .separateWorkspaces) {
private func openChecked() {
let batch = app.hostLibrary.searchHosts("").filter { chooser.selected.contains($0.id) }
guard !batch.isEmpty else { return }
if case .snippet = purpose {
@@ -524,7 +492,7 @@ struct HostChooserView: View {
dismiss()
} else {
guard let target = connectionProtocol.target else { return }
app.open(batch, as: target, layout: layout, in: window)
app.open(batch, as: target, layout: .oneWorkspace, in: window)
dismiss()
}
}
@@ -586,24 +554,6 @@ struct HostChooserView: View {
}
}
private var workspaceHelp: Text {
if chooser.selected.count < 2 {
Text(
AppLocalization.string(
"Select at least two hosts. A Workspace can contain one or more hosts.",
locale: locale
)
)
} else {
Text(
AppLocalization.string(
"Open \(chooser.selected.count) hosts as panes in one workspace.",
locale: locale
)
)
}
}
private var selectAllLabel: String {
AppLocalization.string(
allResultsChecked ? "Deselect All" : "Select All", locale: locale
+61 -1
View File
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI
import TermindPresentation
@@ -81,9 +82,11 @@ struct QuickOpenView: View {
}
.frame(maxWidth: Metrics.QuickOpen.width, maxHeight: Metrics.QuickOpen.height)
.onAppear {
searchFocused = true
selectedID = results.commands.first?.id
}
.onChange(of: window.isQuickOpenPresented) {
if !window.isQuickOpenPresented { searchFocused = false }
}
.onChange(of: query) {
selectedID = results.commands.first?.id
}
@@ -102,6 +105,12 @@ struct QuickOpenView: View {
.textFieldStyle(.plain)
.font(.system(size: 17, weight: .medium))
.focused($searchFocused)
.background {
QuickOpenFocusMount(isPresented: window.isQuickOpenPresented) {
guard window.isQuickOpenPresented else { return }
searchFocused = true
}
}
.onSubmit(performSelection)
.onKeyPress(.upArrow) { moveSelection(by: -1) }
.onKeyPress(.downArrow) { moveSelection(by: 1) }
@@ -286,6 +295,57 @@ struct QuickOpenView: View {
}
}
/// SwiftUI can run `onAppear` before the search field joins its window. Request
/// focus after AppKit mounts it, and retire that request even during a closing transition.
private struct QuickOpenFocusMount: NSViewRepresentable {
let isPresented: Bool
let focus: () -> Void
func makeNSView(context: Context) -> MountView {
let view = MountView()
view.focus = focus
view.isPresented = isPresented
return view
}
func updateNSView(_ view: MountView, context: Context) {
view.focus = focus
view.isPresented = isPresented
}
static func dismantleNSView(_ view: MountView, coordinator: ()) {
view.isPresented = false
view.focus = nil
}
final class MountView: NSView {
var focus: (() -> Void)?
var isPresented = false {
didSet {
if isPresented != oldValue { scheduleFocus() }
}
}
private var generation: UInt64 = 0
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
scheduleFocus()
}
private func scheduleFocus() {
generation &+= 1
guard isPresented, let window else { return }
let request = generation
DispatchQueue.main.async { [weak self, weak window] in
guard let self, let window, self.window === window,
isPresented, generation == request, window.isKeyWindow
else { return }
focus?()
}
}
}
}
private struct QuickOpenShortcutHint: View {
let keys: String
let label: String
+5 -3
View File
@@ -284,7 +284,10 @@ final class WindowModel {
var isQuickOpenPresented: Bool {
get { presentations.isQuickOpenPresented }
set { presentations.isQuickOpenPresented = newValue }
set {
presentations.isQuickOpenPresented = newValue
if newValue { isToolbarFindFocused = false }
}
}
/// Closed workspaces, most recent last. ⇧⌘T pops from here. The Workspace retains
@@ -861,8 +864,7 @@ final class WindowModel {
return open(Workspace(pane: pane))
}
/// Batch Startup's second verb: every host becomes a pane of one Workspace,
/// tiled into a grid rather than opened as a workspace each.
/// Batch Startup opens every host as a pane of one new Workspace, tiled into a grid.
///
/// Returns nil for a target that cannot be a Workspace pane — only SSH and
/// SFTP sessions can, which is the same rule `PaneKind.isCombinable`
@@ -650,7 +650,7 @@ private struct PaneView: View {
sshSession: sshSession,
workspace: workspace,
isVisible: isActive,
requestsFocus: isActive && isFocused && !window.isToolbarFindFocused,
requestsFocus: isActive && isFocused && !window.isToolbarFindFocused && !window.isQuickOpenPresented,
externalFileDrop: localFileDropRequest,
closePane: { window.closePane(pane.id) }
)
@@ -667,7 +667,7 @@ private struct PaneView: View {
SerialTerminalView(
session: serialSession,
isVisible: isActive,
requestsFocus: isActive && isFocused && !window.isToolbarFindFocused,
requestsFocus: isActive && isFocused && !window.isToolbarFindFocused && !window.isQuickOpenPresented,
closePane: { window.closePane(pane.id) }
)
case .empty:
@@ -193,7 +193,7 @@ struct WorkspaceNavigatorView: View {
// The same container as the destinations above it. Drawn by
// hand it sat 8pt further left than every other row and never
// lit up under the pointer.
SidebarNavigatorRow(title: "Batch Startup", symbol: "wind") {
SidebarNavigatorRow(title: "Batch Startup", symbol: MenuSymbol.batchStartup) {
workspaceExpanded = true
app.batchStartup(in: window)
}
+2
View File
@@ -392,6 +392,8 @@ struct BroadcastInputTests {
AppShortcutRegistration("n"),
AppShortcutRegistration("t"),
AppShortcutRegistration("t", modifiers: [.command, .shift]),
AppShortcutRegistration("p"),
AppShortcutRegistration("b", modifiers: [.command, .shift]),
AppShortcutRegistration("w"),
AppShortcutRegistration("w", modifiers: [.command, .shift]),
AppShortcutRegistration("d", modifiers: [.command, .shift]),
+296
View File
@@ -0,0 +1,296 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite(.serialized) @MainActor
struct ChooserInteractionTests {
@Test(arguments: ["toolbar", "ssh", "serial"])
func quickOpenTakesFocusAndHandlesTypingNavigationAndReopening(_ source: String) async throws {
let fixture = ChooserFixture()
defer { fixture.close() }
var workspace: Workspace?
if source == "ssh" {
let session = SSHSessionModel(host: fixture.hosts[0])
session.status = .connected
workspace = fixture.model.open(Workspace(pane: Pane(.sshSession(session))))
} else if source == "serial" {
let session = SerialSessionModel(
configuration: SerialConnectionConfiguration(devicePath: "/dev/termind-test"),
service: fixture.app.serialPorts)
session.status = .connected
workspace = fixture.model.open(Workspace(pane: Pane(.serialSession(session))))
} else {
fixture.model.openPage(.hosts)
}
fixture.mount(QuickOpenHarness(workspace: workspace))
try await fixture.settle()
if source == "toolbar" {
fixture.model.focusToolbarFind()
try await fixture.settle()
#expect(fixture.model.isToolbarFindFocused)
}
fixture.window.makeKey()
fixture.model.isQuickOpenPresented = true
let field = try await fixture.quickOpenField()
#expect(!fixture.model.isToolbarFindFocused)
try fixture.type("New ", into: field)
try await fixture.settle()
#expect(field.stringValue == "New ")
#expect(fixture.window.firstResponder === field.currentEditor())
// New Window → New Chat → New Window → New Chat, then execute.
try fixture.key(String(UnicodeScalar(NSDownArrowFunctionKey)!), code: 125)
try fixture.key(String(UnicodeScalar(NSUpArrowFunctionKey)!), code: 126)
try fixture.key(String(UnicodeScalar(NSDownArrowFunctionKey)!), code: 125)
try fixture.key("\r", code: 36)
try await fixture.settle()
#expect(!fixture.model.isQuickOpenPresented)
#expect(fixture.model.selectedDestination == .assistant)
try await fixture.waitUntilQuickOpenCloses()
fixture.window.makeKey()
fixture.model.isQuickOpenPresented = true
let reopened = try await fixture.quickOpenField()
#expect(reopened.stringValue.isEmpty)
try fixture.key("\u{1B}", code: 53)
try await fixture.settle()
#expect(!fixture.model.isQuickOpenPresented)
}
@Test
func closingBeforeFocusDeliveryAndOpeningInAnotherWindowDoNotStealTheCaret() async throws {
let first = ChooserFixture()
let second = ChooserFixture()
defer {
second.close()
first.close()
}
first.model.openPage(.hosts)
first.mount(QuickOpenHarness(workspace: nil))
try await first.settle()
first.model.isQuickOpenPresented = true
first.window.contentView?.layoutSubtreeIfNeeded()
first.model.isQuickOpenPresented = false
try await first.settle()
first.model.focusToolbarFind()
try await first.settle()
let original = try #require(first.window.firstResponder)
try await first.settle()
#expect(first.window.firstResponder === original)
second.model.openPage(.hosts)
second.mount(QuickOpenHarness(workspace: nil))
try await second.settle()
second.model.focusToolbarFind()
try await second.settle()
let responder = try #require(second.window.firstResponder)
first.model.isQuickOpenPresented = true
try await second.settle()
#expect(second.window.isKeyWindow)
#expect(second.window.firstResponder === responder)
#expect(!second.model.isQuickOpenPresented)
}
@Test(arguments: [HostOpenTarget.sshSession, .sftpSession], [1, 3])
func batchConnectAndReturnCreateExactlyOneWorkspace(target: HostOpenTarget, count: Int) async throws {
for useReturn in [false, true] {
let fixture = ChooserFixture()
defer { fixture.close() }
fixture.mount(HostChooserView(purpose: .batchStartup))
try await fixture.settle()
let confirm = try fixture.element("host-chooser.confirm")
#expect(confirm.isAccessibilityEnabled?() == false)
#expect(fixture.model.workspaces.isEmpty)
if target == .sftpSession {
let picker = try #require(fixture.views.compactMap { $0 as? NSSegmentedControl }.first)
picker.selectedSegment = 1
#expect(picker.sendAction(picker.action, to: picker.target))
}
// Select in reverse order; workspace panes still use the library's order.
for host in fixture.hosts.prefix(count).reversed() {
#expect(try fixture.element("host-chooser.host.\(host.id)").accessibilityPerformPress?() == true)
try await fixture.settle()
}
#expect(fixture.model.workspaces.isEmpty)
#expect(try fixture.element("host-chooser.confirm").isAccessibilityEnabled?() == true)
if useReturn {
try fixture.key("\r", code: 36)
} else {
#expect(try fixture.element("host-chooser.confirm").accessibilityPerformPress?() == true)
}
try await fixture.settle()
#expect(fixture.model.workspaces.count == 1)
let workspace = try #require(fixture.model.selectedWorkspace)
#expect(workspace.panes.map(\.hostID) == fixture.hosts.prefix(count).map(\.id))
#expect(workspace.focusedPaneID == workspace.panes.first?.id)
#expect(workspace.panes.allSatisfy { $0.kind == (target == .sshSession ? .sshSession : .sftpSession) })
}
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func batchHasOneConnectActionAndCancelCreatesNoSessions(_ language: String) async throws {
let fixture = ChooserFixture()
defer { fixture.close() }
fixture.mount(HostChooserView(purpose: .batchStartup).environment(\.locale, Locale(identifier: language)))
try await fixture.settle()
#expect(fixture.elements.filter { $0.accessibilityIdentifier?() == "host-chooser.confirm" }.count == 1)
#expect(try fixture.element("host-chooser.host.\(fixture.hosts[0].id)").accessibilityPerformPress?() == true)
try await fixture.settle()
let view = try #require(fixture.window.contentView)
view.layoutSubtreeIfNeeded()
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
let image = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(image, named: "batch-startup-\(language).png")
try image.write(
to: FileManager.default.temporaryDirectory.appending(path: "termind-batch-startup-\(language).png"))
#expect(try fixture.element("host-chooser.cancel").accessibilityPerformPress?() == true)
try await fixture.settle()
#expect(fixture.model.workspaces.isEmpty)
}
}
private struct QuickOpenHarness: View {
let workspace: Workspace?
@Environment(WindowModel.self) private var model
var body: some View {
Group {
if let workspace {
WorkspaceContentView(workspace: workspace, isActive: model.selectedWorkspaceID == workspace.id)
} else {
VStack {
WindowToolbarFindField()
Spacer()
}
}
}
.frame(width: 800, height: 600)
.overlay {
if model.isQuickOpenPresented {
QuickOpenOverlay { model.isQuickOpenPresented = false }
}
}
}
}
@MainActor
private final class ChooserFixture {
let hosts: [Host]
let app: AppModel
let model = WindowModel()
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled], backing: .buffered, defer: false)
init() {
hosts = ["Alpha", "Bravo", "Charlie"].enumerated().map { index, name in
Host(name: name, host: "\(name.lowercased()).invalid", sortIndex: Double(index))
}
app = AppModel(hosts: HostLibraryModel(hosts: hosts))
window.isReleasedWhenClosed = false
window.animationBehavior = .none
}
func mount(_ view: some View) {
let controller = NSHostingController(rootView: view.environment(app).environment(model))
controller.sizingOptions = []
window.contentViewController = controller
controller.view.frame = window.contentLayoutRect
controller.view.layoutSubtreeIfNeeded()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
var views: [NSView] {
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
return window.contentView.map(descendants) ?? []
}
var elements: [AnyObject] {
var seen = Set<ObjectIdentifier>()
func descendants(_ element: AnyObject) -> [AnyObject] {
guard seen.insert(ObjectIdentifier(element)).inserted else { return [] }
return [element]
+ (element.accessibilityChildren?() ?? []).map { $0 as AnyObject }
.flatMap(descendants)
}
return views.flatMap { descendants($0) }
}
func element(_ identifier: String) throws -> AnyObject {
if !elements.contains(where: { $0.accessibilityIdentifier?() == identifier }) {
print(
"CHOOSER VIEWS",
views.map {
"\(type(of: $0)): \(($0 as? NSButton)?.title ?? "") \($0.accessibilityIdentifier() ?? "")"
})
print(
"CHOOSER AX",
elements.map {
"\(type(of: $0)): \($0.accessibilityLabel?() ?? "") \($0.accessibilityIdentifier?() ?? "")"
})
}
return try #require(elements.first { $0.accessibilityIdentifier?() == identifier }, "Missing \(identifier)")
}
func waitUntilQuickOpenCloses() async throws {
for _ in 0..<100 {
if !views.contains(where: { ($0 as? NSTextField)?.isEditable == true && !($0 is NSSearchField) }) {
return
}
try await Task.sleep(for: .milliseconds(10))
}
throw ChooserTestError.searchDidNotClose
}
func quickOpenField() async throws -> NSTextField {
for _ in 0..<100 {
if let field = views.compactMap({ $0 as? NSTextField }).first(where: {
$0.isEditable && !($0 is NSSearchField) && $0.currentEditor() === window.firstResponder
}) {
return field
}
try await Task.sleep(for: .milliseconds(10))
}
throw ChooserTestError.searchDidNotTakeFocus
}
func type(_ text: String, into field: NSTextField) throws {
let editor = try #require(field.currentEditor() as? NSTextView)
editor.insertText(text, replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
}
func key(_ characters: String, code: UInt16) throws {
let event = try #require(
NSEvent.keyEvent(
with: .keyDown, location: .zero, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil, characters: characters,
charactersIgnoringModifiers: characters, isARepeat: false, keyCode: code))
NSApp.sendEvent(event)
}
func settle() async throws { try await Task.sleep(for: .milliseconds(100)) }
func close() {
window.orderOut(nil)
window.contentViewController = nil
window.close()
for workspace in model.workspaces {
for pane in workspace.panes { pane.disconnectConnection() }
}
app.shutdownForTermination()
}
}
private enum ChooserTestError: Error {
case searchDidNotTakeFocus
case searchDidNotClose
}
-2
View File
@@ -78,12 +78,10 @@ struct ManagedChooserTests {
#expect(HostChooserPurpose.connect.choosesConnectionProtocol)
#expect(!HostChooserPurpose.connect.allowsMultiple)
#expect(HostChooserPurpose.connect.activatesHostImmediately)
#expect(!HostChooserPurpose.connect.showsWorkspaceChoice)
#expect(HostChooserPurpose.batchStartup.choosesConnectionProtocol)
#expect(HostChooserPurpose.batchStartup.allowsMultiple)
#expect(!HostChooserPurpose.batchStartup.activatesHostImmediately)
#expect(HostChooserPurpose.batchStartup.showsWorkspaceChoice)
#expect(HostChooserPurpose.metrics.allowsMultiple)
#expect(!HostChooserPurpose.metrics.activatesHostImmediately)