Fix Quick Open & Add Batch Startup Shortcut
This commit is contained in:
1 parent
9ef2c9c8cc
commit
c283201b33
14 files changed
+397
-127
No files matched your search
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user