Skip to content

Commit dd82839

Browse files
authored
fix(ios): TabView navigation fixes and code review cleanup (#870)
* fix(ios): inline query toolbar, dynamic tab titles, review fixes - Move QueryEditorView run button and menu inline (TabView blocks toolbar propagation) - Dynamic navigationTitle per tab (Tables/Query show connection name, History/Settings show tab name) - Remove dead .navigationTitle/.toolbar from tab content views - Show connection name in nav bar during connecting phase - Rename tablesPath to navigationPath (shared across tabs) - Remove duplicate safe mode icon from query action bar - Add Sendable conformance to ConnectedTab * fix(ios): review fixes across 6 files - Add .environment(coordinator) to Settings tab for consistency - Fix coordinator race condition on mid-connect navigation - Add accessibility labels to keyboard shortcut buttons - Localize alert/dialog strings in QueryEditorView and QueryHistoryView - Cancel saveQueryTask on QueryEditorView disappear - Fix reorderSection sort corruption in grouped connection list - Refresh columnDetails on initial DataBrowserView load - Cancel dismissSuccessTask on RowDetailView disappear - Add systemGroupedBackground to RowDetailView * fix(ios): restore navigationTitle on SettingsView for sheet presentation
1 parent 726aadc commit dd82839

8 files changed

Lines changed: 135 additions & 118 deletions

File tree

TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ final class ConnectionCoordinator {
3333
}
3434
}
3535
var pendingQuery: String?
36-
var tablesPath = NavigationPath()
36+
var navigationPath = NavigationPath()
3737

3838
private(set) var queryHistory: [QueryHistoryItem] = []
3939
private let historyStorage = QueryHistoryStorage()
@@ -277,7 +277,7 @@ final class ConnectionCoordinator {
277277
appState.pendingTableName = nil
278278
selectedTab = .tables
279279
Task { @MainActor in
280-
tablesPath.append(table)
280+
navigationPath.append(table)
281281
}
282282
}
283283

TableProMobile/TableProMobile/Models/ConnectedTab.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// TableProMobile
44
//
55

