mirror of
https://github.com/home-assistant/iOS.git
synced 2026-02-04 11:42:39 -06:00
## Summary Database explorer in settings now displays priority columns first (`id`, `entityId`, `serverId`, `name`, `areaId`, `uniqueId`) followed by remaining columns alphabetically, improving information density and scannability. ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> N/A - Backend sorting logic only, UI layout unchanged ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes **Implementation:** - Added private `Dictionary` extension with `sortedKeys()` method for `[String: String]` - Applied to both list view (first 3 columns) and detail view (all columns) - Priority keys maintain specified order; remaining keys sorted alphabetically **Before:** ```swift ForEach(row.keys.sorted(), id: \.self) { key in // Keys sorted alphabetically: areaId, entityId, id, name, other, serverId } ``` **After:** ```swift ForEach(row.sortedKeys(), id: \.self) { key in // Keys sorted by priority: id, entityId, serverId, name, areaId, other } ``` <!-- START COPILOT CODING AGENT SUFFIX --> <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > In database explorer inside settings, improve the information readability, prefer displaying table columns named "id", "entityId, "serverId", "name", "areaId, "uniqueId" first </details> <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/home-assistant/iOS/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
229 lines
7.7 KiB
Swift
229 lines
7.7 KiB
Swift
import GRDB
|
|
import SFSafeSymbols
|
|
import Shared
|
|
import SwiftUI
|
|
|
|
private extension [String: String] {
|
|
func sortedKeys() -> [String] {
|
|
let priorityKeys = ["id", "entityId", "serverId", "name", "areaId", "uniqueId"]
|
|
let keys = Array(keys)
|
|
|
|
// Separate priority keys that exist in the dictionary from other keys
|
|
let existingPriorityKeys = priorityKeys.filter { keys.contains($0) }
|
|
let remainingKeys = keys.filter { !priorityKeys.contains($0) }.sorted()
|
|
|
|
return existingPriorityKeys + remainingKeys
|
|
}
|
|
}
|
|
|
|
struct DatabaseTableDetailView: View {
|
|
let tableName: String
|
|
|
|
@StateObject private var viewModel: DatabaseTableDetailViewModel
|
|
|
|
init(tableName: String) {
|
|
self.tableName = tableName
|
|
_viewModel = StateObject(wrappedValue: DatabaseTableDetailViewModel(tableName: tableName))
|
|
}
|
|
|
|
var body: some View {
|
|
List {
|
|
serverFilter
|
|
ForEach(viewModel.filteredRows.indices, id: \.self) { index in
|
|
rowView(viewModel.filteredRows[index])
|
|
}
|
|
if viewModel.filteredRows.isEmpty {
|
|
Text(L10n.Settings.DatabaseExplorer.noEntries)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.listRowBackground(Color.clear)
|
|
.font(.headline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.searchable(text: $viewModel.searchText)
|
|
.navigationTitle(tableName)
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.onAppear {
|
|
viewModel.loadData()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var serverFilter: some View {
|
|
if viewModel.hasServerIdColumn, Current.servers.all.count > 1 {
|
|
Section {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack {
|
|
Button {
|
|
viewModel.selectedServerId = nil
|
|
} label: {
|
|
PillView(
|
|
text: L10n.ClientEvents.EventType.all,
|
|
selected: viewModel.selectedServerId == nil
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
ForEach(Current.servers.all, id: \.identifier) { server in
|
|
Button {
|
|
viewModel.selectedServerId = server.identifier.rawValue
|
|
} label: {
|
|
PillView(
|
|
text: server.info.name,
|
|
selected: viewModel.selectedServerId == server.identifier.rawValue
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
.modify { view in
|
|
if #available(iOS 17.0, *) {
|
|
view.listSectionSpacing(DesignSystem.Spaces.one)
|
|
} else {
|
|
view
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func rowView(_ row: [String: String]) -> some View {
|
|
NavigationLink {
|
|
DatabaseRowDetailView(row: row)
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
|
|
ForEach(row.sortedKeys().prefix(3), id: \.self) { key in
|
|
HStack {
|
|
Text(key)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
Text(row[key] ?? "nil")
|
|
.font(.caption)
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
if row.count > 3 {
|
|
Text(L10n.Settings.DatabaseExplorer.moreFields(row.count - 3))
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class DatabaseTableDetailViewModel: ObservableObject {
|
|
let tableName: String
|
|
|
|
@Published var rows: [[String: String]] = []
|
|
@Published var searchText: String = ""
|
|
@Published var selectedServerId: String?
|
|
@Published var hasServerIdColumn: Bool = false
|
|
|
|
init(tableName: String) {
|
|
self.tableName = tableName
|
|
}
|
|
|
|
var filteredRows: [[String: String]] {
|
|
var result = rows
|
|
|
|
// Filter by serverId if selected and column exists
|
|
if let serverId = selectedServerId, hasServerIdColumn {
|
|
result = result.filter { row in
|
|
row["serverId"] == serverId
|
|
}
|
|
}
|
|
|
|
// Filter by search text
|
|
if !searchText.isEmpty {
|
|
result = result.filter { row in
|
|
row.values.contains { value in
|
|
value.lowercased().contains(searchText.lowercased())
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func loadData() {
|
|
do {
|
|
let database = Current.database()
|
|
|
|
// Validate table name exists in the database to prevent SQL injection
|
|
let tableExists = try database.read { db in
|
|
try String.fetchOne(
|
|
db,
|
|
sql: "SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
|
|
arguments: [tableName]
|
|
) != nil
|
|
}
|
|
|
|
guard tableExists else {
|
|
Current.Log.error("Table '\(tableName)' not found in database")
|
|
return
|
|
}
|
|
|
|
// Check if table has serverId column
|
|
let columns = try database.read { db in
|
|
try db.columns(in: tableName).map(\.name)
|
|
}
|
|
hasServerIdColumn = columns.contains("serverId")
|
|
|
|
// Fetch rows with a limit to prevent memory issues on large tables
|
|
// Table name is already validated to exist above via parameterized query
|
|
rows = try database.read { db in
|
|
let quotedTableName = "\"\(tableName.replacingOccurrences(of: "\"", with: "\"\""))\""
|
|
let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM \(quotedTableName) LIMIT 1000")
|
|
var result: [[String: String]] = []
|
|
while let row = try cursor.next() {
|
|
var dict: [String: String] = [:]
|
|
for column in row.columnNames {
|
|
if let value = row[column] {
|
|
dict[column] = String(describing: value)
|
|
} else {
|
|
dict[column] = "nil"
|
|
}
|
|
}
|
|
result.append(dict)
|
|
}
|
|
return result
|
|
}
|
|
} catch {
|
|
Current.Log.error("Failed to load table data: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DatabaseRowDetailView: View {
|
|
let row: [String: String]
|
|
|
|
var body: some View {
|
|
List {
|
|
ForEach(row.sortedKeys(), id: \.self) { key in
|
|
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
|
|
Text(key)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Text(row[key] ?? "nil")
|
|
.font(.body)
|
|
.foregroundColor(.primary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(L10n.Settings.DatabaseExplorer.rowDetail)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationView {
|
|
DatabaseTableDetailView(tableName: "hAAppEntity")
|
|
}
|
|
}
|