Fix edit sidebar size
This commit is contained in:
1 parent
509484ae4c
commit
9ef2c9c8cc
14 files changed
+193
-147
No files matched your search
@@ -7295,6 +7295,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Default" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "默认"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "預設"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Default Network Interfaces" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -10707,22 +10724,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Follow Global Settings" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "跟随全局设置"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "跟隨全域設定"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Follow Permissions" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
|
||||
@@ -35,13 +35,15 @@ public struct ConnectionJourneyHeader: View {
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(journey.destination?.name ?? String(localized: "SSH Connection"))
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.help(title)
|
||||
Text(detail)
|
||||
.font(.callout.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.help(detail)
|
||||
}
|
||||
|
||||
Spacer(minLength: 6)
|
||||
@@ -63,6 +65,8 @@ public struct ConnectionJourneyHeader: View {
|
||||
.padding(.horizontal, isEmbedded ? 16 : 34)
|
||||
}
|
||||
|
||||
private var title: String { journey.destination?.name ?? String(localized: "SSH Connection") }
|
||||
|
||||
private var detail: String {
|
||||
let protocolName: String =
|
||||
switch journey.purpose {
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
|
||||
public struct HostIntegrationSection<Selection: View>: View {
|
||||
private let selection: Selection
|
||||
|
||||
public init(@ViewBuilder selection: () -> Selection) { self.selection = selection() }
|
||||
|
||||
public var body: some View {
|
||||
Section("Integration") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Shell Integration")
|
||||
selection.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct HostShellIntegrationPicker: View {
|
||||
public struct HostIntegrationSection: View {
|
||||
@Binding private var mode: HostShellIntegrationMode
|
||||
|
||||
public init(mode: Binding<HostShellIntegrationMode>) { _mode = mode }
|
||||
|
||||
public var body: some View {
|
||||
Picker("Shell Integration", selection: $mode) {
|
||||
Text("Enable").tag(HostShellIntegrationMode.enabled)
|
||||
Text("Follow Global Settings").tag(HostShellIntegrationMode.followGlobalSettings)
|
||||
Text("Disable").tag(HostShellIntegrationMode.disabled)
|
||||
Section("Integration") {
|
||||
Picker("Shell Integration", selection: $mode) {
|
||||
Text("Default").tag(HostShellIntegrationMode.followGlobalSettings)
|
||||
Text("Enable").tag(HostShellIntegrationMode.enabled)
|
||||
Text("Disable").tag(HostShellIntegrationMode.disabled)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.menuOrder(.fixed)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
+12
-6
@@ -87,14 +87,19 @@ the latest record counts as unchanged.
|
||||
Snippets, Known Hosts, Welcome and editor routes. Settings data is app-wide; window-local Settings
|
||||
navigation remembers and restores the replaced destination.
|
||||
- Desktop detail minimum is 360 points; Assistant reading maximum is independently 840. Opening
|
||||
Navigator grows the window as needed. Vault reserves a 280-point list and 360–400-point editor,
|
||||
overlaying below 641 points; covered lists retain their last visible size. Editor/space transitions
|
||||
take 0.22 seconds, preserve content size and respect Reduce Motion. Metrics below 700 points uses
|
||||
Navigator grows the window as needed. Vault reserves a 280-point list, one-point divider and fixed
|
||||
320-point editor (including the embedded forwarding Journey), overlaying below 601 content points;
|
||||
the full-page editor fills available width and covered lists retain their last visible size. Editor/space
|
||||
transitions take 0.22 seconds, preserve content size and respect Reduce Motion. Metrics below 700 points uses
|
||||
a titlebar Status/Processes picker with hidden title; wider layouts show both panels/title.
|
||||
Covered panels retain size, table identity, sorting and scroll. Selection/width are window-local;
|
||||
changing server selects Status, while focusing search or narrowing during search reveals Processes.
|
||||
Compact Pause/Resume goes in More; macOS 15 search uses a toolbar popover. Resizing changes layout,
|
||||
never routes, editor identity, Workspace composition or monitoring ownership.
|
||||
Vault grouped forms retain native horizontal insets without expanding their scroll content; only
|
||||
vertical scrolling is allowed. The embedded Journey retains 16-point horizontal insets and a horizontal
|
||||
route scroller. Host integration uses a native labeled menu on both platforms, fitting narrow editors
|
||||
without widening them or clipping their footers.
|
||||
- `AssistantLibraryModel` owns standalone conversations, never Workspace panes. Workspaces own
|
||||
SSH/SFTP/serial panes and session-scoped conversations; sessions own live transports and
|
||||
terminal/browser state. Assistant ordering/pinning is metadata-only during runs, approvals or
|
||||
@@ -251,9 +256,10 @@ interactive input capture, retaining bracketed paste/broadcast. Remote mouse cap
|
||||
keep Ghostty handling; mouse reports stay outside broadcast capture. Empty paste arms no deferred capture;
|
||||
bracketed-paste classification spans chunks through its terminator or existing short-lived ticket expiry.
|
||||
|
||||
Host Shell Integration is Vault-synced Follow Global Settings/Enable/Disable; V22 adds a column while
|
||||
freezing V1–V21 Hosts and defaults legacy data to Follow Global. Both explicit-save editors place it after
|
||||
Group (desktop Group & Tags), with Follow Global centered between Enable/Disable. Each SSH attempt resolves
|
||||
Host Shell Integration is Vault-synced Default/Enable/Disable; V22 adds a column while
|
||||
freezing V1–V21 Hosts and defaults legacy data to follow global settings. Both explicit-save editors place it after
|
||||
Group (desktop Group & Tags), with a native menu ordered Default, Enable, Disable. Default follows the global
|
||||
preference without changing persisted values. Each SSH attempt resolves
|
||||
destination override/global preference once before authentication for bootstrap, History, Command Generator,
|
||||
system info and directory actions. Live edits never inject scripts/reconnect/change that attempt; new/manual/
|
||||
automatic reconnects resolve again. SFTP/forwarding route identity is unchanged.
|
||||
|
||||
@@ -90,7 +90,7 @@ struct MobileHostEditor: View {
|
||||
ForEach(library.groups) { Text($0.name).tag(Optional($0.id)) }
|
||||
}
|
||||
}
|
||||
HostIntegrationSection { MobileHostIntegrationPicker(mode: $draft.shellIntegrationMode) }
|
||||
HostIntegrationSection(mode: $draft.shellIntegrationMode)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import UIKit
|
||||
|
||||
/// Content-sized segments keep the longer follow-global label readable on phones.
|
||||
struct MobileHostIntegrationPicker: UIViewRepresentable {
|
||||
private static let modes: [HostShellIntegrationMode] = [.enabled, .followGlobalSettings, .disabled]
|
||||
@Binding var mode: HostShellIntegrationMode
|
||||
@Environment(\.locale) private var locale
|
||||
|
||||
func makeUIView(context: Context) -> UISegmentedControl {
|
||||
let control = UISegmentedControl(items: ["", "", ""])
|
||||
control.apportionsSegmentWidthsByContent = true
|
||||
control.addTarget(context.coordinator, action: #selector(Coordinator.select(_:)), for: .valueChanged)
|
||||
control.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
return control
|
||||
}
|
||||
|
||||
func updateUIView(_ control: UISegmentedControl, context: Context) {
|
||||
context.coordinator.mode = $mode
|
||||
let labels = [
|
||||
AppLocalization.string("Enable", locale: locale),
|
||||
AppLocalization.string("Follow Global Settings", locale: locale),
|
||||
AppLocalization.string("Disable", locale: locale),
|
||||
]
|
||||
for (index, label) in labels.enumerated() { control.setTitle(label, forSegmentAt: index) }
|
||||
control.accessibilityLabel = AppLocalization.string("Shell Integration", locale: locale)
|
||||
control.selectedSegmentIndex = Self.modes.firstIndex(of: mode) ?? 1
|
||||
control.isEnabled = context.environment.isEnabled
|
||||
}
|
||||
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UISegmentedControl, context: Context) -> CGSize? {
|
||||
CGSize(width: proposal.width ?? uiView.intrinsicContentSize.width, height: uiView.intrinsicContentSize.height)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(mode: $mode) }
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
var mode: Binding<HostShellIntegrationMode>
|
||||
|
||||
init(mode: Binding<HostShellIntegrationMode>) { self.mode = mode }
|
||||
|
||||
@objc func select(_ control: UISegmentedControl) {
|
||||
guard MobileHostIntegrationPicker.modes.indices.contains(control.selectedSegmentIndex) else { return }
|
||||
mode.wrappedValue = MobileHostIntegrationPicker.modes[control.selectedSegmentIndex]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,7 @@ struct MobileHostIntegrationRenderingTests {
|
||||
let controller = UIHostingController(
|
||||
rootView: Form {
|
||||
Section("Group") { Text("Production") }
|
||||
HostIntegrationSection {
|
||||
MobileHostIntegrationPicker(mode: Binding(get: { draft.mode }, set: { draft.mode = $0 }))
|
||||
}
|
||||
HostIntegrationSection(mode: Binding(get: { draft.mode }, set: { draft.mode = $0 }))
|
||||
}
|
||||
.environment(\.locale, Locale(identifier: language)))
|
||||
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
@@ -34,22 +32,32 @@ struct MobileHostIntegrationRenderingTests {
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
controller.view.layoutIfNeeded()
|
||||
let picker = try #require(descendants(controller.view).compactMap { $0 as? UISegmentedControl }.first)
|
||||
#expect(picker.numberOfSegments == 3)
|
||||
#expect(picker.apportionsSegmentWidthsByContent)
|
||||
#expect(picker.selectedSegmentIndex == 1)
|
||||
#expect(
|
||||
picker.titleForSegment(at: 1)
|
||||
== AppLocalization.string("Follow Global Settings", locale: Locale(identifier: language)))
|
||||
picker.selectedSegmentIndex = 0
|
||||
picker.sendActions(for: .valueChanged)
|
||||
#expect(descendants(controller.view).compactMap { $0 as? UISegmentedControl }.isEmpty)
|
||||
let picker = try #require(
|
||||
descendants(controller.view).compactMap { $0 as? UIButton }.first { $0.showsMenuAsPrimaryAction })
|
||||
let labels = ["Default", "Enable", "Disable"].map {
|
||||
AppLocalization.string($0, locale: Locale(identifier: language))
|
||||
}
|
||||
let actions = try await menuActions(in: picker)
|
||||
#expect(actions.map(\.title) == labels)
|
||||
#expect(actions.map(\.state) == [.on, .off, .off])
|
||||
picker.sendAction(actions[1])
|
||||
#expect(draft.mode == .enabled)
|
||||
picker.selectedSegmentIndex = 2
|
||||
picker.sendActions(for: .valueChanged)
|
||||
controller.view.frame.size.width = 600
|
||||
controller.view.layoutIfNeeded()
|
||||
let enabledActions = try await menuActions(in: picker)
|
||||
#expect(enabledActions.map(\.title) == labels)
|
||||
#expect(enabledActions.map(\.state) == [.off, .on, .off])
|
||||
picker.sendAction(enabledActions[2])
|
||||
#expect(draft.mode == .disabled)
|
||||
picker.selectedSegmentIndex = 1
|
||||
picker.sendActions(for: .valueChanged)
|
||||
controller.view.frame.size.width = 375
|
||||
controller.view.layoutIfNeeded()
|
||||
let disabledActions = try await menuActions(in: picker)
|
||||
#expect(disabledActions.map(\.title) == labels)
|
||||
#expect(disabledActions.map(\.state) == [.off, .off, .on])
|
||||
picker.sendAction(disabledActions[0])
|
||||
#expect(draft.mode == .followGlobalSettings)
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
|
||||
#expect(window.drawHierarchy(in: window.bounds, afterScreenUpdates: true))
|
||||
}
|
||||
@@ -63,6 +71,29 @@ struct MobileHostIntegrationRenderingTests {
|
||||
private func descendants(_ view: UIView) -> [UIView] {
|
||||
[view] + view.subviews.flatMap(descendants)
|
||||
}
|
||||
|
||||
private func menuActions(in button: UIButton) async throws -> [UIAction] {
|
||||
button.performPrimaryAction()
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let interaction = try #require(button.contextMenuInteraction)
|
||||
var actions: [UIAction] = []
|
||||
interaction.updateVisibleMenu { menu in
|
||||
actions = self.menuActions(menu)
|
||||
return menu
|
||||
}
|
||||
interaction.dismissMenu()
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
try #require(actions.count == 3)
|
||||
return actions
|
||||
}
|
||||
|
||||
private func menuActions(_ menu: UIMenu) -> [UIAction] {
|
||||
menu.children.flatMap { element -> [UIAction] in
|
||||
if let action = element as? UIAction { return [action] }
|
||||
if let submenu = element as? UIMenu { return menuActions(submenu) }
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable @MainActor private final class HostIntegrationDraft {
|
||||
|
||||
@@ -274,13 +274,11 @@ nonisolated enum Metrics {
|
||||
enum VaultContent {
|
||||
static let min: CGFloat = 280
|
||||
static let ideal: CGFloat = 680
|
||||
static let splitMinWidth: CGFloat = min + 1 + Metrics.VaultDetail.min
|
||||
static let splitMinWidth: CGFloat = min + 1 + Metrics.VaultDetail.width
|
||||
}
|
||||
|
||||
enum VaultDetail {
|
||||
static let min: CGFloat = 360
|
||||
static let ideal: CGFloat = 400
|
||||
static let max: CGFloat = 720
|
||||
static let width: CGFloat = 320
|
||||
}
|
||||
|
||||
enum Window {
|
||||
|
||||
@@ -167,7 +167,7 @@ struct HostEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
HostIntegrationSection { HostShellIntegrationPicker(mode: $draft.shellIntegrationMode) }
|
||||
HostIntegrationSection(mode: $draft.shellIntegrationMode)
|
||||
|
||||
if !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Section("SSH Command") {
|
||||
|
||||
@@ -80,8 +80,7 @@ struct VaultWorkspaceLayout: Layout {
|
||||
let progress = min(1, max(0, expansion))
|
||||
// Keep the editor at its open width even while it leaves. Only its
|
||||
// position and the list's reserved space animate, never the form's width.
|
||||
let detailWidth =
|
||||
split ? min(Metrics.VaultDetail.ideal, bounds.width - Metrics.VaultContent.min - 1) : bounds.width
|
||||
let detailWidth = split ? Metrics.VaultDetail.width : bounds.width
|
||||
let listWidth = split ? bounds.width - (detailWidth + 1) * progress : bounds.width
|
||||
let listIsVisible = !showsDetail || split
|
||||
let listSize = listIsVisible ? CGSize(width: listWidth, height: bounds.height) : cache.listSize ?? bounds.size
|
||||
|
||||
@@ -141,7 +141,10 @@ struct VaultDetailScaffold<Content: View, Footer: View>: View {
|
||||
|
||||
Label(title, systemImage: symbol)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.help(title)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, Metrics.Space.l)
|
||||
.padding(.vertical, Metrics.Space.m)
|
||||
|
||||
@@ -8,22 +8,22 @@ import Testing
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct HostIntegrationRenderingTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func hostEditorShowsIntegrationBelowGroupingAtNarrowWidth(language: String) async throws {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 360.0, 480.0])
|
||||
func hostEditorShowsIntegrationBelowGroupingAtNarrowWidth(language: String, width: CGFloat) async throws {
|
||||
let host = TermindDomain.Host(name: "Production", host: "server.invalid")
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
||||
let navigation = WindowModel()
|
||||
let controller = NSHostingController(
|
||||
rootView: HostEditorView(hostID: host.id).environment(app).environment(navigation)
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.frame(width: 360, height: 1400))
|
||||
.frame(width: width, height: 1400))
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 360, height: 1400),
|
||||
contentRect: NSRect(x: 0, y: 0, width: width, height: 1400),
|
||||
styleMask: [.borderless], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
controller.sizingOptions = []
|
||||
window.contentViewController = controller
|
||||
controller.view.frame = NSRect(x: 0, y: 0, width: 360, height: 1400)
|
||||
controller.view.frame = NSRect(x: 0, y: 0, width: width, height: 1400)
|
||||
window.orderFront(nil)
|
||||
defer {
|
||||
app.shutdownForTermination()
|
||||
@@ -33,18 +33,19 @@ struct HostIntegrationRenderingTests {
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
controller.view.layoutSubtreeIfNeeded()
|
||||
let control = try #require(descendants(controller.view).compactMap { $0 as? NSSegmentedControl }.first)
|
||||
#expect(control.segmentCount == 3)
|
||||
#expect(control.selectedSegment == 1)
|
||||
let labels = (0..<3).map { control.label(forSegment: $0) ?? "" }
|
||||
#expect(labels.allSatisfy { !$0.isEmpty })
|
||||
#expect(labels[1] == AppLocalization.string("Follow Global Settings", locale: Locale(identifier: language)))
|
||||
let defaultLabel = AppLocalization.string("Default", locale: Locale(identifier: language))
|
||||
#expect(descendants(controller.view).compactMap { $0 as? NSSegmentedControl }.isEmpty)
|
||||
let control = try #require(
|
||||
descendants(controller.view).compactMap { $0 as? NSPopUpButton }.first { $0.title == defaultLabel })
|
||||
let frame = control.convert(control.bounds, to: controller.view)
|
||||
#expect(frame.minX >= 16 && frame.maxX <= width - 16)
|
||||
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
|
||||
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
|
||||
let data = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
Attachment.record(data, named: "host-integration-mac-\(language).png")
|
||||
Attachment.record(data, named: "host-integration-mac-\(language)-\(Int(width)).png")
|
||||
try data.write(
|
||||
to: FileManager.default.temporaryDirectory.appending(path: "host-integration-mac-\(language).png"))
|
||||
to: FileManager.default.temporaryDirectory.appending(
|
||||
path: "host-integration-mac-\(language)-\(Int(width)).png"))
|
||||
}
|
||||
|
||||
private func descendants(_ view: NSView) -> [NSView] {
|
||||
|
||||
@@ -123,7 +123,7 @@ struct PortForwardingRenderingTests {
|
||||
let prompt = try #require(context.authenticationPrompt)
|
||||
navigation.openVaultWork(.forwardingEditor(rules[0].id), on: .portForwarding)
|
||||
var passwordField: NSSecureTextField?
|
||||
for width in [960.0, 360.0, 640.0, 641.0, 681.0, 960.0] {
|
||||
for width in [960.0, 360.0, 600.0, 601.0, 640.0, 641.0, 681.0, 960.0] {
|
||||
window.setContentSize(NSSize(width: width, height: 440))
|
||||
controller.view.frame = NSRect(x: 0, y: 0, width: width, height: 440)
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
@@ -172,7 +172,8 @@ struct PortForwardingRenderingTests {
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func embeddedJourneyDecisionsAndLogsFitShortNarrowColumns(_ language: String) async throws {
|
||||
let host = Host(name: "Production Database Gateway with a Long Name", host: "gateway.example.invalid")
|
||||
let host = Host(
|
||||
name: "AWS US-West-1 Production Database Gateway with a Long Name", host: "gateway.example.invalid")
|
||||
let jump = Host(name: "Jump Host", host: "jump.example.invalid")
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: [host, jump]))
|
||||
defer { app.shutdownForTermination() }
|
||||
@@ -208,7 +209,7 @@ struct PortForwardingRenderingTests {
|
||||
journey.showsLogs = true
|
||||
}
|
||||
let controller = NSHostingController(
|
||||
rootView: VaultDetailScaffold(title: "Port Forwarding", symbol: "arrow.left.arrow.right", close: {}) {
|
||||
rootView: VaultDetailScaffold(title: "\(host.name)", symbol: "arrow.left.arrow.right", close: {}) {
|
||||
ConnectionJourneyView(
|
||||
journey: journey, hostKey: hostKey,
|
||||
submitAuthentication: { _, _, _, _, _, _ in actions += 1 },
|
||||
@@ -224,7 +225,7 @@ struct PortForwardingRenderingTests {
|
||||
.environment(app)
|
||||
.environment(\.locale, Locale(identifier: language)))
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 440),
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 440),
|
||||
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
controller.sizingOptions = []
|
||||
@@ -238,7 +239,7 @@ struct PortForwardingRenderingTests {
|
||||
window.close()
|
||||
}
|
||||
var fields: [NSTextField]?
|
||||
for width in [400.0, 360.0, 400.0] {
|
||||
for width in [320.0, 360.0, 320.0] {
|
||||
window.setContentSize(NSSize(width: width, height: 440))
|
||||
controller.view.frame = NSRect(x: 0, y: 0, width: width, height: 440)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
@@ -8,6 +9,65 @@ import Testing
|
||||
@MainActor
|
||||
@Suite(.serialized)
|
||||
struct ResponsivePageLayoutTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func vaultEditorsKeepFieldsInsideNarrowColumns(_ language: String) async throws {
|
||||
let host = TermindDomain.Host(name: "Production Database Gateway with a Long Name", host: "server.invalid")
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
||||
let navigation = WindowModel()
|
||||
defer { app.shutdownForTermination() }
|
||||
let editors: [(String, VaultWorkRoute)] = [
|
||||
("host", .hostEditor(host.id)),
|
||||
("group", .hostGroupEditor(groupID: nil, parentID: nil)),
|
||||
("identity", .identityEditor(nil)),
|
||||
("known-host", .knownHostEditor(nil)),
|
||||
("forwarding", .forwardingEditor(nil)),
|
||||
("snippet", .snippetEditor(snippetID: nil, packageID: nil)),
|
||||
("package", .snippetPackageEditor(packageID: nil, parentID: nil)),
|
||||
("generate-key", .generateKey),
|
||||
]
|
||||
for (name, route) in editors {
|
||||
let controller = NSHostingController(
|
||||
rootView: VaultWorkDetailView(route: route).environment(app).environment(navigation)
|
||||
.environment(\.locale, Locale(identifier: language)))
|
||||
let window = NSWindow(
|
||||
contentRect: CGRect(x: 0, y: 0, width: 320, height: 640),
|
||||
styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
controller.sizingOptions = []
|
||||
window.contentViewController = controller
|
||||
window.setContentSize(CGSize(width: 320, height: 640))
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 320, height: 640)
|
||||
window.orderFront(nil)
|
||||
defer {
|
||||
window.orderOut(nil)
|
||||
window.contentViewController = nil
|
||||
window.close()
|
||||
}
|
||||
await layout(controller.view)
|
||||
#expect(abs(controller.view.bounds.width - 320) <= 1)
|
||||
let scroll = try #require(descendant(in: controller.view, ofType: NSScrollView.self))
|
||||
let document = try #require(scroll.documentView)
|
||||
#expect(document.frame.width <= scroll.contentView.bounds.width + 1, "\(name) overflows horizontally")
|
||||
#expect(scroll.contentInsets.left >= 0 && scroll.contentInsets.right >= 0)
|
||||
#expect(!scroll.hasHorizontalScroller)
|
||||
let fields = descendants(in: controller.view).compactMap { $0 as? NSTextField }
|
||||
.filter { $0.isEditable && !$0.isHiddenOrHasHiddenAncestor }
|
||||
#expect(!fields.isEmpty)
|
||||
for field in fields {
|
||||
let frame = field.convert(field.bounds, to: controller.view)
|
||||
#expect(frame.minX >= 0 && frame.maxX <= 321, "\(name): \(frame)")
|
||||
}
|
||||
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
|
||||
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
|
||||
let data = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
let filename = "macos-vault-\(name)-\(language)-320.png"
|
||||
Attachment.record(data, named: filename)
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-vault-ui")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: directory.appending(path: filename))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func metricsPagesAndResizeKeepTheProcessTableAndBothScrollPositions() async throws {
|
||||
let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english)
|
||||
@@ -97,10 +157,10 @@ struct ResponsivePageLayoutTests {
|
||||
let listView = try #require(list.view)
|
||||
let editorView = try #require(editor.view)
|
||||
editorView.stringValue = "Unsaved host name"
|
||||
#expect(abs(editorView.bounds.width - 400) <= 1)
|
||||
#expect(abs(editorView.bounds.width - 320) <= 1)
|
||||
let listSize = listView.bounds.size
|
||||
|
||||
for width in [360.0, 640.0] {
|
||||
for width in [360.0, 600.0] {
|
||||
host.frame.size.width = width
|
||||
await layout(host)
|
||||
#expect(editor.view === editorView)
|
||||
@@ -109,17 +169,17 @@ struct ResponsivePageLayoutTests {
|
||||
#expect(listView.bounds.size == listSize)
|
||||
}
|
||||
|
||||
host.frame.size.width = 641
|
||||
host.frame.size.width = 601
|
||||
await layout(host)
|
||||
#expect(editorView.stringValue == "Unsaved host name")
|
||||
#expect(abs(editorView.bounds.width - 360) <= 1)
|
||||
#expect(abs(editorView.bounds.width - 320) <= 1)
|
||||
#expect(abs(listView.bounds.width - 280) <= 1)
|
||||
|
||||
for width in [681.0, 900.0] {
|
||||
for width in [640.0, 641.0, 681.0, 900.0] {
|
||||
host.frame.size.width = width
|
||||
await layout(host)
|
||||
#expect(abs(editorView.bounds.width - 400) <= 1)
|
||||
#expect(abs(listView.bounds.width - (width - 401)) <= 1)
|
||||
#expect(abs(editorView.bounds.width - 320) <= 1)
|
||||
#expect(abs(listView.bounds.width - (width - 321)) <= 1)
|
||||
#expect(editorView.stringValue == "Unsaved host name")
|
||||
}
|
||||
|
||||
@@ -133,7 +193,7 @@ struct ResponsivePageLayoutTests {
|
||||
#expect(editor.creations == 1)
|
||||
}
|
||||
|
||||
@Test(arguments: [360.0, 900.0])
|
||||
@Test(arguments: [360.0, 600.0, 601.0, 900.0])
|
||||
func vaultOpeningAndClosingSlideWithoutSqueezingTheEditor(_ width: CGFloat) async throws {
|
||||
let list = Probe()
|
||||
let editor = Probe()
|
||||
@@ -154,8 +214,8 @@ struct ResponsivePageLayoutTests {
|
||||
let frame = editorView.convert(editorView.bounds, to: host)
|
||||
if progress == 0.5 {
|
||||
#expect(frame.minX > width - editorWidth && frame.minX < width)
|
||||
if width == 900 {
|
||||
#expect(listView.bounds.width > 499 && listView.bounds.width < 900)
|
||||
if width >= 601 {
|
||||
#expect(abs(listView.bounds.width - (width - 321 * progress)) <= 1)
|
||||
}
|
||||
} else if progress == 1 {
|
||||
#expect(abs(frame.maxX - width) <= 1)
|
||||
@@ -181,6 +241,10 @@ struct ResponsivePageLayoutTests {
|
||||
return view.subviews.lazy.compactMap { descendant(in: $0, ofType: type) }.first
|
||||
}
|
||||
|
||||
private func descendants(in view: NSView) -> [NSView] {
|
||||
[view] + view.subviews.flatMap { descendants(in: $0) }
|
||||
}
|
||||
|
||||
private final class Probe {
|
||||
var view: NSTextField?
|
||||
var creations = 0
|
||||
|
||||
Reference in new issue
Block a user