Skip to content

Commit d0a5750

Browse files
authored
feat(ios): P1 HIG compliance fixes (#676)
* feat(ios): consolidate data browser toolbar and fix group management layout * feat(ios): add context menus to table list rows * feat(ios): add swipe gesture for row-by-row navigation * feat(ios): add share sheet support to export menus * feat(ios): add iPad keyboard shortcuts and hover effects * feat(ios): add Handoff support for cross-device continuity * fix(ios): exclude Info.plist from bundle resources to fix duplicate output * fix(ios): use correct identifier quoting for truncate and drop table * fix(ios): fix prev/next buttons not working with TabView paging
1 parent 02be250 commit d0a5750

12 files changed

Lines changed: 350 additions & 101 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Full-text search across all columns in iOS data browser
13+
- iPad keyboard shortcuts (Cmd+N new connection, Cmd+Return execute query, Cmd+1/2 switch tabs) and trackpad hover effects on list rows
1314
- Server Dashboard with active sessions, server metrics, and slow query monitoring (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite)
15+
- Handoff support for cross-device continuity between iOS and macOS
1416

1517
## [0.30.1] - 2026-04-10
1618

TableProMobile/TableProMobile.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,13 @@
537537
);
538538
target = 5AB9F3D82F7C1C12001F3337 /* TableProMobile */;
539539
};
540+
5AB9F3DC2F7C1C13001F3337 /* Exceptions for "TableProMobile" folder in "TableProMobile" target */ = {
541+
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
542+
membershipExceptions = (
543+
Info.plist,
544+
);
545+
target = 5AB9F3D82F7C1C12001F3337 /* TableProMobile */;
546+
};
540547
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
541548

542549
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -551,6 +558,9 @@
551558
};
552559
5AB9F3DB2F7C1C12001F3337 /* TableProMobile */ = {
553560
isa = PBXFileSystemSynchronizedRootGroup;
561+
exceptions = (
562+
5AB9F3DC2F7C1C13001F3337 /* Exceptions for "TableProMobile" folder in "TableProMobile" target */,
563+
);
554564
path = TableProMobile;
555565
sourceTree = "<group>";
556566
};
@@ -1973,6 +1983,7 @@
19731983
DEVELOPMENT_TEAM = D7HJ5TFYCU;
19741984
ENABLE_PREVIEWS = YES;
19751985
GENERATE_INFOPLIST_FILE = YES;
1986+
INFOPLIST_FILE = TableProMobile/Info.plist;
19761987
INFOPLIST_KEY_CFBundleDisplayName = TablePro;
19771988
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
19781989
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
@@ -2015,6 +2026,7 @@
20152026
DEVELOPMENT_TEAM = D7HJ5TFYCU;
20162027
ENABLE_PREVIEWS = YES;
20172028
GENERATE_INFOPLIST_FILE = YES;
2029+
INFOPLIST_FILE = TableProMobile/Info.plist;
20182030
INFOPLIST_KEY_CFBundleDisplayName = TablePro;
20192031
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
20202032
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>NSUserActivityTypes</key>
6+
<array>
7+
<string>com.TablePro.viewConnection</string>
8+
<string>com.TablePro.viewTable</string>
9+
</array>
10+
</dict>
11+
</plist>