6-
enum ConnectedTab: String, CaseIterable {
6+
enum ConnectedTab: String, CaseIterable, Sendable {
77
case tables
88
case query
99
case history

TableProMobile/TableProMobile/Views/ConnectedView.swift

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ struct ConnectedView: View {
3636
connectingView
3737
}
3838
}
39+
.navigationTitle(connection.name.isEmpty ? connection.host : connection.name)
40+
.navigationBarTitleDisplayMode(.inline)
3941
.task {
40-
if let cached = cachedCoordinator, cached.session != nil {
42+
if let cached = cachedCoordinator {
4143
coordinator = cached
4244
if case .connected = cached.phase { return }
4345
await cached.connect()
@@ -86,7 +88,7 @@ struct ConnectedView: View {
8688

8789
private func connectedContent(_ coordinator: ConnectionCoordinator) -> some View {
8890
@Bindable var coordinator = coordinator
89-
return NavigationStack(path: $coordinator.tablesPath) {
91+
return NavigationStack(path: $coordinator.navigationPath) {
9092
TabView(selection: $coordinator.selectedTab) {
9193
Tab("Tables", systemImage: "tablecells", value: .tables) {
9294
TableListView()
@@ -102,9 +104,10 @@ struct ConnectedView: View {
102104
}
103105
Tab("Settings", systemImage: "gear", value: .settings) {
104106
SettingsView()
107+
.environment(coordinator)
105108
}
106109
}
107-
.navigationTitle(coordinator.displayName)
110+
.navigationTitle(tabTitle(coordinator))
108111
.navigationBarTitleDisplayMode(.inline)
109112
.toolbar { connectionToolbar(coordinator) }
110113
.navigationDestination(for: TableInfo.self) { table in
@@ -115,15 +118,19 @@ struct ConnectedView: View {
115118
.background {
116119
Button("") { coordinator.selectedTab = .tables }
117120
.keyboardShortcut("1", modifiers: .command)
121+
.accessibilityLabel(Text("Tables"))
118122
.hidden()
119123
Button("") { coordinator.selectedTab = .query }
120124
.keyboardShortcut("2", modifiers: .command)
125+
.accessibilityLabel(Text("Query"))
121126
.hidden()
122127
Button("") { coordinator.selectedTab = .history }
123128
.keyboardShortcut("3", modifiers: .command)
129+
.accessibilityLabel(Text("History"))
124130
.hidden()
125131
Button("") { coordinator.selectedTab = .settings }
126132
.keyboardShortcut("4", modifiers: .command)
133+
.accessibilityLabel(Text("Settings"))
127134
.hidden()
128135
}
129136
.overlay(alignment: .top) {
@@ -168,6 +175,14 @@ struct ConnectedView: View {
168175
}
169176
}
170177

178+
private func tabTitle(_ coordinator: ConnectionCoordinator) -> String {
179+
switch coordinator.selectedTab {
180+
case .tables, .query: coordinator.displayName
181+
case .history: String(localized: "History")
182+
case .settings: String(localized: "Settings")
183+
}
184+
}
185+
171186
// MARK: - Connection Toolbar
172187

173188
@ToolbarContentBuilder

TableProMobile/TableProMobile/Views/ConnectionListView.swift

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -354,16 +354,14 @@ struct ConnectionListView: View {
354354
var items = sectionItems
355355
items.move(fromOffsets: source, toOffset: destination)
356356
var all = appState.connections
357-
for (i, item) in items.enumerated() {
357+
let baseOrder = items.compactMap { item in
358+
all.firstIndex { $0.id == item.id }.map { all[$0].sortOrder }
359+
}.sorted()
360+
for (i, item) in items.enumerated() where i < baseOrder.count {
358361
if let idx = all.firstIndex(where: { $0.id == item.id }) {
359-
all[idx].sortOrder = i
362+
all[idx].sortOrder = baseOrder[i]
360363
}
361364
}
362-
all.sort {
363-
if $0.sortOrder != $1.sortOrder { return $0.sortOrder < $1.sortOrder }
364-
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
365-
}
366-
for index in all.indices { all[index].sortOrder = index }
367365
appState.reorderConnections(all)
368366
}
369367

TableProMobile/TableProMobile/Views/DataBrowserView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -529,10 +529,10 @@ struct DataBrowserView: View {
529529
if rows.count < pagination.pageSize, pagination.totalRows == nil {
530530
pagination.totalRows = pagination.currentOffset + rows.count
531531
}
532-
if columnDetails.isEmpty {
532+
if columnDetails.isEmpty || isInitial {
533533
columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil)
534534
}
535-
if foreignKeys.isEmpty {
535+
if foreignKeys.isEmpty || isInitial {
536536
do {
537537
foreignKeys = try await session.driver.fetchForeignKeys(table: table.name, schema: nil)
538538
} catch {

TableProMobile/TableProMobile/Views/QueryEditorView.swift

Lines changed: 100 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,6 @@ struct QueryEditorView: View {
4242
Divider()
4343
resultSection
4444
}
45-
.navigationTitle(coordinator.displayName)
46-
.navigationBarTitleDisplayMode(.inline)
47-
.toolbar { toolbarContent }
4845
.onAppear {
4946
if let pending = coordinator.pendingQuery {
5047
query = pending
@@ -61,19 +58,22 @@ struct QueryEditorView: View {
6158
UserDefaults.standard.set(newValue, forKey: "lastQuery.\(connectionId.uuidString)")
6259
}
6360
}
61+
.onDisappear {
62+
saveQueryTask?.cancel()
63+
}
6464
.onChange(of: coordinator.pendingQuery) { _, newQuery in
6565
if let newQuery {
6666
query = newQuery
6767
coordinator.pendingQuery = nil
6868
}
6969
}
70-
.alert("Write Query Blocked", isPresented: $showWriteBlockedAlert) {
70+
.alert(String(localized: "Write Query Blocked"), isPresented: $showWriteBlockedAlert) {
7171
Button("OK", role: .cancel) {}
7272
} message: {
7373
Text("This connection is in read-only mode. Write queries are not allowed.")
7474
}
75-
.confirmationDialog("Execute Write Query?", isPresented: $showWriteConfirmation, titleVisibility: .visible) {
76-
Button("Execute", role: .destructive) {
75+
.confirmationDialog(String(localized: "Execute Write Query?"), isPresented: $showWriteConfirmation, titleVisibility: .visible) {
76+
Button(String(localized: "Execute"), role: .destructive) {
7777
executeTask = Task { await executeQueryDirect(pendingWriteQuery) }
7878
}
7979
} message: {
@@ -107,31 +107,57 @@ struct QueryEditorView: View {
107107
SQLHighlightTextView(text: $query)
108108
.frame(minHeight: 80, maxHeight: result != nil || appError != nil ? 120 : 250)
109109

110-
if isExecuting || executionTime != nil || result != nil {
111-
HStack {
112-
if isExecuting, let startTime = executionStartTime {
113-
TimelineView(.periodic(from: startTime, by: 0.1)) { context in
114-
let elapsed = context.date.timeIntervalSince(startTime)
115-
Label(String(format: "%.1fs", elapsed), systemImage: "clock")
116-
.font(.caption2)
117-
.foregroundStyle(.secondary)
118-
}
119-
} else if let time = executionTime {
120-
Label(String(format: "%.1fms", time * 1000), systemImage: "clock")
121-
.font(.caption2)
122-
.foregroundStyle(.secondary)
123-
}
124-
Spacer()
125-
if let result, !result.rows.isEmpty {
126-
Text(verbatim: "\(result.rows.count) rows")
127-
.font(.caption2)
128-
.foregroundStyle(.secondary)
129-
}
110+
actionBar
111+
}
112+
}
113+
114+
private var actionBar: some View {
115+
HStack(spacing: 12) {
116+
Button {
117+
if isExecuting {
118+
executeTask?.cancel()
119+
Task { try? await session?.driver.cancelCurrentQuery() }
120+
} else {
121+
executeTask = Task { await executeQuery() }
130122
}
131-
.padding(.horizontal, 12)
132-
.padding(.bottom, 4)
123+
} label: {
124+
Label(
125+
isExecuting ? String(localized: "Stop") : String(localized: "Run"),
126+
systemImage: isExecuting ? "stop.fill" : "play.fill"
127+
)
128+
.font(.subheadline.weight(.medium))
133129
}
130+
.buttonStyle(.bordered)
131+
.controlSize(.small)
132+
.tint(isExecuting ? .red : .accentColor)
133+
.disabled(!isExecuting && query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
134+
.keyboardShortcut(.return, modifiers: .command)
135+
136+
Spacer()
137+
138+
if isExecuting, let startTime = executionStartTime {
139+
TimelineView(.periodic(from: startTime, by: 0.1)) { context in
140+
let elapsed = context.date.timeIntervalSince(startTime)
141+
Text(String(format: "%.1fs", elapsed))
142+
.font(.caption2)
143+
.foregroundStyle(.secondary)
144+
}
145+
} else if let time = executionTime {
146+
Text(String(format: "%.1fms", time * 1000))
147+
.font(.caption2)
148+
.foregroundStyle(.secondary)
149+
}
150+
151+
if let result, !result.rows.isEmpty {
152+
Text(verbatim: "\(result.rows.count) rows")
153+
.font(.caption2)
154+
.foregroundStyle(.secondary)
155+
}
156+
157+
queryMenu
134158
}
159+
.padding(.horizontal, 12)
160+
.padding(.vertical, 6)
135161
}
136162

137163
// MARK: - Results
@@ -172,7 +198,7 @@ struct QueryEditorView: View {
172198
Image(systemName: "checkmark.circle.fill")
173199
.font(.largeTitle)
174200
.foregroundStyle(.green)
175-
Text(verbatim: "\(result.rowsAffected) row(s) affected")
201+
Text(String(format: String(localized: "%d row(s) affected"), result.rowsAffected))
176202
.font(.body)
177203
}
178204
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -259,93 +285,69 @@ struct QueryEditorView: View {
259285
}
260286
}
261287

262-
// MARK: - Toolbar
288+
// MARK: - Query Menu
263289

264-
@ToolbarContentBuilder
265-
private var toolbarContent: some ToolbarContent {
266-
if safeModeLevel != .off {
267-
ToolbarItem(placement: .topBarLeading) {
268-
Image(systemName: safeModeLevel == .readOnly ? "lock.fill" : "shield.fill")
269-
.foregroundStyle(safeModeLevel == .readOnly ? .red : .orange)
270-
.font(.caption)
271-
}
272-
}
273-
274-
ToolbarItem(placement: .primaryAction) {
290+
private var queryMenu: some View {
291+
Menu {
275292
Button {
276-
if isExecuting {
277-
executeTask?.cancel()
278-
Task { try? await session?.driver.cancelCurrentQuery() }
279-
} else {
280-
executeTask = Task { await executeQuery() }
281-
}
293+
coordinator.selectedTab = .history
282294
} label: {
283-
Image(systemName: isExecuting ? "stop.fill" : "play.fill")
295+
Label("History", systemImage: "clock")
284296
}
285-
.disabled(!isExecuting && query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
286-
.keyboardShortcut(.return, modifiers: .command)
287-
}
288-
289-
ToolbarItem(placement: .topBarTrailing) {
290-
Menu {
291-
Button {
292-
coordinator.selectedTab = .history
293-
} label: {
294-
Label("History", systemImage: "clock")
295-
}
296297

297-
if !tables.isEmpty {
298-
Menu {
299-
ForEach(tables) { table in
300-
Button(table.name) {
301-
let quoted = SQLBuilder.quoteIdentifier(table.name, for: databaseType)
302-
query = "SELECT * FROM \(quoted) LIMIT 100"
303-
}
298+
if !tables.isEmpty {
299+
Menu {
300+
ForEach(tables) { table in
301+
Button(table.name) {
302+
let quoted = SQLBuilder.quoteIdentifier(table.name, for: databaseType)
303+
query = "SELECT * FROM \(quoted) LIMIT 100"
304304
}
305-
} label: {
306-
Label("SELECT * FROM ...", systemImage: "text.badge.star")
307305
}
306+
} label: {
307+
Label("SELECT * FROM ...", systemImage: "text.badge.star")
308308
}
309+
}
309310

310-
if let result, !result.rows.isEmpty {
311-
Section("Share Results") {
312-
ForEach(ExportFormat.allCases) { format in
313-
Button {
314-
shareText = ClipboardExporter.exportRows(
315-
columns: result.columns, rows: result.rows,
316-
format: format
317-
)
318-
showShareSheet = true
319-
} label: {
320-
Label(format.rawValue, systemImage: "square.and.arrow.up")
321-
}
311+
if let result, !result.rows.isEmpty {
312+
Section("Share Results") {
313+
ForEach(ExportFormat.allCases) { format in
314+
Button {
315+
shareText = ClipboardExporter.exportRows(
316+
columns: result.columns, rows: result.rows,
317+
format: format
318+
)
319+
showShareSheet = true
320+
} label: {
321+
Label(format.rawValue, systemImage: "square.and.arrow.up")
322322
}
323323
}
324-
Section("Copy Results") {
325-
ForEach(ExportFormat.allCases) { format in
326-
Button {
327-
let text = ClipboardExporter.exportRows(
328-
columns: result.columns, rows: result.rows,
329-
format: format
330-
)
331-
ClipboardExporter.copyToClipboard(text)
332-
} label: {
333-
Label(format.rawValue, systemImage: "doc.on.clipboard")
334-
}
324+
}
325+
Section("Copy Results") {
326+
ForEach(ExportFormat.allCases) { format in
327+
Button {
328+
let text = ClipboardExporter.exportRows(
329+
columns: result.columns, rows: result.rows,
330+
format: format
331+
)
332+
ClipboardExporter.copyToClipboard(text)
333+
} label: {
334+
Label(format.rawValue, systemImage: "doc.on.clipboard")
335335
}
336336
}
337337
}
338+
}
338339

339-
Divider()
340+
Divider()
340341

341-
Button(role: .destructive) {
342-
showClearConfirmation = true
343-
} label: {
344-
Label("Clear", systemImage: "trash")
345-
}
342+
Button(role: .destructive) {
343+
showClearConfirmation = true
346344
} label: {
347-
Image(systemName: "ellipsis.circle")
345+
Label("Clear", systemImage: "trash")
348346
}
347+
} label: {
348+
Image(systemName: "ellipsis.circle")
349+
.font(.body)
350+
.foregroundStyle(.secondary)
349351
}
350352
}
351353

0 commit comments

Comments
 (0)