Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
12 changes: 9 additions & 3 deletions TableProMobile/TableProMobile/Views/ConnectedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ struct ConnectedView: View {
@State private var toastMessage: String?
@State private var toastTask: Task<Void, Never>?
@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] = []
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -180,7 +184,9 @@ struct ConnectedView: View {
QueryEditorView(
session: session,
tables: tables,
queryHistory: $queryHistory
queryHistory: $queryHistory,
connectionId: connection.id,
historyStorage: historyStorage
)
}
}
Expand Down
46 changes: 33 additions & 13 deletions TableProMobile/TableProMobile/Views/QueryEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ struct QueryEditorView: View {
@State private var isExecuting = false
@State private var executionTime: TimeInterval?
@State private var executeTask: Task<Void, Never>?
@Binding var queryHistory: [String]
@Binding var queryHistory: [QueryHistoryItem]
let connectionId: UUID
let historyStorage: QueryHistoryStorage
@State private var showHistory = false
@FocusState private var editorFocused: Bool

Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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)
Expand Down
Loading