TableProMobile/TableProMobile/TableProMobileApp.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ struct TableProMobileApp: App {
3737
let uuid = UUID(uuidString: identifier) else { return }
3838
appState.pendingConnectionId = uuid
3939
}
40+
.onContinueUserActivity("com.TablePro.viewConnection") { activity in
41+
guard let connectionId = activity.userInfo?["connectionId"] as? String,
42+
let uuid = UUID(uuidString: connectionId) else { return }
43+
appState.pendingConnectionId = uuid
44+
}
45+
.onContinueUserActivity("com.TablePro.viewTable") { activity in
46+
guard let connectionId = activity.userInfo?["connectionId"] as? String,
47+
let uuid = UUID(uuidString: connectionId) else { return }
48+
appState.pendingConnectionId = uuid
49+
}
4050
}
4151
.onChange(of: scenePhase) { _, phase in
4252
switch phase {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// ActivityViewController.swift
3+
// TableProMobile
4+
//
5+
6+
import SwiftUI
7+
import UIKit
8+
9+
struct ActivityViewController: UIViewControllerRepresentable {
10+
let items: [Any]
11+
12+
func makeUIViewController(context: Context) -> UIActivityViewController {
13+
UIActivityViewController(activityItems: items, applicationActivities: nil)
14+
}
15+
16+
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
17+
}

TableProMobile/TableProMobile/Views/ConnectedView.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ struct ConnectedView: View {
7272
}
7373
} else {
7474
connectedContent
75+
.userActivity("com.TablePro.viewConnection") { activity in
76+
activity.title = connection.name.isEmpty ? connection.host : connection.name
77+
activity.isEligibleForHandoff = true
78+
activity.userInfo = ["connectionId": connection.id.uuidString]
79+
}
7580
.allowsHitTesting(!isSwitching)
7681
.overlay {
7782
if isSwitching {
@@ -107,6 +112,14 @@ struct ConnectedView: View {
107112
.padding(.vertical, 8)
108113
.background(.bar)
109114
}
115+
.background {
116+
Button("") { selectedTab = .tables }
117+
.keyboardShortcut("1", modifiers: .command)
118+
.hidden()
119+
Button("") { selectedTab = .query }
120+
.keyboardShortcut("2", modifiers: .command)
121+
.hidden()
122+
}
110123
.toolbar {
111124
if connection.safeModeLevel != .off {
112125
ToolbarItem(placement: .topBarTrailing) {

TableProMobile/TableProMobile/Views/ConnectionListView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ struct ConnectionListView: View {
6464
} label: {
6565
Image(systemName: "plus")
6666
}
67+
.keyboardShortcut("n", modifiers: .command)
6768
}
6869
ToolbarItem(placement: .topBarLeading) {
6970
Button {
@@ -336,6 +337,7 @@ struct ConnectionListView: View {
336337
NavigationLink(value: connection.id) {
337338
ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId))
338339
}
340+
.hoverEffect()
339341
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
340342
Button {
341343
connectionToDelete = connection

TableProMobile/TableProMobile/Views/DataBrowserView.swift

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,11 @@ struct DataBrowserView: View {
4141
@State private var foreignKeys: [ForeignKeyInfo] = []
4242
@State private var fkPreviewItem: FKPreviewItem?
4343
@State private var memoryWarningMessage: String?
44+
@State private var showShareSheet = false
45+
@State private var shareText = ""
4446
@State private var hapticSuccess = false
4547
@State private var hapticError = false
48+
@State private var showStructure = false
4649

4750
private var isView: Bool {
4851
table.type == .view || table.type == .materializedView
@@ -102,6 +105,14 @@ struct DataBrowserView: View {
102105

103106
var body: some View {
104107
searchableContent
108+
.userActivity("com.TablePro.viewTable") { activity in
109+
activity.title = table.name
110+
activity.isEligibleForHandoff = true
111+
activity.userInfo = [
112+
"connectionId": connection.id.uuidString,
113+
"tableName": table.name
114+
]
115+
}
105116
.toolbar { topToolbar }
106117
.toolbar(rows.isEmpty && !hasActiveSearch && !hasActiveFilters ? .hidden : .visible, for: .bottomBar)
107118
.toolbar { paginationToolbar }
@@ -124,6 +135,9 @@ struct DataBrowserView: View {
124135
databaseType: connection.type
125136
)
126137
}
138+
.sheet(isPresented: $showShareSheet) {
139+
ActivityViewController(items: [shareText])
140+
}
127141
.confirmationDialog("Delete Row", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
128142
Button("Delete", role: .destructive) {
129143
if let pkValues = deleteTarget {
@@ -161,6 +175,9 @@ struct DataBrowserView: View {
161175
}
162176
.sensoryFeedback(.success, trigger: hapticSuccess)
163177
.sensoryFeedback(.error, trigger: hapticError)
178+
.navigationDestination(isPresented: $showStructure) {
179+
StructureView(table: table, session: session, databaseType: connection.type)
180+
}
164181
.alert("Go to Page", isPresented: $showGoToPage) {
165182
TextField("Page number", text: $goToPageInput)
166183
.keyboardType(.numberPad)
@@ -250,7 +267,19 @@ struct DataBrowserView: View {
250267
row: row
251268
)
252269
}
270+
.hoverEffect()
253271
.contextMenu {
272+
Menu("Share Row") {
273+
ForEach(ExportFormat.allCases) { format in
274+
Button(format.rawValue) {
275+
shareText = ClipboardExporter.exportRow(
276+
columns: columns, row: row,
277+
format: format, tableName: table.name
278+
)
279+
showShareSheet = true
280+
}
281+
}
282+
}
254283
Menu("Copy Row") {
255284
ForEach(ExportFormat.allCases) { format in
256285
Button(format.rawValue) {
@@ -311,25 +340,6 @@ struct DataBrowserView: View {
311340

312341
@ToolbarContentBuilder
313342
private var topToolbar: some ToolbarContent {
314-
ToolbarItem(placement: .topBarTrailing) {
315-
Menu {
316-
ForEach(ExportFormat.allCases) { format in
317-
Button {
318-
let text = ClipboardExporter.exportRows(
319-
columns: columns, rows: rows,
320-
format: format, tableName: table.name
321-
)
322-
ClipboardExporter.copyToClipboard(text)
323-
} label: {
324-
Label(format.rawValue, systemImage: "doc.on.clipboard")
325-
}
326-
}
327-
} label: {
328-
Image(systemName: "square.and.arrow.up")
329-
.accessibilityLabel(Text("Export"))
330-
}
331-
.disabled(rows.isEmpty)
332-
}
333343
ToolbarItem(placement: .topBarTrailing) {
334344
Menu {
335345
Picker("Sort By", selection: sortColumnBinding) {
@@ -365,11 +375,26 @@ struct DataBrowserView: View {
365375
.badge(activeFilterCount)
366376
}
367377
ToolbarItem(placement: .topBarTrailing) {
368-
NavigationLink {
369-
StructureView(table: table, session: session, databaseType: connection.type)
378+
Menu {
379+
Button { showStructure = true } label: {
380+
Label("Table Structure", systemImage: "info.circle")
381+
}
382+
Divider()
383+
Section("Export") {
384+
ForEach(ExportFormat.allCases) { format in
385+
Button {
386+
let text = ClipboardExporter.exportRows(
387+
columns: columns, rows: rows,
388+
format: format, tableName: table.name
389+
)
390+
ClipboardExporter.copyToClipboard(text)
391+
} label: {
392+
Label(format.rawValue, systemImage: "doc.on.clipboard")
393+
}
394+
}
395+
}
370396
} label: {
371-
Image(systemName: "info.circle")
372-
.accessibilityLabel(Text("Table Structure"))
397+
Image(systemName: "ellipsis.circle")
373398
}
374399
}
375400
if !isView && !connection.safeModeLevel.blocksWrites {

TableProMobile/TableProMobile/Views/GroupManagementView.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,10 @@ struct GroupManagementView: View {
8686
.navigationTitle("Groups")
8787
.navigationBarTitleDisplayMode(.inline)
8888
.toolbar {
89-
ToolbarItemGroup(placement: .topBarTrailing) {
89+
ToolbarItem(placement: .topBarLeading) {
9090
EditButton()
91+
}
92+
ToolbarItemGroup(placement: .topBarTrailing) {
9193
Button {
9294
showingAddGroup = true
9395
} label: {

TableProMobile/TableProMobile/Views/QueryEditorView.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ struct QueryEditorView: View {
3131
@State private var showWriteConfirmation = false
3232
@State private var showWriteBlockedAlert = false
3333
@State private var pendingWriteQuery = ""
34+
@State private var showShareSheet = false
35+
@State private var shareText = ""
3436
@State private var hapticSuccess = false
3537
@State private var hapticError = false
3638
var body: some View {
@@ -57,6 +59,9 @@ struct QueryEditorView: View {
5759
}
5860
.sensoryFeedback(.success, trigger: hapticSuccess)
5961
.sensoryFeedback(.error, trigger: hapticError)
62+
.sheet(isPresented: $showShareSheet) {
63+
ActivityViewController(items: [shareText])
64+
}
6065
.sheet(isPresented: $showHistory) { historySheet }
6166
}
6267

@@ -228,6 +233,7 @@ struct QueryEditorView: View {
228233
Image(systemName: isExecuting ? "stop.fill" : "play.fill")
229234
}
230235
.disabled(!isExecuting && query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
236+
.keyboardShortcut(.return, modifiers: .command)
231237
}
232238

233239
ToolbarItem(placement: .topBarTrailing) {
@@ -253,6 +259,19 @@ struct QueryEditorView: View {
253259
}
254260

255261
if let result, !result.rows.isEmpty {
262+
Section("Share Results") {
263+
ForEach(ExportFormat.allCases) { format in
264+
Button {
265+
shareText = ClipboardExporter.exportRows(
266+
columns: result.columns, rows: result.rows,
267+
format: format
268+
)
269+
showShareSheet = true
270+
} label: {
271+
Label(format.rawValue, systemImage: "square.and.arrow.up")
272+
}
273+
}
274+
}
256275
Section("Copy Results") {
257276
ForEach(ExportFormat.allCases) { format in
258277
Button {

0 commit comments

Comments
 (0)