Skip to content

Commit 0f31b5a

Browse files
authored
feat: Redis key namespace tree view with collapse/expand (#418) (#429)
* feat: add Redis key namespace tree view with collapse/expand grouping (#418) * fix: initialize RedisKeyTreeViewModel and trigger key loading on database select * fix: prevent infinite SwiftUI re-render loop in Redis key tree filtering * fix: eliminate all state mutation from view body to prevent SwiftUI re-render loop * fix: address PR review — access control, KEYS instead of SCAN, multi-char separator, namespace tap, clear on fail * fix: move RedisKeyTreeViewModel creation out of SidebarView init to prevent re-render loop * test: add comprehensive tests for Redis key tree building, model, and filtering * fix: load Redis key tree on first connect, not only on database switch * fix: rename Redis table entity from "Keys" to "Databases" for db0-db15 section * fix: read Redis key tree from SharedSidebarState so it appears on first connect * fix: address PR review — access control, clear on error, new tab per key, separator-aware title * docs: update Redis docs with key namespace tree view and separator setting * docs: update Chinese Redis docs with key namespace tree view
1 parent dd8bc55 commit 0f31b5a

14 files changed

Lines changed: 742 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Redis key namespace tree view with collapse/expand grouping in sidebar (#418)
1213
- Keyboard focus navigation (Tab, Ctrl+J/K/N/P, arrow keys) for connection list, quick switcher, and database switcher
1314
- MongoDB `mongodb+srv://` URI support with SRV toggle, Auth Mechanism dropdown, and Replica Set field (#419)
1415
- Show all available database types in connection form with install status badge (#418)

Plugins/RedisDriverPlugin/RedisPlugin.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
2828
defaultValue: "0",
2929
fieldType: .stepper(range: ConnectionField.IntRange(0...15))
3030
),
31+
ConnectionField(
32+
id: "redisSeparator",
33+
label: String(localized: "Key Separator"),
34+
defaultValue: ":",
35+
fieldType: .text,
36+
section: .advanced
37+
),
3138
]
3239
static let additionalDatabaseTypeIds: [String] = []
3340

@@ -45,7 +52,7 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
4552
static let supportsSchemaEditing = false
4653
static let supportsDatabaseSwitching = false
4754
static let supportsImport = false
48-
static let tableEntityName = "Keys"
55+
static let tableEntityName = "Databases"
4956
static let supportsForeignKeyDisable = false
5057
static let supportsReadOnlyMode = false
5158
static let databaseGroupingStrategy: GroupingStrategy = .flat
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// RedisKeyNode.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
internal enum RedisKeyNode: Identifiable, Hashable {
9+
case namespace(name: String, fullPrefix: String, children: [RedisKeyNode], keyCount: Int)
10+
case key(name: String, fullKey: String, keyType: String)
11+
12+
var id: String {
13+
switch self {
14+
case .namespace(_, let fullPrefix, _, _): return "ns:\(fullPrefix)"
15+
case .key(_, let fullKey, _): return "key:\(fullKey)"
16+
}
17+
}
18+
19+
var displayName: String {
20+
switch self {
21+
case .namespace(let name, _, _, _): return name
22+
case .key(let name, _, _): return name
23+
}
24+
}
25+
26+
// Hash on id only (children excluded for performance)
27+
func hash(into hasher: inout Hasher) {
28+
hasher.combine(id)
29+
}
30+
31+
static func == (lhs: RedisKeyNode, rhs: RedisKeyNode) -> Bool {
32+
lhs.id == rhs.id
33+
}
34+
}

TablePro/Models/UI/SharedSidebarState.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ internal enum SidebarTab: String, CaseIterable {
1818
final class SharedSidebarState {
1919
var selectedTables: Set<TableInfo> = []
2020
var searchText: String = ""
21+
var redisKeyTreeViewModel: RedisKeyTreeViewModel?
2122

2223
var selectedSidebarTab: SidebarTab {
2324
didSet {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
//
2+
// RedisKeyTreeViewModel.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import Observation
8+
import os
9+
10+
@MainActor @Observable
11+
internal final class RedisKeyTreeViewModel {
12+
private static let logger = Logger(subsystem: "com.TablePro", category: "RedisKeyTree")
13+
private static let maxKeys = 50_000
14+
15+
var rootNodes: [RedisKeyNode] = []
16+
var expandedPrefixes: Set<String> = []
17+
var isLoading = false
18+
var isTruncated = false
19+
var separator: String = ":"
20+
21+
private(set) var allKeys: [(key: String, type: String)] = []
22+
23+
/// Test-only setter for allKeys
24+
var allKeysForTesting: [(key: String, type: String)] {
25+
get { allKeys }
26+
set { allKeys = newValue }
27+
}
28+
29+
func loadKeys(connectionId: UUID, database: String, separator: String) async {
30+
self.separator = separator
31+
isLoading = true
32+
isTruncated = false
33+
defer { isLoading = false }
34+
35+
guard let driver = DatabaseManager.shared.driver(for: connectionId) else {
36+
clear()
37+
return
38+
}
39+
40+
do {
41+
// Use KEYS command for simplicity — returns all keys matching pattern
42+
let result = try await driver.execute(query: "KEYS *")
43+
44+
let keyColumnIndex = result.columns.firstIndex(of: "Key") ?? 0
45+
let typeColumnIndex = result.columns.firstIndex(of: "Type") ?? 1
46+
47+
var keys: [(key: String, type: String)] = []
48+
for row in result.rows {
49+
guard keyColumnIndex < row.count,
50+
let keyName = row[keyColumnIndex] else { continue }
51+
let keyType = typeColumnIndex < row.count ? (row[typeColumnIndex] ?? "string") : "string"
52+
keys.append((key: keyName, type: keyType))
53+
if keys.count >= Self.maxKeys { break }
54+
}
55+
56+
isTruncated = keys.count >= Self.maxKeys
57+
allKeys = keys
58+
rootNodes = Self.buildTree(keys: keys, separator: separator)
59+
} catch {
60+
Self.logger.error("Failed to load Redis keys: \(error.localizedDescription, privacy: .public)")
61+
clear()
62+
}
63+
}
64+
65+
func clear() {
66+
rootNodes = []
67+
allKeys = []
68+
expandedPrefixes = []
69+
isTruncated = false
70+
}
71+
72+
func displayNodes(searchText: String) -> [RedisKeyNode] {
73+
guard !searchText.isEmpty else { return rootNodes }
74+
75+
let filtered = allKeys.filter { $0.key.localizedCaseInsensitiveContains(searchText) }
76+
if filtered.isEmpty { return [] }
77+
78+
return Self.buildTree(keys: filtered, separator: separator)
79+
}
80+
81+
// MARK: - Tree Building (Pure Function)
82+
83+
static func buildTree(keys: [(key: String, type: String)], separator: String) -> [RedisKeyNode] {
84+
guard !separator.isEmpty else {
85+
return keys.sorted { $0.key < $1.key }
86+
.map { .key(name: $0.key, fullKey: $0.key, keyType: $0.type) }
87+
}
88+
89+
var root = TrieNode()
90+
for entry in keys {
91+
let parts = entry.key.components(separatedBy: separator)
92+
root.insert(parts: parts, fullKey: entry.key, keyType: entry.type)
93+
}
94+
95+
return root.toRedisKeyNodes(parentPrefix: "", separator: separator)
96+
}
97+
}
98+
99+
// MARK: - Trie for Tree Building
100+
101+
private class TrieNode {
102+
var children: [String: TrieNode] = [:]
103+
var leafKeys: [(fullKey: String, keyType: String)] = []
104+
105+
func insert(parts: [String], fullKey: String, keyType: String) {
106+
guard !parts.isEmpty else {
107+
leafKeys.append((fullKey: fullKey, keyType: keyType))
108+
return
109+
}
110+
111+
if parts.count == 1 {
112+
leafKeys.append((fullKey: fullKey, keyType: keyType))
113+
} else {
114+
let segment = parts[0]
115+
let child = children[segment] ?? TrieNode()
116+
children[segment] = child
117+
child.insert(parts: Array(parts.dropFirst()), fullKey: fullKey, keyType: keyType)
118+
}
119+
}
120+
121+
func toRedisKeyNodes(parentPrefix: String, separator: String) -> [RedisKeyNode] {
122+
var nodes: [RedisKeyNode] = []
123+
124+
let sortedChildren = children.sorted { $0.key < $1.key }
125+
for (segment, child) in sortedChildren {
126+
let fullPrefix = parentPrefix.isEmpty ? "\(segment)\(separator)" : "\(parentPrefix)\(segment)\(separator)"
127+
let childNodes = child.toRedisKeyNodes(parentPrefix: fullPrefix, separator: separator)
128+
let keyCount = child.countLeafKeys()
129+
130+
if !childNodes.isEmpty || !child.leafKeys.isEmpty {
131+
nodes.append(.namespace(
132+
name: segment,
133+
fullPrefix: fullPrefix,
134+
children: childNodes,
135+
keyCount: keyCount
136+
))
137+
}
138+
}
139+
140+
let sortedLeafs = leafKeys.sorted { $0.fullKey < $1.fullKey }
141+
for leaf in sortedLeafs {
142+
let displayName: String
143+
if parentPrefix.isEmpty {
144+
displayName = leaf.fullKey
145+
} else {
146+
displayName = String(leaf.fullKey.dropFirst(parentPrefix.count))
147+
}
148+
nodes.append(.key(name: displayName, fullKey: leaf.fullKey, keyType: leaf.keyType))
149+
}
150+
151+
return nodes
152+
}
153+
154+
func countLeafKeys() -> Int {
155+
var count = leafKeys.count
156+
for child in children.values {
157+
count += child.countLeafKeys()
158+
}
159+
return count
160+
}
161+
}

TablePro/ViewModels/SidebarViewModel.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ final class SidebarViewModel {
6666
}() {
6767
didSet { UserDefaults.standard.set(isTablesExpanded, forKey: "sidebar.isTablesExpanded") }
6868
}
69+
var isRedisKeysExpanded: Bool = {
70+
let key = "sidebar.isRedisKeysExpanded"
71+
if UserDefaults.standard.object(forKey: key) != nil {
72+
return UserDefaults.standard.bool(forKey: key)
73+
}
74+
return true
75+
}() {
76+
didSet { UserDefaults.standard.set(isRedisKeysExpanded, forKey: "sidebar.isRedisKeysExpanded") }
77+
}
78+
var redisKeyTreeViewModel: RedisKeyTreeViewModel?
6979
var showOperationDialog = false
7080
var pendingOperationType: TableOperationType?
7181
var pendingOperationTables: [String] = []

TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,70 @@ extension MainContentCoordinator {
460460
}
461461
toolbarState.databaseName = database
462462
executeTableTabQueryDirectly()
463+
464+
let separator = connection.additionalFields["redisSeparator"] ?? ":"
465+
if sidebarViewModel?.redisKeyTreeViewModel == nil {
466+
let vm = RedisKeyTreeViewModel()
467+
sidebarViewModel?.redisKeyTreeViewModel = vm
468+
let sidebarState = SharedSidebarState.forConnection(connId)
469+
sidebarState.redisKeyTreeViewModel = vm
470+
}
471+
Task {
472+
await sidebarViewModel?.redisKeyTreeViewModel?.loadKeys(
473+
connectionId: connId,
474+
database: database,
475+
separator: separator
476+
)
477+
}
478+
}
479+
}
480+
481+
func initRedisKeyTreeIfNeeded() {
482+
guard connection.type == .redis else { return }
483+
let sidebarState = SharedSidebarState.forConnection(connectionId)
484+
guard sidebarState.redisKeyTreeViewModel == nil else { return }
485+
486+
let vm = RedisKeyTreeViewModel()
487+
sidebarState.redisKeyTreeViewModel = vm
488+
sidebarViewModel?.redisKeyTreeViewModel = vm
489+
490+
let connId = connectionId
491+
let database = toolbarState.databaseName
492+
let separator = connection.additionalFields["redisSeparator"] ?? ":"
493+
Task {
494+
await vm.loadKeys(connectionId: connId, database: database, separator: separator)
495+
}
496+
}
497+
498+
// MARK: - Redis Key Tree Navigation
499+
500+
func browseRedisNamespace(_ prefix: String) {
501+
let separator = connection.additionalFields["redisSeparator"] ?? ":"
502+
let escapedPrefix = prefix.replacingOccurrences(of: "\"", with: "\\\"")
503+
let query = "SCAN 0 MATCH \"\(escapedPrefix)*\" COUNT 200"
504+
let title = prefix.hasSuffix(separator) ? String(prefix.dropLast(separator.count)) : prefix
505+
tabManager.addTab(initialQuery: query, title: title)
506+
runQuery()
507+
}
508+
509+
func openRedisKey(_ keyName: String, keyType: String) {
510+
let escapedKey = keyName.replacingOccurrences(of: "\"", with: "\\\"")
511+
let query: String
512+
switch keyType.lowercased() {
513+
case "hash":
514+
query = "HGETALL \"\(escapedKey)\""
515+
case "list":
516+
query = "LRANGE \"\(escapedKey)\" 0 -1"
517+
case "set":
518+
query = "SMEMBERS \"\(escapedKey)\""
519+
case "zset":
520+
query = "ZRANGE \"\(escapedKey)\" 0 -1 WITHSCORES"
521+
case "stream":
522+
query = "XRANGE \"\(escapedKey)\" - +"
523+
default:
524+
query = "GET \"\(escapedKey)\""
463525
}
526+
tabManager.addTab(initialQuery: query, title: keyName)
527+
runQuery()
464528
}
465529
}

TablePro/Views/Main/MainContentCommandActions.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,7 @@ final class MainContentCommandActions {
545545
coordinator?.toolbarState.databaseVersion = driver.serverVersion
546546
}
547547
coordinator?.reloadSidebar()
548+
coordinator?.initRedisKeyTreeIfNeeded()
548549
}
549550
}
550551

0 commit comments

Comments
 (0)