Redesign Quick Open command palette

This commit is contained in:
Kaede Akino committed 2026-08-29 10:50:45 +08:00
1 parent 2c8e02a137
commit 4c718a2c81
8 files changed
+541 -87

No files matched your search

+14 -6
View File
@@ -73,14 +73,22 @@ struct RootView: View {
WindowToolbarFindField()
}
}
.sheet(item: $presentations.sheet) { route in
switch route {
case .hostChooser(let purpose):
HostChooserView(purpose: purpose)
case .quickOpen:
QuickOpenView()
.sheet(item: $presentations.hostChooser) { purpose in
HostChooserView(purpose: purpose)
}
.overlay {
if presentations.isQuickOpenPresented {
QuickOpenOverlay {
presentations.isQuickOpenPresented = false
}
.transition(
reduceMotion
? .opacity
: .opacity.combined(with: .scale(scale: 0.985))
)
}
}
.animation(Motion.panel(reduceMotion: reduceMotion), value: presentations.route?.id)
.alert(
"Conversation Operation Failed",
isPresented: Binding(
@@ -1,5 +1,23 @@
import Foundation
nonisolated enum AppCommandSection: String, CaseIterable, Hashable, Sendable, Identifiable {
case navigation
case workspaces
case hosts
case actions
nonisolated var id: Self { self }
var title: String {
switch self {
case .navigation: String(localized: "Navigation")
case .workspaces: String(localized: "Workspaces")
case .hosts: String(localized: "Hosts")
case .actions: String(localized: "Actions")
}
}
}
nonisolated enum AppCommandTarget: Hashable, Sendable {
case vaultPage(VaultPage)
case vault
@@ -17,6 +35,7 @@ nonisolated struct AppCommandDescriptor: Identifiable, Hashable, Sendable {
let title: String
let subtitle: String?
let symbol: String
let section: AppCommandSection
let keywords: [String]
let target: AppCommandTarget
@@ -72,16 +91,19 @@ final class AppCommandRegistry {
AppCommandDescriptor(
id: "fixed.vault", title: String(localized: "Vault"),
subtitle: String(localized: "Navigator"), symbol: "lock.square.stack",
section: .navigation,
keywords: ["hosts", "keys", "snippets"], target: .vault
),
AppCommandDescriptor(
id: "fixed.assistant", title: String(localized: "Assistant"),
subtitle: String(localized: "Chats"), symbol: "sparkles",
section: .navigation,
keywords: ["chat", "conversations"], target: .assistant
),
AppCommandDescriptor(
id: "fixed.settings", title: String(localized: "Settings"),
subtitle: String(localized: "Window"), symbol: "gearshape",
section: .navigation,
keywords: ["preferences"], target: .settings
),
]
@@ -94,6 +116,7 @@ final class AppCommandRegistry {
title: page.title,
subtitle: String(localized: "Vault"),
symbol: page.symbol,
section: .navigation,
keywords: [page.rawValue],
target: .vaultPage(page)
)
@@ -108,6 +131,7 @@ final class AppCommandRegistry {
title: workspace.title(in: store),
subtitle: String(localized: "Open Workspace"),
symbol: workspace.symbol,
section: .workspaces,
keywords: workspace.panes.map { $0.sidebarTitle(in: store) },
target: .workspace(workspace.id)
)
@@ -121,6 +145,7 @@ final class AppCommandRegistry {
title: host.name,
subtitle: String(localized: "Connect with SSH · \(host.displayAddress)"),
symbol: "apple.terminal",
section: .hosts,
keywords: keywords + ["ssh"],
target: .connectHost(host.id, .sshSession)
),
@@ -129,6 +154,7 @@ final class AppCommandRegistry {
title: host.name,
subtitle: String(localized: "Browse with SFTP · \(host.displayAddress)"),
symbol: "folder",
section: .hosts,
keywords: keywords + ["sftp", "files"],
target: .connectHost(host.id, .sftpSession)
),
@@ -140,11 +166,13 @@ final class AppCommandRegistry {
AppCommandDescriptor(
id: "action.new-chat", title: String(localized: "New Chat"),
subtitle: String(localized: "Action"), symbol: "plus.bubble",
section: .actions,
keywords: ["assistant", "conversation"], target: .newChat
),
AppCommandDescriptor(
id: "action.new-host", title: String(localized: "New Host"),
subtitle: String(localized: "Action"), symbol: "plus.rectangle",
section: .actions,
keywords: ["server", "add"], target: .newHost
),
]
@@ -152,6 +180,7 @@ final class AppCommandRegistry {
commands.append(AppCommandDescriptor(
id: "action.show-assistant", title: String(localized: "Show Assistant in Sidebar"),
subtitle: String(localized: "Action"), symbol: "sidebar.left",
section: .actions,
keywords: ["sidebar", "assistant", "context", "details"], target: .showAssistant
))
}
+16
View File
@@ -121,6 +121,22 @@ nonisolated enum Metrics {
static let height: CGFloat = 520
}
/// The window-level command palette raised by Command-P.
///
/// It is deliberately wider than a chooser: command subtitles carry a
/// destination or connection address, while the short fixed height keeps
/// the window behind it recognisable as the context the command acts on.
enum QuickOpen {
static let width: CGFloat = 640
static let height: CGFloat = 480
static let cornerRadius: CGFloat = 18
static let searchHeight: CGFloat = 60
static let sectionHeight: CGFloat = 28
static let rowHeight: CGFloat = 48
static let footerHeight: CGFloat = 36
static let iconSize: CGFloat = 28
}
/// Settings is a page in the window rather than its own panel, so it needs a
/// reading width of its own — a `Form` stretched across a 1600pt display puts
/// the label and its control at opposite ends of the desk.
+337 -71
View File
@@ -2,97 +2,262 @@ import AppKit
import ListViewKit
import SwiftUI
/// The window-level presentation for Command-P.
///
/// The dimming layer owns dismissal while the glass surface intercepts clicks
/// inside its bounds. Keeping this out of `RootView` leaves the window shell in
/// charge of *when* the palette is visible and this view in charge of how it
/// behaves while it is visible.
struct QuickOpenOverlay: View {
let dismiss: () -> Void
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
var body: some View {
ZStack {
Color.black.opacity(colorScheme == .dark ? 0.18 : 0.10)
.ignoresSafeArea()
.contentShape(.rect)
.onTapGesture(perform: close)
QuickOpenView(dismiss: close)
.clipShape(.rect(cornerRadius: Metrics.QuickOpen.cornerRadius))
.termindGlassSurface(cornerRadius: Metrics.QuickOpen.cornerRadius)
.overlay {
RoundedRectangle(cornerRadius: Metrics.QuickOpen.cornerRadius)
.strokeBorder(Color.primary.opacity(0.10), lineWidth: 1)
.allowsHitTesting(false)
}
.shadow(
color: .black.opacity(colorScheme == .dark ? 0.34 : 0.20),
radius: 28,
y: 12
)
.contentShape(.rect)
}
.onExitCommand(perform: close)
.accessibilityElement(children: .contain)
.accessibilityLabel(Text("Quick Open"))
}
private func close() {
if reduceMotion {
dismiss()
} else {
withAnimation(Motion.panel) { dismiss() }
}
}
}
struct QuickOpenView: View {
let dismiss: () -> Void
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.dismiss) private var dismiss
@Environment(\.openSettings) private var openSettings
@State private var query = ""
@State private var selectedID: AppCommandDescriptor.ID?
@FocusState private var searchFocused: Bool
private var matches: [AppCommandDescriptor] {
app.commands.commands(app: app, window: window).filter { $0.matches(query) }
private var results: QuickOpenResults {
QuickOpenResults(
commands: app.commands.commands(app: app, window: window),
query: query
)
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: Metrics.Space.m) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Open a page, workspace, host, or command", text: $query)
.textFieldStyle(.plain)
.font(.title3)
.focused($searchFocused)
.onSubmit(performSelection)
.onKeyPress(.upArrow) { moveSelection(by: -1) }
.onKeyPress(.downArrow) { moveSelection(by: 1) }
searchHeader
Divider().opacity(0.72)
resultContent
Divider().opacity(0.72)
shortcutFooter
}
.frame(width: Metrics.QuickOpen.width, height: Metrics.QuickOpen.height)
.onAppear {
searchFocused = true
selectedID = results.commands.first?.id
}
.onChange(of: query) {
selectedID = results.commands.first?.id
}
}
private var searchHeader: some View {
HStack(spacing: Metrics.Space.l) {
Image(systemName: "magnifyingglass")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.secondary)
.accessibilityHidden(true)
TextField("Open a page, workspace, host, or command", text: $query)
.textFieldStyle(.plain)
.font(.system(size: 17, weight: .medium))
.focused($searchFocused)
.onSubmit(performSelection)
.onKeyPress(.upArrow) { moveSelection(by: -1) }
.onKeyPress(.downArrow) { moveSelection(by: 1) }
.onExitCommand(perform: dismiss)
.accessibilityLabel(Text("Search commands"))
if !query.isEmpty {
Button {
query = ""
searchFocused = true
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
.contentShape(.circle)
}
.buttonStyle(.plain)
.focusable(false)
.help(Text("Clear search"))
.accessibilityLabel(Text("Clear search"))
}
.padding(Metrics.Space.l)
Divider()
QuickOpenKeycap(keys: "esc")
.accessibilityHidden(true)
}
.padding(.horizontal, Metrics.Space.xl)
.frame(height: Metrics.QuickOpen.searchHeight)
}
if matches.isEmpty {
ContentUnavailableView.search(text: query)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
QuickOpenNativeList(items: quickOpenItems) { item in
let command = item.command
Button {
perform(command)
} label: {
HStack(spacing: Metrics.Space.m) {
Image(systemName: command.symbol)
.frame(width: 22)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(command.title).lineLimit(1)
if let subtitle = command.subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.contentShape(.rect)
.background(
item.isSelected
? Color.accentColor.opacity(0.16) : Color.clear
)
}
.buttonStyle(.plain)
@ViewBuilder
private var resultContent: some View {
if results.commands.isEmpty {
VStack(spacing: Metrics.Space.m) {
Image(systemName: "magnifyingglass")
.font(.system(size: 25, weight: .medium))
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
Text("No results")
.font(.headline)
Text("No commands match “\(query)”")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .combine)
} else {
QuickOpenNativeList(items: listItems) { item in
switch item {
case .section(let section):
quickOpenSection(section)
case .command(let command, let isSelected):
quickOpenCommand(command, isSelected: isSelected)
}
}
}
.frame(width: 620, height: 430)
.onAppear {
searchFocused = true
selectedID = matches.first?.id
}
.onChange(of: query) {
selectedID = matches.first?.id
}
}
private var quickOpenItems: [QuickOpenListItem] {
matches.map { QuickOpenListItem(command: $0, isSelected: selectedID == $0.id) }
private var listItems: [QuickOpenListItem] {
results.listItems(selectedID: selectedID)
}
private func quickOpenSection(_ section: AppCommandSection) -> some View {
Text(section.title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.padding(.horizontal, Metrics.Space.xl + Metrics.Space.xs)
.padding(.bottom, Metrics.Space.xs)
.accessibilityAddTraits(.isHeader)
}
private func quickOpenCommand(
_ command: AppCommandDescriptor,
isSelected: Bool
) -> some View {
Button {
perform(command)
} label: {
HStack(spacing: Metrics.Space.l) {
Image(systemName: command.symbol)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(
isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)
)
.frame(width: Metrics.QuickOpen.iconSize, height: Metrics.QuickOpen.iconSize)
.background(
isSelected
? AnyShapeStyle(Color.accentColor.opacity(0.12))
: AnyShapeStyle(Color.primary.opacity(0.055)),
in: .rect(cornerRadius: Metrics.Radius.medium)
)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text(command.title)
.font(.callout.weight(isSelected ? .medium : .regular))
.lineLimit(1)
if let subtitle = command.subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, Metrics.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(
Highlight.fill(isSelected: isSelected),
in: .rect(cornerRadius: Metrics.Space.m)
)
.contentShape(.rect)
}
.buttonStyle(.plain)
.padding(.horizontal, Metrics.Space.s)
.onHover { hovering in
if hovering { selectedID = command.id }
}
.accessibilityElement(children: .combine)
.accessibilityLabel(commandAccessibilityLabel(command))
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private var shortcutFooter: some View {
HStack(spacing: Metrics.Space.xl) {
QuickOpenShortcutHint(keys: "↑↓", label: String(localized: "Navigate"))
QuickOpenShortcutHint(keys: "↩", label: String(localized: "Open"))
QuickOpenShortcutHint(keys: "esc", label: String(localized: "Close"))
Spacer(minLength: 0)
}
.padding(.horizontal, Metrics.Space.xl)
.frame(height: Metrics.QuickOpen.footerHeight)
.accessibilityHidden(true)
}
private func commandAccessibilityLabel(_ command: AppCommandDescriptor) -> Text {
if let subtitle = command.subtitle {
Text("\(command.title), \(subtitle)")
} else {
Text(command.title)
}
}
private func moveSelection(by offset: Int) -> KeyPress.Result {
guard !matches.isEmpty else { return .ignored }
let current = selectedID.flatMap { id in matches.firstIndex { $0.id == id } }
?? (offset > 0 ? -1 : matches.count)
selectedID = matches[min(max(current + offset, 0), matches.count - 1)].id
guard let next = results.selection(movingFrom: selectedID, by: offset) else {
return .ignored
}
selectedID = next
return .handled
}
private func performSelection() {
let command =
selectedID.flatMap { id in matches.first { $0.id == id } }
?? matches.first
selectedID.flatMap { id in results.commands.first { $0.id == id } }
?? results.commands.first
if let command { perform(command) }
}
@@ -105,10 +270,104 @@ struct QuickOpenView: View {
}
}
nonisolated struct QuickOpenListItem: Identifiable, Hashable, Sendable {
let command: AppCommandDescriptor
let isSelected: Bool
var id: AppCommandDescriptor.ID { command.id }
private struct QuickOpenShortcutHint: View {
let keys: String
let label: String
var body: some View {
HStack(spacing: Metrics.Space.s) {
QuickOpenKeycap(keys: keys)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private struct QuickOpenKeycap: View {
let keys: String
var body: some View {
Text(verbatim: keys)
.font(.system(size: 10, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
.padding(.horizontal, Metrics.Space.s)
.frame(minWidth: 22, minHeight: 18)
.background(
Color.primary.opacity(0.055),
in: .rect(cornerRadius: Metrics.Radius.small)
)
.overlay {
RoundedRectangle(cornerRadius: Metrics.Radius.small)
.strokeBorder(Color.primary.opacity(0.10), lineWidth: 1)
}
}
}
nonisolated struct QuickOpenResults: Equatable, Sendable {
let commands: [AppCommandDescriptor]
init(commands: [AppCommandDescriptor], query: String) {
let matches = commands.filter { $0.matches(query) }
self.commands = AppCommandSection.allCases.flatMap { section in
matches.filter { $0.section == section }
}
}
var sections: [AppCommandSection] {
AppCommandSection.allCases.filter { section in
commands.contains { $0.section == section }
}
}
func listItems(selectedID: AppCommandDescriptor.ID?) -> [QuickOpenListItem] {
sections.flatMap { section in
[.section(section)] + commands.compactMap { command in
guard command.section == section else { return nil }
return .command(command, isSelected: command.id == selectedID)
}
}
}
func selection(
movingFrom selectedID: AppCommandDescriptor.ID?,
by offset: Int
) -> AppCommandDescriptor.ID? {
guard !commands.isEmpty else { return nil }
let current = selectedID.flatMap { id in commands.firstIndex { $0.id == id } }
?? (offset > 0 ? -1 : commands.count)
let next = min(max(current + offset, 0), commands.count - 1)
return commands[next].id
}
}
nonisolated enum QuickOpenListItem: Identifiable, Hashable, Sendable {
nonisolated enum ID: Hashable, Sendable {
case section(AppCommandSection)
case command(AppCommandDescriptor.ID)
}
case section(AppCommandSection)
case command(AppCommandDescriptor, isSelected: Bool)
var id: ID {
switch self {
case .section(let section): .section(section)
case .command(let command, _): .command(command.id)
}
}
var height: CGFloat {
switch self {
case .section: Metrics.QuickOpen.sectionHeight
case .command: Metrics.QuickOpen.rowHeight
}
}
var isSelected: Bool {
guard case .command(_, let isSelected) = self else { return false }
return isSelected
}
}
private struct QuickOpenNativeList: NSViewRepresentable {
@@ -117,6 +376,7 @@ private struct QuickOpenNativeList: NSViewRepresentable {
let items: [QuickOpenListItem]
let renderer: Renderer
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.locale) private var locale
@Environment(\.colorScheme) private var colorScheme
@@ -134,7 +394,7 @@ private struct QuickOpenNativeList: NSViewRepresentable {
func updateNSView(_ listView: NSViewType, context: Context) {
context.coordinator.update(
listView: listView, items: items, renderer: renderer,
locale: locale, colorScheme: colorScheme
locale: locale, colorScheme: colorScheme, animated: !reduceMotion
)
}
@@ -148,10 +408,12 @@ private struct QuickOpenNativeList: NSViewRepresentable {
func makeListView() -> NSViewType {
let listView = NSViewType()
listView.rowAnimator = ListBouncyAnimator(style: .regular)
listView.estimatedRowHeight = 54
listView.topInset = Metrics.Space.xs
listView.bottomInset = Metrics.Space.xs
listView.estimatedRowHeight = Metrics.QuickOpen.rowHeight
listView.rows {
ListRow(QuickOpenHostingRow.self)
.height { _, _ in 54 }
.height { item, _ in item.height }
.configure { [weak self] row, item, _ in self?.configure(row, item: item) }
}
return listView
@@ -159,7 +421,7 @@ private struct QuickOpenNativeList: NSViewRepresentable {
func update(
listView: NSViewType, items: [QuickOpenListItem], renderer: @escaping Renderer,
locale: Locale, colorScheme: ColorScheme
locale: Locale, colorScheme: ColorScheme, animated: Bool
) {
self.renderer = renderer
let environmentChanged = self.locale != locale || self.colorScheme != colorScheme
@@ -170,7 +432,7 @@ private struct QuickOpenNativeList: NSViewRepresentable {
listView.update(new)
}
} else {
listView.apply(items)
listView.apply(items, animated: animated && !content.isEmpty)
}
content = items
if let selected = items.first(where: \.isSelected) {
@@ -181,6 +443,7 @@ private struct QuickOpenNativeList: NSViewRepresentable {
private func configure(_ row: QuickOpenHostingRow, item: QuickOpenListItem) {
guard let renderer else { return }
let root = renderer(item)
.id(item.id)
.environment(\.locale, locale)
.environment(\.colorScheme, colorScheme)
row.configure(rootView: AnyView(root))
@@ -191,6 +454,7 @@ private struct QuickOpenNativeList: NSViewRepresentable {
@MainActor
private final class QuickOpenHostingRow: ListRowView {
private let hostingView = NSHostingView(rootView: AnyView(EmptyView()))
override init(frame: CGRect) {
super.init(frame: frame)
hostingView.translatesAutoresizingMaskIntoConstraints = false
@@ -202,9 +466,11 @@ private final class QuickOpenHostingRow: ListRowView {
hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
override func prepareForReuse() {
super.prepareForReuse()
hostingView.rootView = AnyView(EmptyView())
}
func configure(rootView: AnyView) { hostingView.rootView = rootView }
}
@@ -77,7 +77,7 @@ final class WindowSidebarState {
@Observable
final class WindowPresentationState {
enum SheetRoute: Identifiable {
enum PresentationRoute: Identifiable {
case hostChooser(HostChooserPurpose)
case quickOpen
@@ -89,28 +89,28 @@ final class WindowPresentationState {
}
}
var sheet: SheetRoute?
var route: PresentationRoute?
var hostChooser: HostChooserPurpose? {
get {
guard case .hostChooser(let purpose) = sheet else { return nil }
guard case .hostChooser(let purpose) = route else { return nil }
return purpose
}
set {
sheet = newValue.map(SheetRoute.hostChooser)
route = newValue.map(PresentationRoute.hostChooser)
}
}
var isQuickOpenPresented: Bool {
get {
guard case .quickOpen = sheet else { return false }
guard case .quickOpen = route else { return false }
return true
}
set {
if newValue {
sheet = .quickOpen
} else if case .quickOpen = sheet {
sheet = nil
route = .quickOpen
} else if case .quickOpen = route {
route = nil
}
}
}
+72
View File
@@ -2407,6 +2407,18 @@
}
}
},
"Close" : {
"comment" : "Shortcut to close the Quick Open palette.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "关闭"
}
}
}
},
"Close All Workspaces" : {
"localizations" : {
"zh-Hans" : {
@@ -6295,6 +6307,30 @@
}
}
},
"Navigate" : {
"comment" : "Shortcut hint for navigating through the quick open items.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "导航"
}
}
}
},
"Navigation" : {
"comment" : "Title of a command section for navigation-related commands.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "导航"
}
}
}
},
"Navigator" : {
"localizations" : {
"zh-Hans" : {
@@ -6547,6 +6583,18 @@
}
}
},
"No commands match “%@”" : {
"comment" : "A label that indicates that no commands match the current search query. The argument is the query.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "没有与“%@”匹配的命令"
}
}
}
},
"No command is running under that handle. It finished and was already reported, or it was released after going unasked-about for too long." : {
"localizations" : {
"zh-Hans" : {
@@ -9091,6 +9139,18 @@
}
}
},
"Search commands" : {
"comment" : "A label for the search bar in the Quick Open.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "搜索命令"
}
}
}
},
"Search History" : {
"localizations" : {
"zh-Hans" : {
@@ -13135,6 +13195,18 @@
}
}
},
"Workspaces" : {
"comment" : "Title of a command section for workspace management.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "工作区"
}
}
}
},
"Workspace" : {
"localizations" : {
"zh-Hans" : {
+56
View File
@@ -24,6 +24,55 @@ struct QuickOpenTests {
#expect(commands.contains { $0.target == .newChat })
#expect(commands.contains { $0.target == .showAssistant })
#expect(commands.filter { $0.matches("critical") }.count == 2)
#expect(commands.filter { $0.section == .navigation }.allSatisfy {
switch $0.target {
case .vaultPage, .vault, .assistant, .settings: true
default: false
}
})
#expect(commands.filter { $0.section == .workspaces }.allSatisfy {
if case .workspace = $0.target { true } else { false }
})
#expect(commands.filter { $0.section == .hosts }.allSatisfy {
if case .connectHost = $0.target { true } else { false }
})
#expect(commands.filter { $0.section == .actions }.allSatisfy {
switch $0.target {
case .newChat, .newHost, .showAssistant: true
default: false
}
})
}
@Test
func resultsGroupMatchesAndKeyboardSelectionSkipsHeaders() throws {
let host = Host(name: "Production", host: "prod.example", tags: ["critical"])
let store = HostLibraryModel(hosts: [host])
let app = AppModel(hosts: store)
let workspace = Workspace.sshSession(host: host)
let window = WindowModel(workspaces: [workspace])
let commands = app.commands.commands(app: app, window: window)
let results = QuickOpenResults(commands: commands, query: "")
#expect(results.sections == [.navigation, .workspaces, .hosts, .actions])
#expect(sectionItems(in: results.listItems(selectedID: nil)) == results.sections)
let first = try #require(results.commands.first)
let second = try #require(results.commands.dropFirst().first)
let last = try #require(results.commands.last)
#expect(results.selection(movingFrom: nil, by: 1) == first.id)
#expect(results.selection(movingFrom: first.id, by: -1) == first.id)
#expect(results.selection(movingFrom: first.id, by: 1) == second.id)
#expect(results.selection(movingFrom: last.id, by: 1) == last.id)
let filtered = QuickOpenResults(commands: commands, query: "critical")
#expect(filtered.sections == [.hosts])
#expect(filtered.commands.count == 2)
#expect(sectionItems(in: filtered.listItems(selectedID: filtered.commands.first?.id)) == [
.hosts
])
#expect(filtered.listItems(selectedID: filtered.commands.first?.id).filter(\.isSelected).count == 1)
}
@Test
@@ -52,4 +101,11 @@ struct QuickOpenTests {
#expect(window.assistantLibrary.conversations.isEmpty)
#expect(window.assistantLibrary.selectedConversationID == nil)
}
private func sectionItems(in items: [QuickOpenListItem]) -> [AppCommandSection] {
items.compactMap { item in
guard case .section(let section) = item else { return nil }
return section
}
}
}
+9 -2
View File
@@ -770,15 +770,22 @@ struct WindowStateTests {
}
@Test
func windowSheetRouteIsMutuallyExclusive() {
func windowPresentationRouteIsMutuallyExclusive() {
let state = WindowPresentationState()
state.hostChooser = .connect
#expect(state.hostChooser == .connect)
state.isQuickOpenPresented = true
#expect(state.hostChooser == nil)
#expect(state.isQuickOpenPresented)
state.hostChooser = .batchStartup
#expect(state.hostChooser == .batchStartup)
#expect(!state.isQuickOpenPresented)
state.hostChooser = nil
#expect(state.sheet == nil)
#expect(state.route == nil)
}
@Test