From d1ac31c0273e4f64581e9033de6589d98c32f56d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 03:44:10 +0700 Subject: [PATCH 1/3] feat(ios): present every toolbar control vertically on iPhone Duo and size content to the display --- CHANGELOG.md | 5 + .../Views/Components/HairlineThickness.swift | 10 ++ .../Views/Components/RowCard.swift | 9 +- .../Components/SQLHighlightTextView.swift | 9 +- .../TableProMobile/Views/ConnectedView.swift | 24 ++--- .../Views/ConnectionListView.swift | 22 +++-- .../Views/DataBrowserView.swift | 23 +++-- .../Views/DuoLayoutResolver.swift | 67 +++++++++++++ .../Views/QueryEditorView.swift | 26 ++++- .../TableProMobile/Views/RowDetailView.swift | 6 +- .../TableProMobile/Views/SafeModeBadge.swift | 28 ++++++ .../Views/TagManagementView.swift | 3 +- .../Views/BottomSafeAreaBarLayoutTests.swift | 9 ++ .../Views/DuoLayoutResolverTests.swift | 95 +++++++++++++++++++ .../Views/HairlineThicknessTests.swift | 24 +++++ .../Views/SafeModeBadgeTests.swift | 48 ++++++++++ 16 files changed, 361 insertions(+), 47 deletions(-) create mode 100644 TableProMobile/TableProMobile/Views/Components/HairlineThickness.swift create mode 100644 TableProMobile/TableProMobile/Views/DuoLayoutResolver.swift create mode 100644 TableProMobile/TableProMobile/Views/SafeModeBadge.swift create mode 100644 TableProMobile/TableProMobileTests/Views/DuoLayoutResolverTests.swift create mode 100644 TableProMobile/TableProMobileTests/Views/HairlineThicknessTests.swift create mode 100644 TableProMobile/TableProMobileTests/Views/SafeModeBadgeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 38191a6e45..099639b869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Agent** mode, giving one session the whole connection window: its sessions, its conversation, and what it ran. +- Row previews and the query editor sized to the display on iPad and on iPhone Duo's inner display. - **View > Mode**, with **Toggle Agent Mode** on ⌥⇧⌘A. - Agent mode holds its connection at Safe Mode **Alert** while it is on, and hands back the level you set on the way out. - **Open in Agent Mode** on a connection in the welcome window. @@ -48,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A column resized moments before its table was dropped saving the layout back over the clear. - A connection left pointing at a database that was dropped. - A dropped table's saved filters, column layout, highlight rules, value formats and label columns coming back on a table recreated with its name. +- Database switcher, Safe Mode badge and multi-select actions missing from the side bar on iPhone Duo. +- Sort, Filter, More, Insert Row, Share, Edit and Add Tag missing from the side bar on iPhone Duo. +- Safe Mode badge with no accessibility label. +- Keyboard bar separator measured against the main screen instead of the display the editor is on. - A dropped database or schema leaving every one of its tables' saved settings behind. - Favorites rows left behind by a dropped table, schema or database. - Column and operator pull-downs in **Highlight Rules** snapping back to their previous value, leaving every rule on **equals** and on the column it was created with. (#3015) diff --git a/TableProMobile/TableProMobile/Views/Components/HairlineThickness.swift b/TableProMobile/TableProMobile/Views/Components/HairlineThickness.swift new file mode 100644 index 0000000000..78f72c2efa --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/HairlineThickness.swift @@ -0,0 +1,10 @@ +import CoreGraphics + +/// A detached view reports a display scale of zero until it joins a window, and `1 / 0` is a +/// constant `NSLayoutConstraint` rejects, so the scale is clamped before it becomes a thickness. +nonisolated enum HairlineThickness { + static func points(forDisplayScale scale: CGFloat) -> CGFloat { + guard scale.isFinite, scale >= 1 else { return 1 } + return 1 / scale + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/RowCard.swift b/TableProMobile/TableProMobile/Views/Components/RowCard.swift index d3fb3ad46c..16d9a71b7f 100644 --- a/TableProMobile/TableProMobile/Views/Components/RowCard.swift +++ b/TableProMobile/TableProMobile/Views/Components/RowCard.swift @@ -5,8 +5,7 @@ struct RowCard: View { let columns: [ColumnInfo] let columnDetails: [ColumnInfo] let row: [String?] - - private static let maxPreview = 4 + var previewFieldCount: Int = DuoLayoutResolver.compactPreviewFieldCount private var pkNames: Set { Set(columnDetails.filter(\.isPrimaryKey).map(\.name)) @@ -26,7 +25,7 @@ struct RowCard: View { let title = titlePair?.name return zip(columns, row) .filter { !pks.contains($0.0.name) && $0.0.name != title } - .prefix(Self.maxPreview - 1) + .prefix(max(previewFieldCount - 1, 0)) .map { ($0.0.name, $0.1 ?? "NULL") } } @@ -56,8 +55,8 @@ struct RowCard: View { } } - if columns.count > Self.maxPreview { - Text("+\(columns.count - Self.maxPreview) more columns") + if columns.count > previewFieldCount { + Text("+\(columns.count - previewFieldCount) more columns") .font(.caption2) .foregroundStyle(.quaternary) } diff --git a/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift b/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift index e5cf639d67..7987c2dec8 100644 --- a/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift +++ b/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift @@ -96,6 +96,13 @@ struct SQLHighlightTextView: UIViewRepresentable { separator.translatesAutoresizingMaskIntoConstraints = false toolbar.addSubview(separator) + let separatorHeight = separator.heightAnchor.constraint( + equalToConstant: HairlineThickness.points(forDisplayScale: toolbar.traitCollection.displayScale) + ) + toolbar.registerForTraitChanges([UITraitDisplayScale.self]) { (view: UIView, _) in + separatorHeight.constant = HairlineThickness.points(forDisplayScale: view.traitCollection.displayScale) + } + let scrollView = UIScrollView() scrollView.showsHorizontalScrollIndicator = false scrollView.translatesAutoresizingMaskIntoConstraints = false @@ -127,7 +134,7 @@ struct SQLHighlightTextView: UIViewRepresentable { separator.topAnchor.constraint(equalTo: toolbar.topAnchor), separator.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor), separator.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor), - separator.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), + separatorHeight, scrollView.topAnchor.constraint(equalTo: toolbar.topAnchor), scrollView.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor, constant: 8), diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 7ddd5abfdf..7178ccea3d 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -265,16 +265,14 @@ struct ConnectedView: View { Button { presenter.presentConnectionEditor(for: connection.id) } label: { - Image(systemName: "pencil") - .accessibilityLabel(Text("Edit Connection")) + Label("Edit Connection", systemImage: "pencil") } } } - if connection.safeModeLevel != .off { + if let badge = SafeModeBadge(level: connection.safeModeLevel) { ToolbarItem(placement: .topBarTrailing) { - Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") - .foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange) - .font(.caption) + Label(badge.title, systemImage: badge.symbolName) + .foregroundStyle(badge.tint == .blocked ? Color.red : Color.orange) } } if coordinator.supportsDatabaseSwitching && coordinator.databases.count > 1 { @@ -292,18 +290,8 @@ struct ConnectedView: View { } } } label: { - HStack(spacing: 4) { - Text(coordinator.activeDatabase) - .font(.subheadline) - if coordinator.isSwitching { - ProgressView() - .controlSize(.mini) - } else { - Image(systemName: "chevron.down") - .font(.caption2) - .foregroundStyle(.secondary) - } - } + Label(coordinator.activeDatabase, systemImage: "cylinder.split.1x2") + .font(.subheadline) } .disabled(coordinator.isSwitching) } diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index b9b64a4a60..c5982a5dfd 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -566,10 +566,16 @@ struct ConnectionListView: View { ToolbarItemGroup(placement: .topBarTrailing) { moreMenu if hasLibraryItems { - Button(isEditing ? String(localized: "Done") : String(localized: "Edit")) { + Button { withAnimation { editMode = isEditing ? .inactive : .active } + } label: { + if isEditing { + Label("Done", systemImage: "checkmark") + } else { + Label("Edit", systemImage: "checklist") + } } } Button { @@ -583,15 +589,17 @@ struct ConnectionListView: View { if isEditing { ToolbarItemGroup(placement: .bottomBar) { let ids = selectedConnectionIds - Button("Move") { + Button { presenter.present(.moveConnections(ids)) + } label: { + Label("Move", systemImage: "folder") } .disabled(ids.isEmpty) - Spacer() selectionFavoriteButton(ids) - Spacer() - Button(String(localized: "Delete"), role: .destructive) { + Button(role: .destructive) { connectionsPendingDeletion = Set(ids) + } label: { + Label("Delete", systemImage: "trash") } .disabled(ids.isEmpty) } @@ -606,9 +614,9 @@ struct ConnectionListView: View { appState.setFavorite(selected, isFavorite: !allFavorites) } label: { if allFavorites { - Text("Unfavorite") + Label("Unfavorite", systemImage: "star.slash") } else { - Text("Favorite") + Label("Favorite", systemImage: "star") } } .disabled(ids.isEmpty) diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index f2d4fb0ec1..ef80e337d4 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -6,6 +6,7 @@ import TableProQuery struct DataBrowserView: View { @Environment(AppState.self) private var appState @Environment(ConnectionCoordinator.self) private var coordinator + @Environment(\.horizontalSizeClass) private var horizontalSizeClass let table: TableInfo private var connection: DatabaseConnection { coordinator.connection } @@ -287,7 +288,12 @@ struct DataBrowserView: View { } ) } label: { - RowCard(columns: columns, columnDetails: viewModel.columnDetails, row: row) + RowCard( + columns: columns, + columnDetails: viewModel.columnDetails, + row: row, + previewFieldCount: DuoLayoutResolver.previewFieldCount(for: duoWidthClass) + ) } .hoverEffect() .contextMenu { rowContextMenu(row: row) } @@ -377,19 +383,17 @@ struct DataBrowserView: View { .pickerStyle(.inline) } } label: { - Image(systemName: viewModel.sortState.isSorting + Label("Sort", systemImage: viewModel.sortState.isSorting ? "arrow.up.arrow.down.circle.fill" : "arrow.up.arrow.down.circle") - .accessibilityLabel(Text("Sort")) } .disabled(columns.isEmpty) } ToolbarItem(placement: .topBarTrailing) { Button { showFilterSheet = true } label: { - Image(systemName: viewModel.hasActiveFilters + Label("Filter", systemImage: viewModel.hasActiveFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") - .accessibilityLabel(Text("Filter")) } .badge(viewModel.activeFilterCount) } @@ -414,19 +418,22 @@ struct DataBrowserView: View { } } } label: { - Image(systemName: "ellipsis.circle") + Label("More", systemImage: "ellipsis.circle") } } if canInsertRow { ToolbarItem(placement: .primaryAction) { Button { showInsertSheet = true } label: { - Image(systemName: "plus") - .accessibilityLabel(Text("Insert Row")) + Label("Insert Row", systemImage: "plus") } } } } + private var duoWidthClass: DuoWidthClass { + horizontalSizeClass == .regular ? .regular : .compact + } + private var showsPaginationBar: Bool { viewModel.showsPaginationBar && !searchFocused } diff --git a/TableProMobile/TableProMobile/Views/DuoLayoutResolver.swift b/TableProMobile/TableProMobile/Views/DuoLayoutResolver.swift new file mode 100644 index 0000000000..8ccd2bbd4f --- /dev/null +++ b/TableProMobile/TableProMobile/Views/DuoLayoutResolver.swift @@ -0,0 +1,67 @@ +import CoreGraphics +import Foundation + +nonisolated enum DuoWidthClass: Equatable, Sendable { + case compact + case regular +} + +nonisolated struct DuoLayoutContext: Equatable, Sendable { + let size: CGSize + let widthClass: DuoWidthClass + let showsResult: Bool + + init(size: CGSize, widthClass: DuoWidthClass, showsResult: Bool = false) { + self.size = size + self.widthClass = widthClass + self.showsResult = showsResult + } +} + +nonisolated struct DuoLayoutPlan: Equatable, Sendable { + let previewFieldCount: Int + let editorMaxHeight: CGFloat + let revealsDetailAlongsideList: Bool + + static let compactDefault = DuoLayoutPlan( + previewFieldCount: DuoLayoutResolver.compactPreviewFieldCount, + editorMaxHeight: DuoLayoutResolver.compactEditorHeightWithoutResult, + revealsDetailAlongsideList: false + ) +} + +nonisolated enum DuoLayoutResolver { + static let compactPreviewFieldCount = 4 + static let regularPreviewFieldCount = 8 + static let compactEditorHeightWithResult: CGFloat = 120 + static let compactEditorHeightWithoutResult: CGFloat = 250 + static let minimumEditorHeight: CGFloat = 120 + static let maximumEditorHeight: CGFloat = 640 + + static func plan(for context: DuoLayoutContext) -> DuoLayoutPlan { + DuoLayoutPlan( + previewFieldCount: previewFieldCount(for: context.widthClass), + editorMaxHeight: editorMaxHeight(for: context), + revealsDetailAlongsideList: context.widthClass == .regular + ) + } + + static func previewFieldCount(for widthClass: DuoWidthClass) -> Int { + switch widthClass { + case .compact: return compactPreviewFieldCount + case .regular: return regularPreviewFieldCount + } + } + + static func editorMaxHeight(for context: DuoLayoutContext) -> CGFloat { + guard context.widthClass == .regular else { + return context.showsResult ? compactEditorHeightWithResult : compactEditorHeightWithoutResult + } + let height = context.size.height + guard height.isFinite, height > 0 else { + return context.showsResult ? compactEditorHeightWithResult : compactEditorHeightWithoutResult + } + let fraction: CGFloat = context.showsResult ? 0.38 : 0.6 + return min(max(height * fraction, minimumEditorHeight), maximumEditorHeight) + } +} diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 74dc3f738f..9488c7ea31 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -7,6 +7,7 @@ import TableProQuery struct QueryEditorView: View { @Environment(ConnectionCoordinator.self) private var coordinator @Environment(AppState.self) private var appState + @Environment(\.horizontalSizeClass) private var horizontalSizeClass private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorView") @@ -35,6 +36,7 @@ struct QueryEditorView: View { @State private var shareText = "" @State private var hapticSuccess = false @State private var hapticError = false + @State private var containerSize: CGSize = .zero private var session: ConnectionSession? { coordinator.session } private var tables: [TableInfo] { coordinator.tables } @@ -48,6 +50,7 @@ struct QueryEditorView: View { Divider() resultSection } + .onGeometryChange(for: CGSize.self) { $0.size } action: { containerSize = $0 } .onAppear { if let pending = coordinator.pendingQuery { query = pending @@ -114,10 +117,24 @@ struct QueryEditorView: View { // MARK: - Editor + private var duoWidthClass: DuoWidthClass { + horizontalSizeClass == .regular ? .regular : .compact + } + + private var editorMaxHeight: CGFloat { + DuoLayoutResolver.editorMaxHeight( + for: DuoLayoutContext( + size: containerSize, + widthClass: duoWidthClass, + showsResult: hasResult || appError != nil + ) + ) + } + private var editorSection: some View { VStack(spacing: 0) { SQLHighlightTextView(text: $query, isFocused: $editorFocused) - .frame(minHeight: 80, maxHeight: hasResult || appError != nil ? 120 : 250) + .frame(minHeight: 80, maxHeight: editorMaxHeight) actionBar } @@ -280,7 +297,8 @@ struct QueryEditorView: View { } private func resultRowCard(columns: [ColumnInfo], row: [String?]) -> some View { - let preview = Array(zip(columns, row).prefix(4)) + let budget = DuoLayoutResolver.previewFieldCount(for: duoWidthClass) + let preview = Array(zip(columns, row).prefix(budget)) return VStack(alignment: .leading, spacing: 4) { ForEach(Array(preview.enumerated()), id: \.offset) { index, pair in HStack(spacing: 6) { @@ -294,8 +312,8 @@ struct QueryEditorView: View { .lineLimit(1) } } - if columns.count > 4 { - Text("+\(columns.count - 4) more columns") + if columns.count > budget { + Text("+\(columns.count - budget) more columns") .font(.caption2) .foregroundStyle(.quaternary) } diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 4c8e8b0607..9ba60d14e9 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -126,7 +126,7 @@ struct RowDetailView: View { Menu { shareMenuContent } label: { - Image(systemName: "square.and.arrow.up") + Label("Share", systemImage: "square.and.arrow.up") } } @@ -138,7 +138,9 @@ struct RowDetailView: View { } .disabled(viewModel.isSaving) } else { - Button("Edit") { viewModel.startEditing() } + Button { viewModel.startEditing() } label: { + Label("Edit", systemImage: "pencil") + } } } } diff --git a/TableProMobile/TableProMobile/Views/SafeModeBadge.swift b/TableProMobile/TableProMobile/Views/SafeModeBadge.swift new file mode 100644 index 0000000000..25fd7e6a73 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/SafeModeBadge.swift @@ -0,0 +1,28 @@ +import Foundation +import TableProModels + +nonisolated enum SafeModeBadgeTint: Equatable, Sendable { + case blocked + case cautioned +} + +nonisolated struct SafeModeBadge: Equatable, Sendable { + let symbolName: String + let title: String + let tint: SafeModeBadgeTint + + init?(level: SafeModeLevel) { + switch level { + case .off: + return nil + case .readOnly: + symbolName = "lock.fill" + title = String(localized: "Read-Only") + tint = .blocked + case .confirmWrites: + symbolName = "shield.fill" + title = String(localized: "Confirm Writes") + tint = .cautioned + } + } +} diff --git a/TableProMobile/TableProMobile/Views/TagManagementView.swift b/TableProMobile/TableProMobile/Views/TagManagementView.swift index 25053ceb22..8da4e91150 100644 --- a/TableProMobile/TableProMobile/Views/TagManagementView.swift +++ b/TableProMobile/TableProMobile/Views/TagManagementView.swift @@ -102,9 +102,8 @@ struct TagManagementView: View { Button { showingAddTag = true } label: { - Image(systemName: "plus") + Label("Add Tag", systemImage: "plus") } - .accessibilityLabel(Text("Add Tag")) CloseButton { dismiss() } } } diff --git a/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift b/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift index ba1aecba79..e61dd4db0d 100644 --- a/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift +++ b/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift @@ -97,6 +97,10 @@ private struct HostedTree { return "No visible tab bar. List insets \(probe.listInsets), marker \(probe.markerFrame)" } let tabBarFrame = tabBar.convert(tabBar.bounds, to: window) + guard Self.isHorizontalBand(tabBarFrame) else { + return "The tab bar is drawn vertically at \(tabBarFrame) in window \(window.bounds), " + + "so it owes the content no bottom inset. Marker \(probe.markerFrame)" + } return "The list's bottom inset \(probe.listInsets.bottom) never covered the tab bar band " + "\(window.bounds.maxY - tabBarFrame.minY). Window \(window.bounds), tab bar \(tabBarFrame), " + "marker \(probe.markerFrame), list insets \(probe.listInsets)" @@ -110,10 +114,15 @@ private struct HostedTree { private func isSettled(against tabBar: UITabBar) -> Bool { let tabBarFrame = tabBar.convert(tabBar.bounds, to: window) guard !tabBarFrame.isEmpty else { return false } + guard Self.isHorizontalBand(tabBarFrame) else { return true } let tabBarBand = window.bounds.maxY - tabBarFrame.minY return probe.listInsets.bottom >= tabBarBand - 0.5 } + static func isHorizontalBand(_ frame: CGRect) -> Bool { + frame.width > frame.height + } + private func visibleTabBar(in view: UIView) -> UITabBar? { if let tabBar = view as? UITabBar, !tabBar.isHidden, tabBar.alpha > 0.01 { return tabBar diff --git a/TableProMobile/TableProMobileTests/Views/DuoLayoutResolverTests.swift b/TableProMobile/TableProMobileTests/Views/DuoLayoutResolverTests.swift new file mode 100644 index 0000000000..ba0f70511c --- /dev/null +++ b/TableProMobile/TableProMobileTests/Views/DuoLayoutResolverTests.swift @@ -0,0 +1,95 @@ +import CoreGraphics +@testable import TableProMobile +import Testing + +@Suite("Duo layout resolver") +struct DuoLayoutResolverTests { + private static let outerDisplay = CGSize(width: 466, height: 678) + private static let innerDisplay = CGSize(width: 669, height: 951) + private static let innerDisplayLandscape = CGSize(width: 951, height: 669) + + @Test("A compact width keeps the four field previews the phone layout was built for") + func compactPreviewBudgetIsUnchanged() { + #expect(DuoLayoutResolver.previewFieldCount(for: .compact) == 4) + } + + @Test("A regular width spends its extra room on more fields") + func regularPreviewBudgetIsLarger() { + let compact = DuoLayoutResolver.previewFieldCount(for: .compact) + let regular = DuoLayoutResolver.previewFieldCount(for: .regular) + #expect(regular > compact) + } + + @Test("Every preview budget leaves at least a title pair and one detail row") + func previewBudgetIsUsable() { + for widthClass in [DuoWidthClass.compact, .regular] { + #expect(DuoLayoutResolver.previewFieldCount(for: widthClass) >= 2) + } + } + + @Test("A compact width keeps the editor heights the phone layout shipped with") + func compactEditorHeightIsUnchanged() { + let withResult = DuoLayoutContext(size: Self.outerDisplay, widthClass: .compact, showsResult: true) + let withoutResult = DuoLayoutContext(size: Self.outerDisplay, widthClass: .compact, showsResult: false) + + #expect(DuoLayoutResolver.editorMaxHeight(for: withResult) == 120) + #expect(DuoLayoutResolver.editorMaxHeight(for: withoutResult) == 250) + } + + @Test("A regular width gives the editor more room than the compact phone height") + func regularEditorHeightGrows() { + let context = DuoLayoutContext(size: Self.innerDisplay, widthClass: .regular, showsResult: true) + #expect(DuoLayoutResolver.editorMaxHeight(for: context) > 120) + } + + @Test("An editor with no result to show is taller than one sharing the screen") + func editorShrinksForAResult() { + let withResult = DuoLayoutContext(size: Self.innerDisplay, widthClass: .regular, showsResult: true) + let withoutResult = DuoLayoutContext(size: Self.innerDisplay, widthClass: .regular, showsResult: false) + + #expect( + DuoLayoutResolver.editorMaxHeight(for: withoutResult) + > DuoLayoutResolver.editorMaxHeight(for: withResult) + ) + } + + @Test("The editor never takes so much of a short container that the result is squeezed out") + func editorLeavesRoomForTheResult() { + for size in [Self.outerDisplay, Self.innerDisplay, Self.innerDisplayLandscape] { + let context = DuoLayoutContext(size: size, widthClass: .regular, showsResult: true) + let height = DuoLayoutResolver.editorMaxHeight(for: context) + #expect(height <= size.height / 2) + } + } + + @Test("A container that has not been measured yet falls back to the compact heights") + func unmeasuredContainerFallsBack() { + let zero = DuoLayoutContext(size: .zero, widthClass: .regular, showsResult: true) + let infinite = DuoLayoutContext( + size: CGSize(width: 100, height: CGFloat.infinity), + widthClass: .regular, + showsResult: true + ) + + #expect(DuoLayoutResolver.editorMaxHeight(for: zero) == 120) + #expect(DuoLayoutResolver.editorMaxHeight(for: infinite) == 120) + } + + @Test("Only a regular width reveals a detail beside the list") + func detailRevealFollowsWidth() { + let compact = DuoLayoutContext(size: Self.outerDisplay, widthClass: .compact) + let regular = DuoLayoutContext(size: Self.innerDisplay, widthClass: .regular) + + #expect(DuoLayoutResolver.plan(for: compact).revealsDetailAlongsideList == false) + #expect(DuoLayoutResolver.plan(for: regular).revealsDetailAlongsideList) + } + + @Test("A plan carries the same answers the individual resolvers give") + func planAgreesWithItsParts() { + let context = DuoLayoutContext(size: Self.innerDisplay, widthClass: .regular, showsResult: true) + let plan = DuoLayoutResolver.plan(for: context) + + #expect(plan.previewFieldCount == DuoLayoutResolver.previewFieldCount(for: .regular)) + #expect(plan.editorMaxHeight == DuoLayoutResolver.editorMaxHeight(for: context)) + } +} diff --git a/TableProMobile/TableProMobileTests/Views/HairlineThicknessTests.swift b/TableProMobile/TableProMobileTests/Views/HairlineThicknessTests.swift new file mode 100644 index 0000000000..75ae824563 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Views/HairlineThicknessTests.swift @@ -0,0 +1,24 @@ +import CoreGraphics +@testable import TableProMobile +import Testing + +@Suite("Hairline thickness") +struct HairlineThicknessTests { + @Test("A hairline is one device pixel on every scale a display reports", arguments: [ + (CGFloat(1), CGFloat(1)), + (CGFloat(2), CGFloat(0.5)), + (CGFloat(3), CGFloat(1.0 / 3.0)), + ]) + func scaleBecomesOnePixel(scale: CGFloat, expected: CGFloat) { + #expect(HairlineThickness.points(forDisplayScale: scale) == expected) + } + + @Test("A detached view reporting no scale yet gets a usable thickness, never infinity") + func unspecifiedScaleIsClamped() { + for scale in [CGFloat(0), -1, 0.5, .nan, .infinity] { + let thickness = HairlineThickness.points(forDisplayScale: scale) + #expect(thickness.isFinite) + #expect(thickness > 0) + } + } +} diff --git a/TableProMobile/TableProMobileTests/Views/SafeModeBadgeTests.swift b/TableProMobile/TableProMobileTests/Views/SafeModeBadgeTests.swift new file mode 100644 index 0000000000..e818a73c0c --- /dev/null +++ b/TableProMobile/TableProMobileTests/Views/SafeModeBadgeTests.swift @@ -0,0 +1,48 @@ +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Safe mode badge") +struct SafeModeBadgeTests { + @Test("Safe Mode off shows no badge") + func offHasNoBadge() { + #expect(SafeModeBadge(level: .off) == nil) + } + + @Test("Read-only reads as blocked") + func readOnlyIsBlocked() throws { + let badge = try #require(SafeModeBadge(level: .readOnly)) + + #expect(badge.tint == .blocked) + #expect(badge.symbolName == "lock.fill") + #expect(!badge.title.isEmpty) + } + + @Test("Confirm writes reads as cautioned") + func confirmWritesIsCautioned() throws { + let badge = try #require(SafeModeBadge(level: .confirmWrites)) + + #expect(badge.tint == .cautioned) + #expect(badge.symbolName == "shield.fill") + #expect(!badge.title.isEmpty) + } + + @Test("Every level that blocks or questions a write carries a symbol and a title") + func everyActiveLevelIsPresentableVertically() { + for level in SafeModeLevel.allCases where level != .off { + let badge = SafeModeBadge(level: level) + #expect(badge != nil) + #expect(badge?.symbolName.isEmpty == false) + #expect(badge?.title.isEmpty == false) + } + } + + @Test("The two active levels are told apart") + func activeLevelsAreDistinct() throws { + let readOnly = try #require(SafeModeBadge(level: .readOnly)) + let confirmWrites = try #require(SafeModeBadge(level: .confirmWrites)) + + #expect(readOnly != confirmWrites) + #expect(readOnly.symbolName != confirmWrites.symbolName) + } +} From 8e258e5682ca6ee48740ec24b553f7cb995aec8a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 04:01:03 +0700 Subject: [PATCH 2/3] feat(ios): show the table list and the browser side by side where the display has room --- CHANGELOG.md | 1 + .../Coordinators/ConnectionCoordinator.swift | 13 +- .../TableProMobile/Views/ConnectedView.swift | 19 ++- .../TableProMobile/Views/TableListView.swift | 8 +- .../Views/TableSelectionResolver.swift | 14 +++ .../ConnectionCoordinatorStoreTests.swift | 4 +- .../Views/TableSelectionResolverTests.swift | 48 ++++++++ .../Views/TablesSplitViewLayoutTests.swift | 115 ++++++++++++++++++ 8 files changed, 205 insertions(+), 17 deletions(-) create mode 100644 TableProMobile/TableProMobile/Views/TableSelectionResolver.swift create mode 100644 TableProMobile/TableProMobileTests/Views/TableSelectionResolverTests.swift create mode 100644 TableProMobile/TableProMobileTests/Views/TablesSplitViewLayoutTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 099639b869..6529349111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Agent** mode, giving one session the whole connection window: its sessions, its conversation, and what it ran. - Row previews and the query editor sized to the display on iPad and on iPhone Duo's inner display. +- Table list and table browser side by side on iPad and on iPhone Duo's inner display. - **View > Mode**, with **Toggle Agent Mode** on ⌥⇧⌘A. - Agent mode holds its connection at Safe Mode **Alert** while it is on, and hands back the level you set on the way out. - **Open in Agent Mode** on a connection in the welcome window. diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 3793c41b48..c6d1b8ba68 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -11,7 +11,9 @@ final class ConnectionCoordinator { private(set) var session: ConnectionSession? private(set) var phase: ConnectionPhase = .connecting - private(set) var tables: [TableInfo] = [] + private(set) var tables: [TableInfo] = [] { + didSet { selectedTable = TableSelectionResolver.keeping(selectedTable, in: tables) } + } private(set) var databases: [String] = [] private(set) var schemas: [String] = [] private(set) var activeDatabase: String = "" @@ -29,7 +31,7 @@ final class ConnectionCoordinator { } var pendingQuery: String? var pendingTableName: String? - var tablesPath = NavigationPath() + var selectedTable: TableInfo? private(set) var queryHistory: [QueryHistoryItem] = [] private let historyStorage = QueryHistoryStorage() @@ -371,13 +373,10 @@ final class ConnectionCoordinator { } func navigateToPendingTable() { - guard let tableName = pendingTableName, - let table = tables.first(where: { $0.name == tableName }) else { return } + guard let table = TableSelectionResolver.resolve(pendingName: pendingTableName, in: tables) else { return } pendingTableName = nil selectedTab = .tables - Task { @MainActor in - tablesPath.append(table) - } + selectedTable = table } // MARK: - Private Helpers diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 7178ccea3d..565fa0870b 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -157,15 +157,26 @@ struct ConnectedView: View { @Bindable var coordinator = coordinator return TabView(selection: $coordinator.selectedTab) { Tab("Tables", systemImage: "tablecells", value: .tables) { - NavigationStack(path: $coordinator.tablesPath) { + NavigationSplitView { tabChrome(coordinator) { TableListView(connectionId: connection.id) } - .navigationDestination(for: TableInfo.self) { table in - DataBrowserView(table: table) - .environment(coordinator) + } detail: { + NavigationStack { + if let table = coordinator.selectedTable { + DataBrowserView(table: table) + .environment(coordinator) + .id(table) + } else { + ContentUnavailableView( + "No Table Selected", + systemImage: "tablecells", + description: Text("Pick a table to browse its rows.") + ) + } } } + .navigationSplitViewStyle(.balanced) } Tab("Query", systemImage: "terminal", value: .query) { NavigationStack { diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index e4a873aea7..534e2936f8 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -75,13 +75,13 @@ struct TableListView: View { } var body: some View { - List { + @Bindable var coordinator = coordinator + return List(selection: $coordinator.selectedTable) { ForEach(tableSections, id: \.0) { sectionTitle, items in Section { ForEach(items) { table in - NavigationLink(value: table) { - TableRow(table: table) - } + TableRow(table: table) + .tag(table) .contextMenu { Button { ClipboardExporter.copyToClipboard(table.name) diff --git a/TableProMobile/TableProMobile/Views/TableSelectionResolver.swift b/TableProMobile/TableProMobile/Views/TableSelectionResolver.swift new file mode 100644 index 0000000000..ee602d67ae --- /dev/null +++ b/TableProMobile/TableProMobile/Views/TableSelectionResolver.swift @@ -0,0 +1,14 @@ +import Foundation +import TableProModels + +nonisolated enum TableSelectionResolver { + static func resolve(pendingName: String?, in tables: [TableInfo]) -> TableInfo? { + guard let pendingName else { return nil } + return tables.first { $0.name == pendingName } + } + + static func keeping(_ selection: TableInfo?, in tables: [TableInfo]) -> TableInfo? { + guard let selection else { return nil } + return tables.contains(selection) ? selection : nil + } +} diff --git a/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift index dc48fa52da..a2d6cf65e9 100644 --- a/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift @@ -153,7 +153,7 @@ struct ConnectionCoordinatorStoreTests { func reorderKeepsTheCoordinator() { let original = connection("A") let coordinator = store.coordinator(for: original, appState: appState) - coordinator.tablesPath.append(TableInfo(name: "users")) + coordinator.selectedTable = TableInfo(name: "users") var reordered = original reordered.sortOrder = 5 @@ -162,7 +162,7 @@ struct ConnectionCoordinatorStoreTests { let resolved = store.coordinator(for: reordered, appState: appState) #expect(resolved === coordinator) #expect(resolved.connection.sortOrder == 5) - #expect(resolved.tablesPath.count == 1) + #expect(resolved.selectedTable?.name == "users") #expect(store.generation(for: original.id) == 0) } diff --git a/TableProMobile/TableProMobileTests/Views/TableSelectionResolverTests.swift b/TableProMobile/TableProMobileTests/Views/TableSelectionResolverTests.swift new file mode 100644 index 0000000000..f37e9ad962 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Views/TableSelectionResolverTests.swift @@ -0,0 +1,48 @@ +@testable import TableProMobile +@testable import TableProModels +import Testing + +@Suite("Table selection resolver") +struct TableSelectionResolverTests { + private static func table(_ name: String) -> TableInfo { + TableInfo(name: name, type: .table) + } + + private static let catalog = [table("albums"), table("artists"), table("tracks")] + + @Test("A pending name resolves to the table it names") + func pendingNameResolves() throws { + let resolved = try #require(TableSelectionResolver.resolve(pendingName: "artists", in: Self.catalog)) + #expect(resolved.name == "artists") + } + + @Test("No pending name selects nothing") + func noPendingNameSelectsNothing() { + #expect(TableSelectionResolver.resolve(pendingName: nil, in: Self.catalog) == nil) + } + + @Test("A name no table carries selects nothing, so the pending name can resolve once tables load") + func unknownNameSelectsNothing() { + #expect(TableSelectionResolver.resolve(pendingName: "invoices", in: Self.catalog) == nil) + #expect(TableSelectionResolver.resolve(pendingName: "albums", in: []) == nil) + } + + @Test("A selection the catalog still carries is kept") + func liveSelectionIsKept() throws { + let selection = Self.catalog[1] + let kept = try #require(TableSelectionResolver.keeping(selection, in: Self.catalog)) + #expect(kept == selection) + } + + @Test("A selection the catalog dropped is cleared rather than left pointing at a table that is gone") + func staleSelectionIsCleared() { + let dropped = Self.table("invoices") + #expect(TableSelectionResolver.keeping(dropped, in: Self.catalog) == nil) + #expect(TableSelectionResolver.keeping(Self.catalog[0], in: []) == nil) + } + + @Test("Keeping nothing stays nothing") + func noSelectionStaysEmpty() { + #expect(TableSelectionResolver.keeping(nil, in: Self.catalog) == nil) + } +} diff --git a/TableProMobile/TableProMobileTests/Views/TablesSplitViewLayoutTests.swift b/TableProMobile/TableProMobileTests/Views/TablesSplitViewLayoutTests.swift new file mode 100644 index 0000000000..6bb1a9cd7e --- /dev/null +++ b/TableProMobile/TableProMobileTests/Views/TablesSplitViewLayoutTests.swift @@ -0,0 +1,115 @@ +import SwiftUI +@testable import TableProMobile +import Testing +import UIKit + +@MainActor +@Suite("Tables split view layout") +struct TablesSplitViewLayoutTests { + @Test("A regular width shows the table list and the browser at once, the way the inner display should") + func regularWidthRevealsBothColumns() throws { + let probe = ColumnProbe() + let host = try HostedSplit(probe: probe, width: 1_024, height: 768, widthClass: .regular) + defer { host.tearDown() } + + try host.settle() + + #expect(!probe.sidebarFrame.isEmpty) + #expect(!probe.detailFrame.isEmpty) + #expect(!probe.sidebarFrame.intersects(probe.detailFrame)) + #expect(probe.sidebarFrame.maxX <= probe.detailFrame.minX + 0.5) + } + + @Test("A compact width shows one column, so the outer display keeps today's single-pane flow") + func compactWidthCollapsesToOneColumn() throws { + let probe = ColumnProbe() + let host = try HostedSplit(probe: probe, width: 466, height: 678, widthClass: .compact) + defer { host.tearDown() } + + try host.settle() + + #expect(!probe.sidebarFrame.isEmpty) + #expect(probe.sidebarFrame.maxX > probe.detailFrame.minX) + } +} + +@MainActor +private final class ColumnProbe { + var sidebarFrame: CGRect = .zero + var detailFrame: CGRect = .zero +} + +private struct SplitProbe: View { + let probe: ColumnProbe + + var body: some View { + TabView { + Tab("Tables", systemImage: "tablecells") { + NavigationSplitView { + List { + Text(verbatim: "albums") + Text(verbatim: "artists") + } + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { + probe.sidebarFrame = $0 + } + } detail: { + NavigationStack { + Color.clear + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { + probe.detailFrame = $0 + } + } + } + .navigationSplitViewStyle(.balanced) + } + Tab("Query", systemImage: "terminal") { Color.clear } + } + .tabViewStyle(.sidebarAdaptable) + } +} + +@MainActor +private struct UnsettledLayout: Error { + let description: String +} + +@MainActor +private final class HostedSplit { + let window: UIWindow + private let probe: ColumnProbe + private static let layoutTurns = 60 + private static let turnLength: TimeInterval = 0.05 + + init(probe: ColumnProbe, width: CGFloat, height: CGFloat, widthClass: UIUserInterfaceSizeClass) throws { + self.probe = probe + let scene = try #require(UIApplication.shared.connectedScenes.first as? UIWindowScene) + window = UIWindow(windowScene: scene) + window.frame = CGRect(x: 0, y: 0, width: width, height: height) + let controller = UIHostingController(rootView: SplitProbe(probe: probe)) + controller.traitOverrides.horizontalSizeClass = widthClass + window.rootViewController = controller + window.makeKeyAndVisible() + } + + func settle() throws { + for _ in 0 ..< Self.layoutTurns { + window.layoutIfNeeded() + if !probe.sidebarFrame.isEmpty { + RunLoop.current.run(until: Date(timeIntervalSinceNow: Self.turnLength)) + window.layoutIfNeeded() + return + } + RunLoop.current.run(until: Date(timeIntervalSinceNow: Self.turnLength)) + } + throw UnsettledLayout( + description: "No sidebar in window \(window.bounds). " + + "Sidebar \(probe.sidebarFrame), detail \(probe.detailFrame)" + ) + } + + func tearDown() { + window.isHidden = true + window.rootViewController = nil + } +} From b47bb4fa4da07b58e54e228be6ba3496f7ff19e6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 04:02:36 +0700 Subject: [PATCH 3/3] docs(ios): describe the side-by-side layout and the side bars on iPhone Duo --- docs/ios/index.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index 13134e2066..54b1e53fe5 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -96,11 +96,15 @@ A database created with **Create New Database**, or copied in by picking it, sta ## What works -An open connection fills the screen and carries four sections: **Tables**, **Query**, **History**, **Info**. They sit in a tab bar on iPhone and in a sidebar on iPad. `Cmd+1` through `Cmd+4` switch between them on a keyboard, a toolbar menu switches database and schema when the engine has more than one, and **Connections** returns to the list. +An open connection fills the screen and carries four sections: **Tables**, **Query**, **History**, **Info**. They sit in a tab bar on iPhone and in a sidebar on iPad. + +On iPad, and on iPhone Duo's inner display, the table list and the open table sit side by side. At phone width the list fills the screen and a table opens over it. + +On iPhone Duo the system draws the toolbar and the tab bar down the side of the display. The outer display is 466 by 678 points, shorter than any other iPhone, so rows keep the height those bars would have taken across the top and bottom. `Cmd+1` through `Cmd+4` switch between them on a keyboard, a toolbar menu switches database and schema when the engine has more than one, and **Connections** returns to the list. ### Browsing -Search the table list by name. Page a table at 50, 100, 200, or 500 rows, jump to a page number, sort by a column, search text columns, and stack filters with AND or OR. A foreign key value previews the row it points at. **Table Structure** lists columns, indexes, and foreign keys, read only. +A row in the list previews four of its fields, and eight where the display is wide enough. Search the table list by name. Page a table at 50, 100, 200, or 500 rows, jump to a page number, sort by a column, search text columns, and stack filters with AND or OR. A foreign key value previews the row it points at. **Table Structure** lists columns, indexes, and foreign keys, read only. ### Editing