From 4fc4a04dea66ffbd90ec2beac49f050c1616e65a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 21:37:28 +0700 Subject: [PATCH 1/2] feat: persistent query history with timestamps, swipe-to-delete, clear all --- .../Helpers/QueryHistoryStorage.swift | 74 +++++++++++++++++++ .../TableProMobile/Views/ConnectedView.swift | 12 ++- .../Views/QueryEditorView.swift | 46 ++++++++---- 3 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift diff --git a/TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift b/TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift new file mode 100644 index 000000000..f7b8fc618 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift @@ -0,0 +1,74 @@ +// +// QueryHistoryStorage.swift +// TableProMobile +// + +import Foundation + +struct QueryHistoryItem: Identifiable, Codable, Hashable { + let id: UUID + let query: String + let timestamp: Date + let connectionId: UUID + + init(id: UUID = UUID(), query: String, timestamp: Date = Date(), connectionId: UUID) { + self.id = id + self.query = query + self.timestamp = timestamp + self.connectionId = connectionId + } +} + +struct QueryHistoryStorage { + private static let maxEntries = 200 + + private var fileURL: URL? { + guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let appDir = dir.appendingPathComponent("TableProMobile", isDirectory: true) + try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) + return appDir.appendingPathComponent("query-history.json") + } + + func save(_ item: QueryHistoryItem) { + var items = loadAll() + if items.last?.query == item.query && items.last?.connectionId == item.connectionId { + return + } + items.append(item) + if items.count > Self.maxEntries { + items.removeFirst(items.count - Self.maxEntries) + } + writeAll(items) + } + + func loadAll() -> [QueryHistoryItem] { + guard let fileURL, let data = try? Data(contentsOf: fileURL), + let items = try? JSONDecoder().decode([QueryHistoryItem].self, from: data) else { + return [] + } + return items + } + + func load(for connectionId: UUID) -> [QueryHistoryItem] { + loadAll().filter { $0.connectionId == connectionId } + } + + func delete(_ id: UUID) { + var items = loadAll() + items.removeAll { $0.id == id } + writeAll(items) + } + + func clearAll(for connectionId: UUID) { + var items = loadAll() + items.removeAll { $0.connectionId == connectionId } + writeAll(items) + } + + private func writeAll(_ items: [QueryHistoryItem]) { + guard let fileURL, let data = try? JSONEncoder().encode(items) else { return } + try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 016474f53..903aaa747 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -22,7 +22,8 @@ struct ConnectedView: View { @State private var toastMessage: String? @State private var toastTask: Task? @State private var selectedTab = ConnectedTab.tables - @State private var queryHistory: [String] = [] + @State private var queryHistory: [QueryHistoryItem] = [] + private let historyStorage = QueryHistoryStorage() @State private var databases: [String] = [] @State private var activeDatabase: String = "" @State private var schemas: [String] = [] @@ -149,7 +150,10 @@ struct ConnectedView: View { } } } - .task { await connect() } + .task { + await connect() + queryHistory = historyStorage.load(for: connection.id) + } .onChange(of: scenePhase) { _, phase in if phase == .active, session != nil { Task { await reconnectIfNeeded() } @@ -180,7 +184,9 @@ struct ConnectedView: View { QueryEditorView( session: session, tables: tables, - queryHistory: $queryHistory + queryHistory: $queryHistory, + connectionId: connection.id, + historyStorage: historyStorage ) } } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index fe89c51f0..b7fe06b08 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -21,7 +21,9 @@ struct QueryEditorView: View { @State private var isExecuting = false @State private var executionTime: TimeInterval? @State private var executeTask: Task? - @Binding var queryHistory: [String] + @Binding var queryHistory: [QueryHistoryItem] + let connectionId: UUID + let historyStorage: QueryHistoryStorage @State private var showHistory = false @FocusState private var editorFocused: Bool @@ -234,21 +236,42 @@ struct QueryEditorView: View { private var historySheet: some View { NavigationStack { List { - ForEach(queryHistory.reversed(), id: \.self) { historyQuery in + ForEach(queryHistory.reversed()) { item in Button { - query = historyQuery + query = item.query showHistory = false } label: { - Text(verbatim: historyQuery) - .font(.system(.footnote, design: .monospaced)) - .lineLimit(3) - .foregroundStyle(.primary) + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: item.query) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(3) + .foregroundStyle(.primary) + Text(item.timestamp, style: .relative) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .onDelete { indexSet in + let reversed = queryHistory.reversed().map(\.id) + for index in indexSet { + historyStorage.delete(reversed[index]) } + queryHistory = historyStorage.load(for: connectionId) } } + .listStyle(.insetGrouped) .navigationTitle("Query History") .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .cancellationAction) { + if !queryHistory.isEmpty { + Button("Clear All", role: .destructive) { + historyStorage.clearAll(for: connectionId) + queryHistory = [] + } + } + } ToolbarItem(placement: .confirmationAction) { Button("Done") { showHistory = false } } @@ -282,12 +305,9 @@ struct QueryEditorView: View { self.result = queryResult self.executionTime = queryResult.executionTime - if !queryHistory.contains(trimmed) { - queryHistory.append(trimmed) - if queryHistory.count > 50 { - queryHistory.removeFirst() - } - } + let item = QueryHistoryItem(query: trimmed, connectionId: connectionId) + historyStorage.save(item) + queryHistory = historyStorage.load(for: connectionId) } catch { let context = ErrorContext(operation: "executeQuery") self.appError = ErrorClassifier.classify(error, context: context) From 61acd9260f65a474f5fdea6b464781e6ee8ae554 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 21:43:27 +0700 Subject: [PATCH 2/2] docs: add CHANGELOG entry for query history --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 886bfd959..9d9de364b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: Quick Connect Home Screen widget - iOS: page-based pagination for data browser - iOS: filter bar with 16 operators, AND/OR logic +- iOS: persistent query history with timestamps ## [0.27.4] - 2026-04-05