From 1c9488f6e4f093ca808b76b18928bc5cf997985b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 19:47:05 +0700 Subject: [PATCH 1/9] feat: page-based pagination controls for iOS data browser --- .../TableProMobile/Helpers/SQLBuilder.swift | 5 + .../Views/DataBrowserView.swift | 98 ++++++++++--------- 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index 1e7e03da3..2c7fb1859 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -24,6 +24,11 @@ enum SQLBuilder { .replacingOccurrences(of: "'", with: "''") } + static func buildCount(table: String, type: DatabaseType) -> String { + let quoted = quoteIdentifier(table, for: type) + return "SELECT COUNT(*) FROM \(quoted)" + } + static func buildSelect(table: String, type: DatabaseType, limit: Int, offset: Int) -> String { let quoted = quoteIdentifier(table, for: type) return "SELECT * FROM \(quoted) LIMIT \(limit) OFFSET \(offset)" diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 875481d56..407b5986d 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -19,12 +19,10 @@ struct DataBrowserView: View { @State private var columnDetails: [ColumnInfo] = [] @State private var rows: [[String?]] = [] @State private var isLoading = true - @State private var isLoadingMore = false @State private var appError: AppError? @State private var toastMessage: String? @State private var toastTask: Task? @State private var pagination = PaginationState(pageSize: 100, currentPage: 0) - @State private var hasMore = true @State private var showInsertSheet = false @State private var deleteTarget: [(column: String, value: String)]? @State private var showDeleteConfirmation = false @@ -41,6 +39,16 @@ struct DataBrowserView: View { columnDetails.contains { $0.isPrimaryKey } } + private var paginationLabel: String { + guard !rows.isEmpty else { return "" } + let start = pagination.currentOffset + 1 + let end = pagination.currentOffset + rows.count + if let total = pagination.totalRows { + return "\(start)–\(end) of \(total)" + } + return "\(start)–\(end)" + } + var body: some View { Group { if isLoading { @@ -69,7 +77,7 @@ struct DataBrowserView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .status) { - Text(verbatim: "\(rows.count) rows") + Text(verbatim: paginationLabel) .font(.caption) .foregroundStyle(.secondary) } @@ -93,6 +101,29 @@ struct DataBrowserView: View { } } } + ToolbarItemGroup(placement: .bottomBar) { + Button { + Task { await goToPreviousPage() } + } label: { + Image(systemName: "chevron.left") + } + .disabled(pagination.currentPage == 0 || isLoading) + + Spacer() + + Text(paginationLabel) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Button { + Task { await goToNextPage() } + } label: { + Image(systemName: "chevron.right") + } + .disabled(!pagination.hasNextPage || isLoading) + } } .task { await loadData(isInitial: true) } .sheet(isPresented: $showInsertSheet) { @@ -147,7 +178,6 @@ struct DataBrowserView: View { private var cardList: some View { List { - // Offset-based identity is acceptable here: rows don't animate/reorder ForEach(Array(rows.enumerated()), id: \.offset) { index, row in NavigationLink { RowDetailView( @@ -180,28 +210,6 @@ struct DataBrowserView: View { } } } - - if hasMore { - Section { - Button { - Task { await loadNextPage() } - } label: { - HStack { - Spacer() - if isLoadingMore { - ProgressView() - .controlSize(.small) - Text("Loading...") - } else { - Label("Load More", systemImage: "arrow.down.circle") - } - Spacer() - } - .foregroundStyle(.blue) - } - .disabled(isLoadingMore) - } - } } .listStyle(.plain) .refreshable { await loadData() } @@ -224,7 +232,6 @@ struct DataBrowserView: View { isLoading = true } appError = nil - pagination.reset() do { let query = SQLBuilder.buildSelect( @@ -234,12 +241,13 @@ struct DataBrowserView: View { let result = try await session.driver.execute(query: query) self.columns = result.columns self.rows = result.rows - self.hasMore = result.rows.count >= pagination.pageSize // columnDetails (from fetchColumns) provides PK info for edit/delete. // columns (from query result) only have name/type, no PK metadata. self.columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil) + await fetchTotalRows(session: session) + isLoading = false } catch { let context = ErrorContext( @@ -252,27 +260,27 @@ struct DataBrowserView: View { } } - private func loadNextPage() async { - guard let session else { return } - - isLoadingMore = true - pagination.currentPage += 1 - + private func fetchTotalRows(session: ConnectionSession) async { do { - let query = SQLBuilder.buildSelect( - table: table.name, type: connection.type, - limit: pagination.pageSize, offset: pagination.currentOffset - ) - let result = try await session.driver.execute(query: query) - rows.append(contentsOf: result.rows) - hasMore = result.rows.count >= pagination.pageSize + let countQuery = SQLBuilder.buildCount(table: table.name, type: connection.type) + let countResult = try await session.driver.execute(query: countQuery) + if let firstRow = countResult.rows.first, let firstCol = firstRow.first { + pagination.totalRows = Int(firstCol ?? "0") + } } catch { - pagination.currentPage -= 1 - Self.logger.warning("Failed to load next page: \(error.localizedDescription, privacy: .public)") - withAnimation { toastMessage = String(localized: "Failed to load more rows") } + Self.logger.warning("Failed to fetch row count: \(error.localizedDescription, privacy: .public)") } + } + + private func goToNextPage() async { + pagination.currentPage += 1 + await loadData() + } - isLoadingMore = false + private func goToPreviousPage() async { + guard pagination.currentPage > 0 else { return } + pagination.currentPage -= 1 + await loadData() } private func deleteRow(withPKs pkValues: [(column: String, value: String)]) async { From 4a3434c08b53b4e5e851bd3fafd334adcfe81729 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 19:53:30 +0700 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20pagination=20bar=20=E2=80=94=20use?= =?UTF-8?q?=20safeAreaInset=20instead=20of=20broken=20bottomBar=20toolbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/DataBrowserView.swift | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 407b5986d..9614105b1 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -76,11 +76,6 @@ struct DataBrowserView: View { .navigationTitle(table.name) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .status) { - Text(verbatim: paginationLabel) - .font(.caption) - .foregroundStyle(.secondary) - } ToolbarItem(placement: .topBarTrailing) { NavigationLink { StructureView( @@ -101,28 +96,35 @@ struct DataBrowserView: View { } } } - ToolbarItemGroup(placement: .bottomBar) { - Button { - Task { await goToPreviousPage() } - } label: { - Image(systemName: "chevron.left") - } - .disabled(pagination.currentPage == 0 || isLoading) + } + .safeAreaInset(edge: .bottom) { + if !rows.isEmpty { + HStack { + Button { + Task { await goToPreviousPage() } + } label: { + Image(systemName: "chevron.left") + } + .disabled(pagination.currentPage == 0 || isLoading) - Spacer() + Spacer() - Text(paginationLabel) - .font(.caption) - .foregroundStyle(.secondary) + Text(paginationLabel) + .font(.footnote) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Button { - Task { await goToNextPage() } - } label: { - Image(systemName: "chevron.right") + Button { + Task { await goToNextPage() } + } label: { + Image(systemName: "chevron.right") + } + .disabled(!pagination.hasNextPage || isLoading) } - .disabled(!pagination.hasNextPage || isLoading) + .padding(.horizontal) + .padding(.vertical, 10) + .background(.bar) } } .task { await loadData(isInitial: true) } From f49bf0d019b799e31f866b4ac052ff2c82976bb4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 19:57:45 +0700 Subject: [PATCH 3/9] refactor: insetGrouped list, cleaner row cards, glass pagination bar --- .../Views/DataBrowserView.swift | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 9614105b1..19fb3d982 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -122,9 +122,9 @@ struct DataBrowserView: View { } .disabled(!pagination.hasNextPage || isLoading) } - .padding(.horizontal) - .padding(.vertical, 10) - .background(.bar) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(.ultraThinMaterial) } } .task { await loadData(isInitial: true) } @@ -213,7 +213,7 @@ struct DataBrowserView: View { } } } - .listStyle(.plain) + .listStyle(.insetGrouped) .refreshable { await loadData() } } @@ -319,42 +319,56 @@ private struct RowCard: View { let row: [String?] let maxPreviewColumns: Int - private var sortedPairs: [(column: ColumnInfo, value: String?)] { - let paired = zip(columns, row).map { ($0, $1) } - let pkPairs = paired.filter { $0.0.isPrimaryKey } - let nonPkPairs = paired.filter { !$0.0.isPrimaryKey } - return (pkPairs + nonPkPairs).prefix(maxPreviewColumns).map { ($0.0, $0.1) } + private var pkPair: (name: String, value: String)? { + for (col, val) in zip(columns, row) where col.isPrimaryKey { + return (col.name, val ?? "NULL") + } + if let first = columns.first { + return (first.name, row.first.flatMap { $0 } ?? "NULL") + } + return nil + } + + private var previewPairs: [(name: String, value: String)] { + let paired = zip(columns, row) + return paired + .filter { !$0.0.isPrimaryKey } + .prefix(maxPreviewColumns - 1) + .map { ($0.0.name, $0.1 ?? "NULL") } } var body: some View { - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(sortedPairs.enumerated()), id: \.offset) { _, pair in - HStack(spacing: 8) { - Text(pair.column.name) + VStack(alignment: .leading, spacing: 4) { + if let pk = pkPair { + HStack(spacing: 6) { + Text(pk.name) + .font(.caption2) + .foregroundStyle(.tertiary) + Text(verbatim: pk.value) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + } + } + + ForEach(Array(previewPairs.enumerated()), id: \.offset) { _, pair in + HStack(spacing: 6) { + Text(pair.name) + .font(.caption2) + .foregroundStyle(.tertiary) + Text(verbatim: pair.value) .font(.caption) .foregroundStyle(.secondary) - .frame(minWidth: 60, alignment: .leading) - - if let value = pair.value { - Text(verbatim: value) - .font(.subheadline) - .fontWeight(pair.column.isPrimaryKey ? .semibold : .regular) - .lineLimit(1) - } else { - Text(verbatim: "NULL") - .font(.subheadline) - .foregroundStyle(.secondary) - .italic() - } + .lineLimit(1) } } if columns.count > maxPreviewColumns { Text("+\(columns.count - maxPreviewColumns) more columns") .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(.quaternary) } } - .padding(.vertical, 4) + .padding(.vertical, 2) } } From 7dfb59feec28600842abdce6f6eab71889d47d37 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 20:07:49 +0700 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20use=20native=20.toolbar(.bottomBar)?= =?UTF-8?q?=20for=20pagination=20=E2=80=94=20system=20glass=20material?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/DataBrowserView.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 19fb3d982..70f0785ce 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -97,24 +97,28 @@ struct DataBrowserView: View { } } } - .safeAreaInset(edge: .bottom) { + .toolbar { if !rows.isEmpty { - HStack { + ToolbarItem(placement: .bottomBar) { Button { Task { await goToPreviousPage() } } label: { Image(systemName: "chevron.left") } .disabled(pagination.currentPage == 0 || isLoading) - + } + ToolbarItem(placement: .bottomBar) { Spacer() - + } + ToolbarItem(placement: .bottomBar) { Text(paginationLabel) .font(.footnote) .foregroundStyle(.secondary) - + } + ToolbarItem(placement: .bottomBar) { Spacer() - + } + ToolbarItem(placement: .bottomBar) { Button { Task { await goToNextPage() } } label: { @@ -122,9 +126,6 @@ struct DataBrowserView: View { } .disabled(!pagination.hasNextPage || isLoading) } - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background(.ultraThinMaterial) } } .task { await loadData(isInitial: true) } From d3be8798e924c4a6dddb3f9ceeaa201e007f897c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 20:12:46 +0700 Subject: [PATCH 5/9] fix: pagination label truncation, duplicate PK row in card preview --- .../Views/DataBrowserView.swift | 66 ++++++++++--------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 70f0785ce..a87982a49 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -97,35 +97,33 @@ struct DataBrowserView: View { } } } + .toolbar(rows.isEmpty ? .hidden : .visible, for: .bottomBar) .toolbar { - if !rows.isEmpty { - ToolbarItem(placement: .bottomBar) { - Button { - Task { await goToPreviousPage() } - } label: { - Image(systemName: "chevron.left") - } - .disabled(pagination.currentPage == 0 || isLoading) - } - ToolbarItem(placement: .bottomBar) { - Spacer() - } - ToolbarItem(placement: .bottomBar) { - Text(paginationLabel) - .font(.footnote) - .foregroundStyle(.secondary) - } - ToolbarItem(placement: .bottomBar) { - Spacer() + ToolbarItemGroup(placement: .bottomBar) { + Button { + Task { await goToPreviousPage() } + } label: { + Image(systemName: "chevron.left") } - ToolbarItem(placement: .bottomBar) { - Button { - Task { await goToNextPage() } - } label: { - Image(systemName: "chevron.right") - } - .disabled(!pagination.hasNextPage || isLoading) + .disabled(pagination.currentPage == 0 || isLoading) + + Spacer() + + Text(paginationLabel) + .font(.footnote) + .monospacedDigit() + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + + Spacer() + + Button { + Task { await goToNextPage() } + } label: { + Image(systemName: "chevron.right") } + .disabled(!pagination.hasNextPage || isLoading) } } .task { await loadData(isInitial: true) } @@ -198,6 +196,7 @@ struct DataBrowserView: View { } label: { RowCard( columns: columns, + columnDetails: columnDetails, row: row, maxPreviewColumns: maxPreviewColumns ) @@ -317,11 +316,17 @@ struct DataBrowserView: View { private struct RowCard: View { let columns: [ColumnInfo] + let columnDetails: [ColumnInfo] let row: [String?] let maxPreviewColumns: Int + private var pkColumnNames: Set { + Set(columnDetails.filter(\.isPrimaryKey).map(\.name)) + } + private var pkPair: (name: String, value: String)? { - for (col, val) in zip(columns, row) where col.isPrimaryKey { + let pkNames = pkColumnNames + for (col, val) in zip(columns, row) where pkNames.contains(col.name) { return (col.name, val ?? "NULL") } if let first = columns.first { @@ -331,9 +336,10 @@ private struct RowCard: View { } private var previewPairs: [(name: String, value: String)] { - let paired = zip(columns, row) - return paired - .filter { !$0.0.isPrimaryKey } + let pkNames = pkColumnNames + let titleName = pkPair?.name + return zip(columns, row) + .filter { !pkNames.contains($0.0.name) && $0.0.name != titleName } .prefix(maxPreviewColumns - 1) .map { ($0.0.name, $0.1 ?? "NULL") } } From e5efccf1446330864d3439cac3955beeda475c7d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 20:14:47 +0700 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20clean=20DataBrowserView=20?= =?UTF-8?q?=E2=80=94=20extract=20toolbars,=20confirmationDialog,=20remove?= =?UTF-8?q?=20redundancy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/DataBrowserView.swift | 324 ++++++++---------- 1 file changed, 144 insertions(+), 180 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index a87982a49..35b7225dc 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -20,8 +20,6 @@ struct DataBrowserView: View { @State private var rows: [[String?]] = [] @State private var isLoading = true @State private var appError: AppError? - @State private var toastMessage: String? - @State private var toastTask: Task? @State private var pagination = PaginationState(pageSize: 100, currentPage: 0) @State private var showInsertSheet = false @State private var deleteTarget: [(column: String, value: String)]? @@ -29,14 +27,12 @@ struct DataBrowserView: View { @State private var operationError: AppError? @State private var showOperationError = false - private let maxPreviewColumns = 4 - private var isView: Bool { table.type == .view || table.type == .materializedView } private var hasPrimaryKeys: Bool { - columnDetails.contains { $0.isPrimaryKey } + columnDetails.contains(where: \.isPrimaryKey) } private var paginationLabel: String { @@ -50,134 +46,56 @@ struct DataBrowserView: View { } var body: some View { - Group { - if isLoading { - ProgressView("Loading data...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let appError { - ErrorView(error: appError) { - await loadData() - } - } else if rows.isEmpty { - ContentUnavailableView { - Label("No Data", systemImage: "tray") - } description: { - Text("This table is empty.") - } actions: { - if !isView { - Button("Insert Row") { showInsertSheet = true } - .buttonStyle(.borderedProminent) + content + .navigationTitle(table.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { topToolbar } + .toolbar(rows.isEmpty ? .hidden : .visible, for: .bottomBar) + .toolbar { paginationToolbar } + .task { await loadData(isInitial: true) } + .sheet(isPresented: $showInsertSheet) { insertSheet } + .confirmationDialog("Delete Row", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { + Button("Delete", role: .destructive) { + if let pkValues = deleteTarget { + Task { await deleteRow(withPKs: pkValues) } } } - } else { - cardList - } - } - .navigationTitle(table.name) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - NavigationLink { - StructureView( - table: table, - session: session, - databaseType: connection.type - ) - } label: { - Image(systemName: "info.circle") - } + } message: { + Text("Are you sure you want to delete this row? This action cannot be undone.") } - ToolbarItem(placement: .primaryAction) { - if !isView { - Button { - showInsertSheet = true - } label: { - Image(systemName: "plus") - } - } + .alert(operationError?.title ?? "Error", isPresented: $showOperationError) { + Button("OK", role: .cancel) {} + } message: { + Text(operationError?.message ?? "") } - } - .toolbar(rows.isEmpty ? .hidden : .visible, for: .bottomBar) - .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - Button { - Task { await goToPreviousPage() } - } label: { - Image(systemName: "chevron.left") - } - .disabled(pagination.currentPage == 0 || isLoading) - - Spacer() - - Text(paginationLabel) - .font(.footnote) - .monospacedDigit() - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize() - - Spacer() + } - Button { - Task { await goToNextPage() } - } label: { - Image(systemName: "chevron.right") - } - .disabled(!pagination.hasNextPage || isLoading) - } - } - .task { await loadData(isInitial: true) } - .sheet(isPresented: $showInsertSheet) { - InsertRowView( - table: table, - columnDetails: columnDetails, - session: session, - databaseType: connection.type, - onInserted: { - Task { await loadData() } - } - ) - } - .alert("Delete Row", isPresented: $showDeleteConfirmation) { - Button("Delete", role: .destructive) { - if let pkValues = deleteTarget { - Task { await deleteRow(withPKs: pkValues) } - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("Are you sure you want to delete this row? This action cannot be undone.") - } - .overlay(alignment: .bottom) { - if let toastMessage { - ErrorToast(message: toastMessage) - .onAppear { - toastTask?.cancel() - toastTask = Task { - try? await Task.sleep(nanoseconds: 3_000_000_000) - withAnimation { self.toastMessage = nil } - } - } - .onDisappear { - toastTask?.cancel() - toastTask = nil - } - } - } - .animation(.default, value: toastMessage) - .alert(operationError?.title ?? "Error", isPresented: $showOperationError) { - Button("OK", role: .cancel) {} - } message: { - VStack { - Text(operationError?.message ?? "An unknown error occurred.") - if let recovery = operationError?.recovery { - Text(verbatim: recovery) + // MARK: - Content + + @ViewBuilder + private var content: some View { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let appError { + ErrorView(error: appError) { await loadData() } + } else if rows.isEmpty { + ContentUnavailableView { + Label("No Data", systemImage: "tray") + } description: { + Text("This table is empty.") + } actions: { + if !isView { + Button("Insert Row") { showInsertSheet = true } + .buttonStyle(.borderedProminent) } } + } else { + rowList } } - private var cardList: some View { + private var rowList: some View { List { ForEach(Array(rows.enumerated()), id: \.offset) { index, row in NavigationLink { @@ -189,16 +107,13 @@ struct DataBrowserView: View { session: session, columnDetails: columnDetails, databaseType: connection.type, - onSaved: { - Task { await loadData() } - } + onSaved: { Task { await loadData() } } ) } label: { RowCard( columns: columns, columnDetails: columnDetails, - row: row, - maxPreviewColumns: maxPreviewColumns + row: row ) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { @@ -217,22 +132,77 @@ struct DataBrowserView: View { .refreshable { await loadData() } } + // MARK: - Toolbars + + @ToolbarContentBuilder + private var topToolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + StructureView(table: table, session: session, databaseType: connection.type) + } label: { + Image(systemName: "info.circle") + } + } + if !isView { + ToolbarItem(placement: .primaryAction) { + Button { showInsertSheet = true } label: { + Image(systemName: "plus") + } + } + } + } + + @ToolbarContentBuilder + private var paginationToolbar: some ToolbarContent { + ToolbarItemGroup(placement: .bottomBar) { + Button { Task { await goToPreviousPage() } } label: { + Image(systemName: "chevron.left") + } + .disabled(pagination.currentPage == 0 || isLoading) + + Spacer() + + Text(paginationLabel) + .font(.footnote) + .monospacedDigit() + .foregroundStyle(.secondary) + .fixedSize() + + Spacer() + + Button { Task { await goToNextPage() } } label: { + Image(systemName: "chevron.right") + } + .disabled(!pagination.hasNextPage || isLoading) + } + } + + private var insertSheet: some View { + InsertRowView( + table: table, + columnDetails: columnDetails, + session: session, + databaseType: connection.type, + onInserted: { Task { await loadData() } } + ) + } + + // MARK: - Data Loading + private func loadData(isInitial: Bool = false) async { guard let session else { appError = AppError( category: .config, - title: "Not Connected", - message: "No active database session.", - recovery: "Go back and reconnect to the database.", + title: String(localized: "Not Connected"), + message: String(localized: "No active database session."), + recovery: String(localized: "Go back and reconnect to the database."), underlying: nil ) isLoading = false return } - if isInitial || rows.isEmpty { - isLoading = true - } + if isInitial || rows.isEmpty { isLoading = true } appError = nil do { @@ -241,23 +211,16 @@ struct DataBrowserView: View { limit: pagination.pageSize, offset: pagination.currentOffset ) let result = try await session.driver.execute(query: query) - self.columns = result.columns - self.rows = result.rows - - // columnDetails (from fetchColumns) provides PK info for edit/delete. - // columns (from query result) only have name/type, no PK metadata. - self.columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil) - + columns = result.columns + rows = result.rows + columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil) await fetchTotalRows(session: session) - isLoading = false } catch { - let context = ErrorContext( - operation: "loadData", - databaseType: connection.type, - host: connection.host + appError = ErrorClassifier.classify( + error, + context: ErrorContext(operation: "loadData", databaseType: connection.type, host: connection.host) ) - appError = ErrorClassifier.classify(error, context: context) isLoading = false } } @@ -285,80 +248,81 @@ struct DataBrowserView: View { await loadData() } + // MARK: - Row Operations + private func deleteRow(withPKs pkValues: [(column: String, value: String)]) async { guard let session, !pkValues.isEmpty else { return } - - let sql = SQLBuilder.buildDelete(table: table.name, type: connection.type, primaryKeys: pkValues) - do { - _ = try await session.driver.execute(query: sql) + _ = try await session.driver.execute( + query: SQLBuilder.buildDelete(table: table.name, type: connection.type, primaryKeys: pkValues) + ) await loadData() } catch { - let context = ErrorContext( - operation: "deleteRow", - databaseType: connection.type, - host: connection.host + operationError = ErrorClassifier.classify( + error, + context: ErrorContext(operation: "deleteRow", databaseType: connection.type, host: connection.host) ) - operationError = ErrorClassifier.classify(error, context: context) showOperationError = true } } private func primaryKeyValues(for row: [String?]) -> [(column: String, value: String)] { - columnDetails.enumerated().compactMap { index, col in - guard col.isPrimaryKey else { return nil } - let colIndex = columns.firstIndex(where: { $0.name == col.name }) - guard let colIndex, colIndex < row.count, let value = row[colIndex] else { return nil } + columnDetails.compactMap { col in + guard col.isPrimaryKey, + let colIndex = columns.firstIndex(where: { $0.name == col.name }), + colIndex < row.count, + let value = row[colIndex] else { return nil } return (column: col.name, value: value) } } } +// MARK: - Row Card + private struct RowCard: View { let columns: [ColumnInfo] let columnDetails: [ColumnInfo] let row: [String?] - let maxPreviewColumns: Int - private var pkColumnNames: Set { + private static let maxPreview = 4 + + private var pkNames: Set { Set(columnDetails.filter(\.isPrimaryKey).map(\.name)) } - private var pkPair: (name: String, value: String)? { - let pkNames = pkColumnNames - for (col, val) in zip(columns, row) where pkNames.contains(col.name) { + private var titlePair: (name: String, value: String)? { + let pks = pkNames + for (col, val) in zip(columns, row) where pks.contains(col.name) { return (col.name, val ?? "NULL") } - if let first = columns.first { - return (first.name, row.first.flatMap { $0 } ?? "NULL") - } - return nil + guard let first = columns.first else { return nil } + return (first.name, row.first.flatMap { $0 } ?? "NULL") } - private var previewPairs: [(name: String, value: String)] { - let pkNames = pkColumnNames - let titleName = pkPair?.name + private var detailPairs: [(name: String, value: String)] { + let pks = pkNames + let title = titlePair?.name return zip(columns, row) - .filter { !pkNames.contains($0.0.name) && $0.0.name != titleName } - .prefix(maxPreviewColumns - 1) + .filter { !pks.contains($0.0.name) && $0.0.name != title } + .prefix(Self.maxPreview - 1) .map { ($0.0.name, $0.1 ?? "NULL") } } var body: some View { VStack(alignment: .leading, spacing: 4) { - if let pk = pkPair { + if let title = titlePair { HStack(spacing: 6) { - Text(pk.name) + Text(title.name) .font(.caption2) .foregroundStyle(.tertiary) - Text(verbatim: pk.value) + Text(verbatim: title.value) .font(.subheadline) .fontWeight(.medium) .lineLimit(1) } } - ForEach(Array(previewPairs.enumerated()), id: \.offset) { _, pair in + ForEach(Array(detailPairs.enumerated()), id: \.offset) { _, pair in HStack(spacing: 6) { Text(pair.name) .font(.caption2) @@ -370,8 +334,8 @@ private struct RowCard: View { } } - if columns.count > maxPreviewColumns { - Text("+\(columns.count - maxPreviewColumns) more columns") + if columns.count > Self.maxPreview { + Text("+\(columns.count - Self.maxPreview) more columns") .font(.caption2) .foregroundStyle(.quaternary) } From a74f557533195116dd93ed2e54ef8ad1af44dfaf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 20:19:45 +0700 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20row=20detail=20pagination=20label=20?= =?UTF-8?q?truncation=20=E2=80=94=20add=20fixedSize,=20use=20"of"=20separa?= =?UTF-8?q?tor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TableProMobile/TableProMobile/Views/RowDetailView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index bf30fd761..235661fa5 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -166,10 +166,11 @@ struct RowDetailView: View { Spacer() - Text("\(currentIndex + 1) / \(rows.count)") + Text("\(currentIndex + 1) of \(rows.count)") .font(.footnote) .foregroundStyle(.secondary) .monospacedDigit() + .fixedSize() Spacer() From b20dc670c57585c14bb79fcfcc01bb4adb2e75c0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 20:28:17 +0700 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20query=20editor=20menu=20=E2=80=94=20?= =?UTF-8?q?use=20topBarTrailing=20instead=20of=20secondaryAction=20pill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TableProMobile/TableProMobile/Views/QueryEditorView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 36dcdce56..fe89c51f0 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -192,7 +192,7 @@ struct QueryEditorView: View { .disabled(!isExecuting && query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - ToolbarItem(placement: .secondaryAction) { + ToolbarItem(placement: .topBarTrailing) { Menu { Button { showHistory = true From 0b9543bbf57829cb59fc51fc46cb0237478de541 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 20:32:41 +0700 Subject: [PATCH 9/9] fix: fetch row count only on initial load, add CHANGELOG entry --- CHANGELOG.md | 1 + TableProMobile/TableProMobile/Views/DataBrowserView.swift | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 214b20c9d..4488d315c 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 - iOS: connection groups and tags - iOS: Quick Connect Home Screen widget +- iOS: page-based pagination for data browser ## [0.27.4] - 2026-04-05 diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 35b7225dc..c476b339d 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -214,7 +214,9 @@ struct DataBrowserView: View { columns = result.columns rows = result.rows columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil) - await fetchTotalRows(session: session) + if pagination.totalRows == nil { + await fetchTotalRows(session: session) + } isLoading = false } catch { appError = ErrorClassifier.classify(