From 4c718a2c819f168d96944be8d95c19f000080ff0 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 29 Aug 2026 10:50:45 +0800 Subject: [PATCH] Redesign Quick Open command palette --- Termind/App/RootView.swift | 20 +- .../Commands/AppCommandRegistry.swift | 29 ++ Termind/Design/DesignTokens.swift | 16 + .../Features/QuickOpen/QuickOpenView.swift | 408 +++++++++++++++--- Termind/Features/Workspaces/WindowState.swift | 16 +- Termind/Resources/Localizable.xcstrings | 72 ++++ TermindTests/QuickOpenTests.swift | 56 +++ TermindTests/WindowStateTests.swift | 11 +- 8 files changed, 541 insertions(+), 87 deletions(-) diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index b0abab1a..9d04ee76 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -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( diff --git a/Termind/Application/Commands/AppCommandRegistry.swift b/Termind/Application/Commands/AppCommandRegistry.swift index 18204533..4029f661 100644 --- a/Termind/Application/Commands/AppCommandRegistry.swift +++ b/Termind/Application/Commands/AppCommandRegistry.swift @@ -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 )) } diff --git a/Termind/Design/DesignTokens.swift b/Termind/Design/DesignTokens.swift index 0055d971..19f6fbd9 100644 --- a/Termind/Design/DesignTokens.swift +++ b/Termind/Design/DesignTokens.swift @@ -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. diff --git a/Termind/Features/QuickOpen/QuickOpenView.swift b/Termind/Features/QuickOpen/QuickOpenView.swift index b4af45da..6c0aeb9d 100644 --- a/Termind/Features/QuickOpen/QuickOpenView.swift +++ b/Termind/Features/QuickOpen/QuickOpenView.swift @@ -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 } } diff --git a/Termind/Features/Workspaces/WindowState.swift b/Termind/Features/Workspaces/WindowState.swift index 88920132..4734485a 100644 --- a/Termind/Features/Workspaces/WindowState.swift +++ b/Termind/Features/Workspaces/WindowState.swift @@ -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 } } } diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index c8b92621..57a9b2a2 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -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" : { diff --git a/TermindTests/QuickOpenTests.swift b/TermindTests/QuickOpenTests.swift index cd6720c3..a4959a5b 100644 --- a/TermindTests/QuickOpenTests.swift +++ b/TermindTests/QuickOpenTests.swift @@ -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 + } + } } diff --git a/TermindTests/WindowStateTests.swift b/TermindTests/WindowStateTests.swift index 1a6f737b..8d7901a2 100644 --- a/TermindTests/WindowStateTests.swift +++ b/TermindTests/WindowStateTests.swift @@ -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