Redesign Quick Open command palette
This commit is contained in:
1 parent
2c8e02a137
commit
4c718a2c81
8 files changed
+541
-87
No files matched your search
@@ -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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user