diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 192dadb8..18e80f5b 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index e16b0053..1f03dd54 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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 diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 0541a81a..03c0af4a 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -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. diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index d1b8eb1e..46bb40d3 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -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 } diff --git a/TFM/App/TermindCommands.swift b/TFM/App/TermindCommands.swift index e071d595..28f373c2 100644 --- a/TFM/App/TermindCommands.swift +++ b/TFM/App/TermindCommands.swift @@ -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]) diff --git a/TFM/DesignSystem/MenuSymbol.swift b/TFM/DesignSystem/MenuSymbol.swift index c9fa8403..787616f4 100644 --- a/TFM/DesignSystem/MenuSymbol.swift +++ b/TFM/DesignSystem/MenuSymbol.swift @@ -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, diff --git a/TFM/Features/HostChooser/HostChooserView.swift b/TFM/Features/HostChooser/HostChooserView.swift index d6209e5b..8a9b449c 100644 --- a/TFM/Features/HostChooser/HostChooserView.swift +++ b/TFM/Features/HostChooser/HostChooserView.swift @@ -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 diff --git a/TFM/Features/QuickOpen/QuickOpenView.swift b/TFM/Features/QuickOpen/QuickOpenView.swift index 2cf9c13d..8d0d7739 100644 --- a/TFM/Features/QuickOpen/QuickOpenView.swift +++ b/TFM/Features/QuickOpen/QuickOpenView.swift @@ -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 diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index cdd71416..3e20f01e 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -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` diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index e1e41478..b87d576d 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -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: diff --git a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift index ee96d465..934da2f8 100644 --- a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift @@ -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) } diff --git a/TFMTests/BroadcastInputTests.swift b/TFMTests/BroadcastInputTests.swift index fc49c412..fbabe262 100644 --- a/TFMTests/BroadcastInputTests.swift +++ b/TFMTests/BroadcastInputTests.swift @@ -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]), diff --git a/TFMTests/ChooserInteractionTests.swift b/TFMTests/ChooserInteractionTests.swift new file mode 100644 index 00000000..493f11dc --- /dev/null +++ b/TFMTests/ChooserInteractionTests.swift @@ -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() + 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 +} diff --git a/TFMTests/ManagedChooserTests.swift b/TFMTests/ManagedChooserTests.swift index 2d588917..e8c6528d 100644 --- a/TFMTests/ManagedChooserTests.swift +++ b/TFMTests/ManagedChooserTests.swift @@ -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)