Add compact Metrics detail switching

This commit is contained in:
Kaede Akino committed 2026-09-18 20:15:17 +08:00
1 parent 0e85a5cec9
commit e8b85c45da
10 files changed
+447 -38

No files matched your search

+9 -2
View File
@@ -108,8 +108,15 @@ cleanup is performed.
- Desktop main pages share a 360-point detail minimum, independent of the
Assistant's 840-point reading maximum. Opening the Navigator grows the window
when necessary. Vault overlays its existing editor below 721 points and keeps
the covered list mounted at its last visible size; Metrics stacks its existing
detail and process views below 630 points. Resizing changes layout, never route,
the covered list mounted at its last visible size. Below 700 points Metrics uses
a titlebar Status/Processes picker with the toolbar title hidden; at larger
widths both panels and the title are visible.
Covered Metrics panels retain their last visible size, table identity, sorting,
and scroll position. Page selection and measured detail width are window-local;
selecting another server resets to Status. Focusing search or narrowing while
search is focused reveals Processes. Compact actions group Pause/Resume into
More, and macOS 15 presents its search field in a toolbar popover.
Resizing changes layout, never route,
editor identity, Workspace composition, or monitoring ownership.
- Assistant conversations are owned by `AssistantLibraryModel`; they are not
Workspace panes. A Workspace owns SSH/SFTP/serial panes and any session-scoped
+4
View File
@@ -93,6 +93,7 @@ struct RootView: View {
WindowTitleCatalog.standard(hosts: app.hostLibrary)
.title(in: window, locale: locale)
)
.toolbar(removing: window.showsCompactMetricsDetail ? .title : nil)
// The window's only toolbar. Creation on the left, one Find on the
// right, both following the content in front rather than the sidebar —
// see `WindowToolbarLeadingActions` and `WindowModel.findKind`.
@@ -100,6 +101,9 @@ struct RootView: View {
ToolbarItemGroup(placement: .navigation) {
WindowToolbarLeadingActions()
}
ToolbarItem(placement: .principal) {
MetricsDetailPagePicker()
}
ToolbarItem(placement: .primaryAction) {
WindowToolbarSearchSlot()
}
@@ -16,6 +16,24 @@ struct MetricsConnectionNotice: View {
}
}
struct MetricsDetailPagePicker: View {
@Environment(WindowModel.self) private var window
var body: some View {
@Bindable var window = window
if window.showsCompactMetricsDetail {
Picker("Details", selection: $window.metricsDetailPage) {
Image(systemName: "gauge.with.dots.needle.33percent")
.accessibilityLabel("Status").help("Status").tag(MetricsDetailPage.status)
Image(systemName: "list.bullet.rectangle")
.accessibilityLabel("Processes").help("Processes").tag(MetricsDetailPage.processes)
}
.pickerStyle(.segmented)
.frame(width: 104)
}
}
}
struct MetricsDetailToolbar: View {
let server: ServerMetric
let model: ServerMetricsModel
@@ -23,16 +41,12 @@ struct MetricsDetailToolbar: View {
@State private var configuring = false
var body: some View {
Button {
model.setEnabled(!server.selection.enabled, for: server)
} label: {
Label(
server.selection.enabled ? "Pause" : "Resume", systemImage: server.selection.enabled ? "pause" : "play"
)
.labelStyle(.iconOnly)
if !window.showsCompactMetricsDetail {
pauseButton.labelStyle(.iconOnly)
.help(server.selection.enabled ? Text("Pause") : Text("Resume"))
}
.help(server.selection.enabled ? Text("Pause") : Text("Resume"))
Menu {
if window.showsCompactMetricsDetail { pauseButton }
Button("Retry", systemImage: MenuSymbol.refresh) { model.retry(server) }
Button("Select Devices", systemImage: MenuSymbol.selectDevices) { configuring = true }
Divider()
@@ -45,4 +59,13 @@ struct MetricsDetailToolbar: View {
}
.sheet(isPresented: $configuring) { MetricsDevicePicker(server: server, model: model) }
}
private var pauseButton: some View {
Button {
model.setEnabled(!server.selection.enabled, for: server)
} label: {
Label(
server.selection.enabled ? "Pause" : "Resume", systemImage: server.selection.enabled ? "pause" : "play")
}
}
}
@@ -0,0 +1,48 @@
import SwiftUI
nonisolated enum MetricsDetailPage: CaseIterable {
case status, processes
static func showsBothColumns(width: CGFloat) -> Bool { width >= 700 }
}
/// Both native scroll views stay mounted. A covered panel keeps its last visible
/// size so switching pages or resizing cannot reset its table or scroll position.
struct MetricsDetailLayout: Layout {
var split: Bool
var page: MetricsDetailPage
struct Cache {
var statusSize: CGSize?
var processSize: CGSize?
}
func makeCache(subviews: Subviews) -> Cache { Cache() }
func updateCache(_ cache: inout Cache, subviews: Subviews) {}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
guard subviews.count == 2 else { return }
let statusWidth = split ? min(340, max(270, (bounds.width + 32) * 0.38)) : bounds.width
let processWidth = split ? max(0, bounds.width - statusWidth - 16) : bounds.width
let showsStatus = split || page == .status
let showsProcesses = split || page == .processes
let statusSize =
showsStatus
? CGSize(width: statusWidth, height: bounds.height) : cache.statusSize ?? bounds.size
let processSize =
showsProcesses
? CGSize(width: processWidth, height: bounds.height) : cache.processSize ?? bounds.size
if showsStatus { cache.statusSize = statusSize }
if showsProcesses { cache.processSize = processSize }
subviews[0].place(at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(statusSize))
subviews[1].place(
at: CGPoint(x: split ? bounds.minX + statusWidth + 16 : bounds.minX, y: bounds.minY),
anchor: .topLeading, proposal: ProposedViewSize(processSize))
}
}
+24 -18
View File
@@ -62,32 +62,38 @@ struct ServerMetricsView: View {
struct MetricsDetailView: View {
let server: ServerMetric
let model: ServerMetricsModel
static let splitMinWidth: CGFloat = 630
static let compactProcessHeight: CGFloat = 360
@Environment(WindowModel.self) private var window
var body: some View {
GeometryReader { geometry in
let compact = geometry.size.width < Self.splitMinWidth
let layout =
compact
? AnyLayout(VStackLayout(alignment: .leading, spacing: 16))
: AnyLayout(HStackLayout(alignment: .top, spacing: 16))
let split = MetricsDetailPage.showsBothColumns(width: geometry.size.width)
let showsStatus = split || window.metricsDetailPage == .status
let showsProcesses = split || window.metricsDetailPage == .processes
ScrollView {
layout {
MetricsDetailLayout(split: split, page: window.metricsDetailPage) {
ScrollView {
MetricsServerDetails(server: server, model: model)
.padding(1)
.frame(width: compact ? nil : min(340, max(270, geometry.size.width * 0.38)))
.fixedSize(horizontal: false, vertical: true)
MetricsProcessList(server: server)
.frame(height: compact ? Self.compactProcessHeight : max(0, geometry.size.height - 32))
.frame(maxWidth: .infinity)
}
.padding(16)
.metricsPlainScrollEdges()
.opacity(showsStatus ? 1 : 0)
.allowsHitTesting(showsStatus)
.disabled(!showsStatus)
.accessibilityHidden(!showsStatus)
MetricsProcessList(server: server)
.opacity(showsProcesses ? 1 : 0)
.allowsHitTesting(showsProcesses)
.disabled(!showsProcesses)
.accessibilityHidden(!showsProcesses)
}
.metricsPlainScrollEdges()
.padding(16)
.clipped()
}
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
window.metricsDetailWidth = $0
}
}
}
+32 -3
View File
@@ -51,9 +51,30 @@ final class WindowModel {
private var settingsReturnDestination: WindowDestination = .vault
weak var metricsModel: ServerMetricsModel?
var pendingMetricsHostIDs: [UUID] = []
var metricsHostID: UUID?
var metricsHostID: UUID? {
didSet {
if metricsHostID != oldValue { metricsDetailPage = .status }
}
}
var metricsDetailPage = MetricsDetailPage.status
var metricsDetailWidth: CGFloat = 0 {
didSet {
if isToolbarFindFocused { revealMetricsProcessesForFind() }
}
}
var showsCompactMetricsDetail: Bool {
guard selectedDestination == .metrics, metricsDetailWidth > 0,
!MetricsDetailPage.showsBothColumns(width: metricsDetailWidth),
let server = metricsModel?.servers.first(where: { $0.id == metricsHostID })
else { return false }
return !server.showsConnectionJourney
}
var metricsQuery = ""
var metricsProcessQuery = ""
var metricsProcessQuery = "" {
didSet {
if isToolbarFindFocused { revealMetricsProcessesForFind() }
}
}
var metricsSelection: Set<UUID> = []
var settingsRoute: SettingsRoute = .index
var vaultDestination: VaultDestination = .welcome
@@ -351,7 +372,15 @@ final class WindowModel {
/// Level, not edge. A pane has to consult it every time it is about to hold
/// focus, not only when this flips: the caret is lost on *each* keystroke,
/// and a stand-down that fires once cannot answer the second one.
var isToolbarFindFocused = false
var isToolbarFindFocused = false {
didSet {
if isToolbarFindFocused { revealMetricsProcessesForFind() }
}
}
private func revealMetricsProcessesForFind() {
if showsCompactMetricsDetail { metricsDetailPage = .processes }
}
/// Focuses the titlebar Find field. Installed by the field itself while it
/// is mounted, and left pointing at a field that has gone away otherwise —
+46 -1
View File
@@ -465,15 +465,60 @@ struct WindowToolbarSearchSlot: View {
var body: some View {
if #available(macOS 26.0, *) {
Color.clear.frame(width: 0, height: Metrics.Toolbar.height)
} else {
WindowToolbarLegacySearch()
}
}
}
/// Older macOS versions need an explicit compact search affordance. Keep the
/// popover inside the toolbar item so changing width never reparents page content.
struct WindowToolbarLegacySearch: View {
@Environment(WindowModel.self) private var window
var body: some View {
if window.showsCompactMetricsDetail {
WindowToolbarCompactFind()
} else {
WindowToolbarFindField()
}
}
}
private struct WindowToolbarCompactFind: View {
@Environment(WindowModel.self) private var window
@State private var isPresented = false
var body: some View {
let presentation = $isPresented
Button("Search", systemImage: "magnifyingglass") { window.focusToolbarFind() }
.labelStyle(.iconOnly)
.help("Search")
.frame(height: Metrics.Toolbar.height)
.popover(isPresented: $isPresented, arrowEdge: .bottom) {
WindowToolbarFindField(installFocusHandler: { focus in
window.focusToolbarFindField = {
presentation.wrappedValue = true
focus()
}
})
.padding(12)
.onExitCommand { isPresented = false }
}
.onAppear {
window.focusToolbarFindField = { presentation.wrappedValue = true }
if window.isToolbarFindFocused { isPresented = true }
}
.onChange(of: isPresented) { _, presented in
if !presented { window.isToolbarFindFocused = false }
}
}
}
/// The trailing half of the titlebar: one Find field, or nothing at all on
/// Welcome and on a workspace that has no searchable pane in front.
struct WindowToolbarFindField: View {
var installFocusHandler: ((@escaping () -> Void) -> Void)? = nil
@Environment(WindowModel.self) private var window
@Environment(\.locale) private var locale
@State private var searchFieldOwner = NativeSearchFieldOwner()
@@ -489,7 +534,7 @@ struct WindowToolbarFindField: View {
maintainsKeyboardFocus: window.isToolbarFindFocused,
navigate: context.navigate,
cancel: context.cancel,
installFocusHandler: { window.focusToolbarFindField = $0 },
installFocusHandler: installFocusHandler ?? { window.focusToolbarFindField = $0 },
onFocusChange: { window.isToolbarFindFocused = $0 }
).singleLinePaste()
.frame(width: Metrics.Toolbar.searchFieldWidth)
+150
View File
@@ -0,0 +1,150 @@
import AppKit
import SwiftData
import SwiftUI
import TermindApplication
import TermindPersistence
import Testing
@testable import Termind
@MainActor
@Suite(.serialized)
struct MetricsDetailToolbarTests {
@Test(arguments: [AppLanguage.english, .simplifiedChinese, .traditionalChinese])
func compactToolbarSwitchesPagesAndSearchRevealsProcesses(language: AppLanguage) async throws {
let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: language)
let hosts = HostLibraryModel.demo(language: language)
let model = configuration.makeMetricsModel(hosts: hosts)
let app = AppModel(hosts: hosts, serverMetrics: model)
let state = configuration.makeWindow(hosts: hosts)
let root = RootView(window: state)
.environment(app)
.environment(\.locale, language.locale)
.modelContainer(try TermindPersistence.makeContainer(inMemory: true))
let window = makeWindow(root)
defer {
window.orderOut(nil)
window.contentViewController = nil
app.shutdownForTermination()
}
await settle(window)
let frame = try #require(window.contentView?.superview)
#expect(state.showsCompactMetricsDetail)
let picker = try #require(descendants(in: frame, ofType: NSSegmentedControl.self).first)
#expect(picker.segmentCount == 2)
#expect(picker.selectedSegment == 0)
#expect(!picker.visibleRect.isEmpty)
try capture(frame, name: "\(language.rawValue)-status")
picker.selectedSegment = 1
picker.sendAction(picker.action, to: picker.target)
await settle(window)
#expect(state.metricsDetailPage == .processes)
try capture(frame, name: "\(language.rawValue)-processes")
picker.selectedSegment = 0
picker.sendAction(picker.action, to: picker.target)
await settle(window)
#expect(state.metricsDetailPage == .status)
state.focusToolbarFind()
await settle(window)
#expect(state.metricsDetailPage == .processes)
#expect(state.isToolbarFindFocused)
try capture(frame, name: "\(language.rawValue)-search")
window.setContentSize(CGSize(width: 900, height: 600))
await settle(window)
#expect(!state.showsCompactMetricsDetail)
#expect(descendants(in: frame, ofType: NSSegmentedControl.self).isEmpty)
window.setContentSize(CGSize(width: 360, height: 600))
await settle(window)
#expect(state.metricsDetailPage == .processes)
#expect(state.showsCompactMetricsDetail)
let search = try #require(window.toolbar?.items.compactMap { $0 as? NSSearchToolbarItem }.first)
search.endSearchInteraction()
await settle(window)
#expect(!descendants(in: frame, ofType: NSSegmentedControl.self).isEmpty)
}
@Test
func legacyCompactSearchOpensTheNativeFieldAndKeepsItsQueryWhenWidened() async throws {
let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english)
let model = configuration.makeMetricsModel(hosts: .demo())
let server = try #require(model.servers.first)
let state = WindowModel()
state.selectedDestination = .metrics
state.metricsHostID = server.id
state.metricsModel = model
let root = NavigationStack {
MetricsDetailView(server: server, model: model)
.toolbar {
ToolbarItem(placement: .principal) { MetricsDetailPagePicker() }
ToolbarItem(placement: .primaryAction) { WindowToolbarLegacySearch() }
}
}.environment(state)
let window = makeWindow(root)
defer {
window.orderOut(nil)
window.contentViewController = nil
model.shutdown()
}
await settle(window)
#expect(state.showsCompactMetricsDetail)
let frame = try #require(window.contentView?.superview)
#expect(descendants(in: frame, ofType: NSSearchField.self).isEmpty)
#expect(window.toolbar != nil)
_ = try #require(state.focusToolbarFindField)
try capture(frame, name: "legacy-before-search")
state.focusToolbarFind()
await settle(window)
try capture(frame, name: "legacy-after-search")
let field = try #require(
NSApp.windows.filter(\.isVisible).compactMap(\.contentView)
.flatMap { descendants(in: $0, ofType: NSSearchField.self) }.first)
#expect(state.metricsDetailPage == .processes)
#expect(field.currentEditor() != nil)
field.stringValue = "node"
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
await settle(window)
#expect(state.metricsProcessQuery == "node")
window.setContentSize(CGSize(width: 900, height: 600))
await settle(window)
let expandedField = try #require(
descendants(in: frame, ofType: NSSearchField.self).first)
#expect(expandedField.stringValue == "node")
#expect(state.metricsDetailPage == .processes)
}
private func makeWindow(_ root: some View) -> NSWindow {
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 360, height: 600),
styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.isRestorable = false
window.contentViewController = NSHostingController(rootView: root)
window.setContentSize(CGSize(width: 360, height: 600))
window.appearance = NSAppearance(named: .aqua)
window.center()
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
return window
}
private func settle(_ window: NSWindow) async {
window.contentView?.layoutSubtreeIfNeeded()
try? await Task.sleep(for: .milliseconds(350))
window.contentView?.layoutSubtreeIfNeeded()
}
private func descendants<T: NSView>(in view: NSView, ofType type: T.Type) -> [T] {
(view as? T).map { [$0] } ?? view.subviews.flatMap { descendants(in: $0, ofType: type) }
}
private func capture(_ view: NSView, name: String) throws {
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
try data.write(
to: FileManager.default.temporaryDirectory.appending(path: "termind-metrics-compact-\(name).png"))
}
}
+68
View File
@@ -16,6 +16,74 @@ import struct TermindDomain.Host
@Suite
@MainActor
struct MetricsInteractionTests {
@Test
func compactMetricsPagesFollowSearchAndRemainWindowLocal() throws {
let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english)
let model = configuration.makeMetricsModel(hosts: .demo())
defer { model.shutdown() }
let server = try #require(model.servers.first)
let window = WindowModel()
let otherWindow = WindowModel()
for state in [window, otherWindow] {
state.selectedDestination = .metrics
state.metricsModel = model
state.metricsHostID = server.id
state.metricsDetailWidth = 360
#expect(state.showsCompactMetricsDetail)
#expect(state.metricsDetailPage == .status)
}
window.focusToolbarFind()
#expect(window.metricsDetailPage == .processes)
#expect(otherWindow.metricsDetailPage == .status)
try #require(window.findContext()).query.wrappedValue = "node"
window.isToolbarFindFocused = false
#expect(window.metricsDetailPage == .processes)
#expect(window.metricsProcessQuery == "node")
window.metricsDetailPage = .status
window.isToolbarFindFocused = true // Native search activation, including a mouse click.
#expect(window.metricsDetailPage == .processes)
window.metricsDetailPage = .status
window.focusToolbarFind() // Repeated Command-F while the search is already presented.
#expect(window.metricsDetailPage == .processes)
window.metricsDetailWidth = 900
window.metricsDetailPage = .status
window.metricsDetailWidth = 700
#expect(!window.showsCompactMetricsDetail)
#expect(window.metricsDetailPage == .status)
window.metricsDetailWidth = 699
#expect(window.showsCompactMetricsDetail)
#expect(window.metricsDetailPage == .processes)
window.isToolbarFindFocused = false
window.metricsDetailPage = .status
window.metricsDetailWidth = 900
window.metricsDetailWidth = 360
#expect(window.metricsDetailPage == .status)
window.metricsDetailPage = .processes
window.metricsHostID = server.id
#expect(window.metricsDetailPage == .processes)
window.metricsHostID = UUID()
#expect(window.metricsDetailPage == .status)
#expect(!window.showsCompactMetricsDetail)
window.metricsHostID = nil
#expect(!window.showsCompactMetricsDetail)
window.metricsHostID = server.id
#expect(window.metricsDetailPage == .status)
server.point = nil
server.isConnecting = true
server.journey.fail("Fixture connection failure")
#expect(server.showsConnectionJourney)
#expect(!window.showsCompactMetricsDetail)
window.focusToolbarFind()
#expect(window.metricsDetailPage == .status)
window.selectedDestination = .assistant
#expect(!window.showsCompactMetricsDetail)
}
@Test
func toolbarSearchKeepsOverviewAndProcessQueriesIndependent() throws {
let window = WindowModel()
+35 -6
View File
@@ -9,11 +9,13 @@ import Testing
@Suite(.serialized)
struct ResponsivePageLayoutTests {
@Test
func metricsResizeKeepsTheProcessTableAndMakesAllDetailsScrollable() async throws {
func metricsPagesAndResizeKeepTheProcessTableAndBothScrollPositions() async throws {
let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english)
let model = configuration.makeMetricsModel(hosts: .demo())
let server = try #require(model.servers.first)
let state = WindowModel()
state.selectedDestination = .metrics
state.metricsModel = model
state.metricsHostID = server.id
let hosting = NSHostingView(rootView: MetricsDetailView(server: server, model: model).environment(state))
let window = NSWindow(
@@ -28,6 +30,8 @@ struct ResponsivePageLayoutTests {
await layout(hosting)
let table = try #require(descendant(in: hosting, ofType: NSTableView.self))
let processScroll = try #require(table.enclosingScrollView)
let statusScroll = try #require(descendant(in: hosting, ofType: NSScrollView.self))
#expect(statusScroll !== processScroll)
let reversedSort = try #require(table.sortDescriptors.first?.reversedSortDescriptor as? NSSortDescriptor)
table.sortDescriptors = [reversedSort]
await layout(hosting)
@@ -35,21 +39,46 @@ struct ResponsivePageLayoutTests {
#expect(sort == [reversedSort])
processScroll.contentView.scroll(to: CGPoint(x: 0, y: 40))
processScroll.reflectScrolledClipView(processScroll.contentView)
let wideProcessSize = processScroll.frame.size
window.setContentSize(CGSize(width: 360, height: 600))
await layout(hosting)
let outerScroll = try #require(descendant(in: hosting, ofType: NSScrollView.self))
#expect(outerScroll !== processScroll)
#expect((outerScroll.documentView?.frame.height ?? 0) > outerScroll.contentView.bounds.height)
#expect(processScroll.frame.height > 200)
#expect(state.showsCompactMetricsDetail)
#expect(state.metricsDetailPage == .status)
#expect((statusScroll.documentView?.frame.height ?? 0) > statusScroll.contentView.bounds.height)
#expect(processScroll.frame.size == wideProcessSize)
#expect(processScroll.contentView.bounds.minY > 0)
#expect(descendant(in: hosting, ofType: NSTableView.self) === table)
#expect(table.sortDescriptors == sort)
statusScroll.contentView.scroll(to: CGPoint(x: 0, y: 20))
statusScroll.reflectScrolledClipView(statusScroll.contentView)
let statusSize = statusScroll.frame.size
state.metricsDetailPage = .processes
await layout(hosting)
#expect(processScroll.frame.height > 480)
#expect(processScroll.frame.width > 280)
#expect(statusScroll.frame.size == statusSize)
#expect(statusScroll.contentView.bounds.minY == 20)
#expect(processScroll.contentView.bounds.minY > 0)
for width in [699.0, 700.0, 900.0, 360.0] {
window.setContentSize(CGSize(width: width, height: 600))
await layout(hosting)
#expect(state.showsCompactMetricsDetail == (width < 700))
#expect(state.metricsDetailPage == .processes)
#expect(descendant(in: hosting, ofType: NSTableView.self) === table)
#expect(table.sortDescriptors == sort)
#expect(processScroll.contentView.bounds.minY > 0)
}
state.metricsDetailPage = .status
await layout(hosting)
#expect(statusScroll.contentView.bounds.minY == 20)
state.metricsDetailPage = .processes
state.metricsProcessQuery = "node"
await layout(hosting)
#expect(table.numberOfRows == 2)
window.setContentSize(CGSize(width: 630, height: 600))
window.setContentSize(CGSize(width: 700, height: 600))
await layout(hosting)
#expect(descendant(in: hosting, ofType: NSTableView.self) === table)
#expect(table.numberOfRows == 2)