diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index b28ad7bd..0fa4c7f8 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -568,6 +568,21 @@ class RedisConnection { return redisReplyInt(result); } + /// DEL key [key ...]. Returns total number of keys removed. + Future delMany(Iterable keys) async { + _assertWritable(); + final list = keys.toList(); + if (list.isEmpty) return 0; + var total = 0; + const chunkSize = 200; + for (var i = 0; i < list.length; i += chunkSize) { + final chunk = list.sublist(i, (i + chunkSize).clamp(0, list.length)); + final result = await sendCommand(['DEL', ...chunk.map(redisCommandArg)]); + total += redisReplyInt(result); + } + return total; + } + /// RENAME old new. Future rename(Object oldKey, String newKey) async { _assertWritable(); @@ -843,6 +858,16 @@ class RedisConnectionTestFake extends RedisConnection { } } return removed; + case 'DEL': + final allKeys = [...firstScanKeys, ...secondScanKeys]; + var count = 0; + for (var i = 1; i < args.length; i++) { + final target = args[i].toString(); + if (allKeys.contains(target)) { + count++; + } + } + return count; default: return null; } diff --git a/lib/features/redis/redis_key_tree_engine.dart b/lib/features/redis/redis_key_tree_engine.dart new file mode 100644 index 00000000..695dce3a --- /dev/null +++ b/lib/features/redis/redis_key_tree_engine.dart @@ -0,0 +1,219 @@ +import 'package:querya_desktop/core/database/redis_bulk.dart'; + +/// Information about a single Redis key. +class RedisKeyInfo { + const RedisKeyInfo({ + required this.name, + required this.type, + required this.ttl, + }); + + final RedisBulkValue name; + final String type; + + /// -1 = no expiry, -2 = key does not exist, >= 0 = seconds remaining + final int ttl; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RedisKeyInfo && + runtimeType == other.runtimeType && + name == other.name && + type == other.type && + ttl == other.ttl; + + @override + int get hashCode => Object.hash(name, type, ttl); +} + +/// Represents a virtual folder containing nested folders and leaf keys. +class RedisKeyFolderNode { + RedisKeyFolderNode({ + required this.name, + required this.fullPrefix, + List? folders, + List? leaves, + }) : folders = folders ?? [], + leaves = leaves ?? []; + + /// The segment name of this folder (e.g. 'user', '100'). + final String name; + + /// Full path prefix including the delimiter (e.g. 'user:', 'user:100:'). + final String fullPrefix; + + /// Subfolders nested under this folder. + final List folders; + + /// Key leaves directly inside this folder. + final List leaves; + + /// Total count of keys in this subtree (both direct leaves and nested leaves). + int get totalKeyCount { + var count = leaves.length; + for (final f in folders) { + count += f.totalKeyCount; + } + return count; + } + + /// All leaf keys in this folder and its subfolders. + List get allKeys { + final result = []; + for (final l in leaves) { + result.add(l.keyInfo); + } + for (final f in folders) { + result.addAll(f.allKeys); + } + return result; + } +} + +/// Represents a single Redis key leaf in the tree. +class RedisKeyLeafNode { + const RedisKeyLeafNode({ + required this.name, + required this.keyInfo, + }); + + /// The segment name of the key (e.g. 'profile' or the full key name if at root). + final String name; + + /// The underlying Redis key information. + final RedisKeyInfo keyInfo; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RedisKeyLeafNode && + runtimeType == other.runtimeType && + name == other.name && + keyInfo == other.keyInfo; + + @override + int get hashCode => Object.hash(name, keyInfo); +} + +/// The hierarchical tree representation of Redis keys grouped by delimiter. +class RedisKeyTree { + const RedisKeyTree({ + required this.rootFolders, + required this.rootLeaves, + required this.delimiter, + }); + + final List rootFolders; + final List rootLeaves; + final String delimiter; + + bool get isEmpty => rootFolders.isEmpty && rootLeaves.isEmpty; + + int get totalKeyCount { + var count = rootLeaves.length; + for (final f in rootFolders) { + count += f.totalKeyCount; + } + return count; + } +} + +class _MutableFolder { + _MutableFolder({required this.name, required this.fullPrefix}); + + final String name; + final String fullPrefix; + final Map subfolders = {}; + final List leaves = []; +} + +/// Engine to partition and build a folder hierarchy of Redis keys by a delimiter. +class RedisKeyTreeEngine { + const RedisKeyTreeEngine._(); + + /// Builds a [RedisKeyTree] by grouping [keys] by [delimiter]. + /// + /// If [delimiter] is empty or whitespace, all keys are placed at the root. + static RedisKeyTree buildTree({ + required Iterable keys, + String delimiter = ':', + }) { + if (delimiter.isEmpty) { + final leaves = [ + for (final k in keys) RedisKeyLeafNode(name: k.name.label, keyInfo: k), + ]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return RedisKeyTree( + rootFolders: const [], + rootLeaves: leaves, + delimiter: delimiter, + ); + } + + final rootFolderMap = {}; + final rootLeaves = []; + + for (final key in keys) { + final label = key.name.label; + if (!label.contains(delimiter)) { + rootLeaves.add(RedisKeyLeafNode(name: label, keyInfo: key)); + continue; + } + + final parts = label.split(delimiter); + // Example: 'user:100:profile' with ':' -> parts: ['user', '100', 'profile'] + // Folder segments: ['user', '100'] + // Leaf segment: 'profile' + final folderSegments = parts.sublist(0, parts.length - 1); + final rawLeaf = parts.last; + final leafSegment = rawLeaf.isEmpty ? '(empty)' : rawLeaf; + + var currentMap = rootFolderMap; + _MutableFolder? currentFolder; + var currentPrefix = ''; + + for (final seg in folderSegments) { + currentPrefix = '$currentPrefix$seg$delimiter'; + currentFolder = currentMap.putIfAbsent( + seg, + () => _MutableFolder(name: seg, fullPrefix: currentPrefix), + ); + currentMap = currentFolder.subfolders; + } + + currentFolder!.leaves.add( + RedisKeyLeafNode(name: leafSegment, keyInfo: key), + ); + } + + rootLeaves.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + + List convertFolders(Map map) { + final sortedKeys = map.keys.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + final result = []; + for (final key in sortedKeys) { + final mf = map[key]!; + mf.leaves.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + result.add( + RedisKeyFolderNode( + name: mf.name, + fullPrefix: mf.fullPrefix, + folders: convertFolders(mf.subfolders), + leaves: mf.leaves, + ), + ); + } + return result; + } + + final rootFolders = convertFolders(rootFolderMap); + return RedisKeyTree( + rootFolders: rootFolders, + rootLeaves: rootLeaves, + delimiter: delimiter, + ); + } +} diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 78b624b3..8565e82e 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -7,6 +7,16 @@ import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart' import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; +import 'redis_key_tree_engine.dart'; + +export 'redis_key_tree_engine.dart' show RedisKeyInfo; + +/// View presentation mode for Redis keys: flat list or folder tree. +enum RedisKeyViewMode { + flat, + tree, +} + /// Paginated key browser for a Redis database. class RedisKeysView extends material.StatefulWidget { const RedisKeysView({ @@ -27,7 +37,7 @@ class RedisKeysView extends material.StatefulWidget { } class _RedisKeysViewState extends material.State { - List<_KeyInfo> _keys = []; + List _keys = []; bool _loading = true; bool _loadingMore = false; String? _error; @@ -35,7 +45,10 @@ class _RedisKeysViewState extends material.State { bool _hasMore = true; int _dbSize = 0; + RedisKeyViewMode _viewMode = RedisKeyViewMode.flat; final _filterController = material.TextEditingController(); + final _delimiterController = material.TextEditingController(text: ':'); + final Set _expandedFolders = {}; String _matchPattern = '*'; @override @@ -47,6 +60,7 @@ class _RedisKeysViewState extends material.State { @override void dispose() { _filterController.dispose(); + _delimiterController.dispose(); super.dispose(); } @@ -64,7 +78,13 @@ class _RedisKeysViewState extends material.State { _dbSize = await widget.connection.dbSize(); await _scanBatch(); if (!mounted) return; - setState(() => _loading = false); + setState(() { + _loading = false; + if (_viewMode == RedisKeyViewMode.tree && _expandedFolders.isEmpty) { + final tree = _buildTree(); + _expandedFolders.addAll(_collectAllFolderPrefixes(tree.rootFolders)); + } + }); } catch (e) { if (mounted) { setState(() { @@ -95,14 +115,14 @@ class _RedisKeysViewState extends material.State { iterations < maxIterations); // One pipelined burst of TYPE+TTL (not N× Future.wait round-trips). - List<_KeyInfo> infos; + List infos; String? typeTtlError; if (accumulatedKeys.isNotEmpty) { try { final metas = await widget.connection.typesAndTtls(accumulatedKeys); infos = [ for (var i = 0; i < accumulatedKeys.length; i++) - _KeyInfo( + RedisKeyInfo( name: accumulatedKeys[i], type: metas[i].type, ttl: metas[i].ttl, @@ -112,7 +132,7 @@ class _RedisKeysViewState extends material.State { // Still show keys with unknown type/TTL; surface the failure non-blocking. infos = [ for (final name in accumulatedKeys) - _KeyInfo(name: name, type: 'unknown', ttl: -1), + RedisKeyInfo(name: name, type: 'unknown', ttl: -1), ]; typeTtlError = 'Failed to load key types/TTLs: $e'; } @@ -155,7 +175,7 @@ class _RedisKeysViewState extends material.State { _load(); } - Future _deleteKey(_KeyInfo keyInfo) async { + Future _deleteKey(RedisKeyInfo keyInfo) async { if (widget.isReadOnly) return; final confirmed = await confirmDestructiveAction( context: context, @@ -177,6 +197,97 @@ class _RedisKeysViewState extends material.State { } } + Future _deleteFolder(RedisKeyFolderNode folder) async { + if (widget.isReadOnly) return; + final keys = folder.allKeys; + if (keys.isEmpty) return; + + final count = keys.length; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisDel, + targetName: + '${folder.fullPrefix}* ($count ${count == 1 ? 'key' : 'keys'})', + commandPreview: + 'DEL ${keys.map((k) => k.name.label).take(5).join(' ')}${count > 5 ? ' ... ($count total)' : ''}', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.delMany(keys.map((k) => k.name)); + setState(() { + final toRemove = keys.map((k) => k.name).toSet(); + _keys.removeWhere((k) => toRemove.contains(k.name)); + _dbSize = (_dbSize - count).clamp(0, _dbSize); + }); + } catch (e) { + if (mounted) setState(() => _error = 'Delete folder failed: $e'); + } + } + + RedisKeyTree _buildTree() { + return RedisKeyTreeEngine.buildTree( + keys: _keys, + delimiter: _delimiterController.text, + ); + } + + Set _collectAllFolderPrefixes(List folders) { + final result = {}; + void add(RedisKeyFolderNode f) { + result.add(f.fullPrefix); + for (final sub in f.folders) { + add(sub); + } + } + + for (final f in folders) { + add(f); + } + return result; + } + + List<_TreeItem> _buildVisibleTreeItems( + RedisKeyTree tree, + Set expandedFolders, + ) { + final items = <_TreeItem>[]; + + void addFolder(RedisKeyFolderNode folder, int depth) { + final isExpanded = expandedFolders.contains(folder.fullPrefix); + items.add(_FolderTreeItem( + folder: folder, + depth: depth, + isExpanded: isExpanded, + )); + + if (isExpanded) { + for (final sub in folder.folders) { + addFolder(sub, depth + 1); + } + for (final leaf in folder.leaves) { + items.add(_LeafTreeItem( + leaf: leaf, + depth: depth + 1, + )); + } + } + } + + for (final f in tree.rootFolders) { + addFolder(f, 0); + } + for (final l in tree.rootLeaves) { + items.add(_LeafTreeItem( + leaf: l, + depth: 0, + )); + } + + return items; + } + // ─── Build ────────────────────────────────────────────────────────────── @override @@ -200,76 +311,147 @@ class _RedisKeysViewState extends material.State { ); } + final material.Widget contentWidget; + if (_keys.isEmpty) { + contentWidget = material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(48), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(_hasMore + ? 'No keys found in scanned range' + : 'No keys found') + .muted(), + if (_hasMore) ...[ + const Gap(16), + OutlineButton( + onPressed: _loadingMore ? null : _loadMore, + size: ButtonSize.small, + child: _loadingMore + ? const Text('Scanning...') + : const Text('Continue scanning'), + ), + ], + ], + ), + ), + ); + } else if (_viewMode == RedisKeyViewMode.flat) { + contentWidget = material.ListView.builder( + padding: const material.EdgeInsets.all(16), + cacheExtent: 400, + itemCount: _keys.length + (_hasMore ? 1 : 0), + itemBuilder: (context, i) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + if (i >= _keys.length) { + return material.Padding( + padding: const material.EdgeInsets.only(top: 12), + child: material.Center( + child: OutlineButton( + onPressed: _loadingMore ? null : _loadMore, + size: ButtonSize.small, + child: _loadingMore + ? const Text('Loading...') + : Text('Load more (${_keys.length} / $_dbSize)'), + ), + ), + ); + } + return material.Padding( + padding: EdgeInsets.only(top: i > 0 ? 4 : 0), + child: _KeyTile( + keyInfo: _keys[i], + colorScheme: cs, + shadcnCs: shadcnCs, + palette: context.semanticPalette, + onTap: () => widget.onKeyTap?.call(_keys[i].name, _keys[i].type), + onDelete: widget.isReadOnly ? null : () => _deleteKey(_keys[i]), + ), + ); + }, + ); + } else { + final tree = _buildTree(); + final visibleItems = _buildVisibleTreeItems(tree, _expandedFolders); + + contentWidget = material.ListView.builder( + padding: const material.EdgeInsets.all(16), + cacheExtent: 400, + itemCount: visibleItems.length + (_hasMore ? 1 : 0), + itemBuilder: (context, i) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + if (i >= visibleItems.length) { + return material.Padding( + padding: const material.EdgeInsets.only(top: 12), + child: material.Center( + child: OutlineButton( + onPressed: _loadingMore ? null : _loadMore, + size: ButtonSize.small, + child: _loadingMore + ? const Text('Loading...') + : Text('Load more (${_keys.length} / $_dbSize)'), + ), + ), + ); + } + final item = visibleItems[i]; + if (item is _FolderTreeItem) { + return material.Padding( + padding: EdgeInsets.only(top: i > 0 ? 4 : 0), + child: _FolderTile( + folder: item.folder, + isExpanded: item.isExpanded, + depth: item.depth, + colorScheme: cs, + shadcnCs: shadcnCs, + palette: context.semanticPalette, + onToggle: () { + setState(() { + if (_expandedFolders.contains(item.folder.fullPrefix)) { + _expandedFolders.remove(item.folder.fullPrefix); + } else { + _expandedFolders.add(item.folder.fullPrefix); + } + }); + }, + onDelete: widget.isReadOnly + ? null + : () => _deleteFolder(item.folder), + ), + ); + } else if (item is _LeafTreeItem) { + return material.Padding( + padding: EdgeInsets.only(top: i > 0 ? 4 : 0), + child: _KeyTile( + keyInfo: item.leaf.keyInfo, + displayName: item.leaf.name, + indent: 16.0 * item.depth, + colorScheme: cs, + shadcnCs: shadcnCs, + palette: context.semanticPalette, + onTap: () => widget.onKeyTap?.call( + item.leaf.keyInfo.name, + item.leaf.keyInfo.type, + ), + onDelete: widget.isReadOnly + ? null + : () => _deleteKey(item.leaf.keyInfo), + ), + ); + } + return const material.SizedBox.shrink(); + }, + ); + } + return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ _buildFilterBar(cs), const Divider(height: 1), if (_error != null) _buildErrorBanner(cs), - material.Expanded( - child: _keys.isEmpty - ? material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(48), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - Text(_hasMore - ? 'No keys found in scanned range' - : 'No keys found') - .muted(), - if (_hasMore) ...[ - const Gap(16), - OutlineButton( - onPressed: _loadingMore ? null : _loadMore, - size: ButtonSize.small, - child: _loadingMore - ? const Text('Scanning...') - : const Text('Continue scanning'), - ), - ], - ], - ), - ), - ) - : material.ListView.builder( - padding: const material.EdgeInsets.all(16), - cacheExtent: 400, - itemCount: _keys.length + (_hasMore ? 1 : 0), - itemBuilder: (context, i) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; - if (i >= _keys.length) { - return material.Padding( - padding: const material.EdgeInsets.only(top: 12), - child: material.Center( - child: OutlineButton( - onPressed: _loadingMore ? null : _loadMore, - size: ButtonSize.small, - child: _loadingMore - ? const Text('Loading...') - : Text( - 'Load more (${_keys.length} / $_dbSize)'), - ), - ), - ); - } - return material.Padding( - padding: EdgeInsets.only(top: i > 0 ? 4 : 0), - child: _KeyTile( - keyInfo: _keys[i], - colorScheme: cs, - shadcnCs: shadcnCs, - palette: context.semanticPalette, - onTap: () => - widget.onKeyTap?.call(_keys[i].name, _keys[i].type), - onDelete: widget.isReadOnly - ? null - : () => _deleteKey(_keys[i]), - ), - ); - }, - ), - ), + material.Expanded(child: contentWidget), _buildStatusBar(cs), ], ); @@ -307,6 +489,118 @@ class _RedisKeysViewState extends material.State { size: ButtonSize.small, child: const Text('Clear'), ), + material.Container( + height: 20, + width: 1, + margin: const material.EdgeInsets.symmetric(horizontal: 8), + color: cs.border.withValues(alpha: 0.3), + ), + material.SizedBox( + height: 28, + child: material.SegmentedButton( + segments: const [ + material.ButtonSegment( + value: RedisKeyViewMode.flat, + label: material.Text('Flat'), + icon: + material.Icon(material.Icons.view_list_rounded, size: 14), + ), + material.ButtonSegment( + value: RedisKeyViewMode.tree, + label: material.Text('Tree'), + icon: material.Icon(material.Icons.account_tree_rounded, + size: 14), + ), + ], + selected: {_viewMode}, + onSelectionChanged: (selected) { + setState(() { + _viewMode = selected.first; + if (_viewMode == RedisKeyViewMode.tree && + _expandedFolders.isEmpty) { + final tree = _buildTree(); + _expandedFolders + .addAll(_collectAllFolderPrefixes(tree.rootFolders)); + } + }); + }, + showSelectedIcon: false, + style: material.SegmentedButton.styleFrom( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 0), + visualDensity: material.VisualDensity.compact, + ), + ), + ), + if (_viewMode == RedisKeyViewMode.tree) ...[ + const Gap(8), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + 'Sep:', + style: material.TextStyle( + fontSize: 12, + color: shadcnCs.mutedForeground, + ), + ), + const Gap(4), + material.SizedBox( + width: 36, + height: 28, + child: material.TextField( + controller: _delimiterController, + textAlign: material.TextAlign.center, + style: const material.TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + decoration: material.InputDecoration( + contentPadding: material.EdgeInsets.zero, + isDense: true, + border: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(6), + borderSide: material.BorderSide( + color: cs.border.withValues(alpha: 0.4), + ), + ), + focusedBorder: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(6), + borderSide: material.BorderSide(color: cs.primary), + ), + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const Gap(4), + material.Tooltip( + message: _expandedFolders.isEmpty + ? 'Expand all folders' + : 'Collapse all folders', + child: GhostButton( + size: ButtonSize.small, + onPressed: () { + setState(() { + if (_expandedFolders.isEmpty) { + final tree = _buildTree(); + _expandedFolders.addAll( + _collectAllFolderPrefixes(tree.rootFolders)); + } else { + _expandedFolders.clear(); + } + }); + }, + child: material.Icon( + _expandedFolders.isEmpty + ? material.Icons.unfold_more_rounded + : material.Icons.unfold_less_rounded, + size: 16, + ), + ), + ), + ], ], ), ); @@ -359,6 +653,10 @@ class _RedisKeysViewState extends material.State { const Text('Read-only session').muted().small(), ], const Spacer(), + if (_viewMode == RedisKeyViewMode.tree) ...[ + Text('Tree (sep: "${_delimiterController.text}")').muted().small(), + const Gap(16), + ], Text('${_keys.length} loaded').muted().small(), if (_hasMore) ...[ const Gap(8), @@ -370,17 +668,148 @@ class _RedisKeysViewState extends material.State { } } -// ─── Key info model ───────────────────────────────────────────────────────── +// ─── Tree item model ──────────────────────────────────────────────────────── + +sealed class _TreeItem { + int get depth; +} + +class _FolderTreeItem extends _TreeItem { + _FolderTreeItem({ + required this.folder, + required this.depth, + required this.isExpanded, + }); + + final RedisKeyFolderNode folder; + @override + final int depth; + final bool isExpanded; +} -class _KeyInfo { - const _KeyInfo({ - required this.name, - required this.type, - required this.ttl, +class _LeafTreeItem extends _TreeItem { + _LeafTreeItem({ + required this.leaf, + required this.depth, }); - final RedisBulkValue name; - final String type; - final int ttl; // -1 = no expiry, -2 = key doesn't exist + + final RedisKeyLeafNode leaf; + @override + final int depth; +} + +// ─── Folder tile widget ───────────────────────────────────────────────────── + +class _FolderTile extends material.StatelessWidget { + const _FolderTile({ + required this.folder, + required this.isExpanded, + required this.depth, + required this.colorScheme, + required this.shadcnCs, + required this.palette, + required this.onToggle, + this.onDelete, + }); + + final RedisKeyFolderNode folder; + final bool isExpanded; + final int depth; + final ColorScheme colorScheme; + final shadcn.ColorScheme shadcnCs; + final QueryaSemanticPalette palette; + final material.VoidCallback onToggle; + final material.VoidCallback? onDelete; + + @override + material.Widget build(material.BuildContext context) { + final cs = colorScheme; + final indent = 16.0 * depth; + + return material.Material( + color: cs.card.withValues(alpha: 0.6), + borderRadius: material.BorderRadius.circular(8), + clipBehavior: material.Clip.antiAlias, + child: material.InkWell( + onTap: onToggle, + hoverColor: shadcnCs.muted.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(8), + child: material.Padding( + padding: material.EdgeInsets.only( + left: 12 + indent, + right: 16, + top: 8, + bottom: 8, + ), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: isExpanded ? 0.25 : 0.0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 18, + color: shadcnCs.mutedForeground, + ), + ), + const Gap(6), + material.Icon( + isExpanded + ? material.Icons.folder_open_rounded + : material.Icons.folder_rounded, + size: 18, + color: palette.action, + ), + const Gap(8), + material.Text( + folder.name, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + fontFamily: 'monospace', + color: cs.foreground, + ), + ), + const Gap(8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(10), + ), + child: material.Text( + folder.totalKeyCount == 1 + ? '1 key' + : '${folder.totalKeyCount} keys', + style: material.TextStyle( + fontSize: 11, + color: shadcnCs.mutedForeground, + fontWeight: material.FontWeight.w500, + ), + ), + ), + const material.Spacer(), + if (onDelete != null) + material.IconButton( + onPressed: onDelete, + icon: material.Icon( + material.Icons.delete_outline_rounded, + size: 16, + color: palette.destructive, + ), + padding: const material.EdgeInsets.all(4), + constraints: const material.BoxConstraints( + minWidth: 28, minHeight: 28), + splashRadius: 18, + tooltip: 'Delete folder (${folder.totalKeyCount} keys)', + ), + ], + ), + ), + ), + ); + } } // ─── Key tile widget ──────────────────────────────────────────────────────── @@ -393,14 +822,18 @@ class _KeyTile extends material.StatelessWidget { required this.palette, required this.onTap, this.onDelete, + this.displayName, + this.indent = 0.0, }); - final _KeyInfo keyInfo; + final RedisKeyInfo keyInfo; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; final QueryaSemanticPalette palette; final material.VoidCallback onTap; final material.VoidCallback? onDelete; + final String? displayName; + final double indent; static Color _typeColor(String type, QueryaSemanticPalette palette) { switch (type) { @@ -460,8 +893,12 @@ class _KeyTile extends material.StatelessWidget { hoverColor: shadcnCs.muted.withValues(alpha: 0.15), borderRadius: material.BorderRadius.circular(8), child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: material.EdgeInsets.only( + left: 16 + indent, + right: 16, + top: 10, + bottom: 10, + ), child: material.Row( children: [ material.Icon(_typeIcon(ki.type), size: 16, color: typeCol), @@ -485,14 +922,17 @@ class _KeyTile extends material.StatelessWidget { ), const Gap(10), material.Expanded( - child: material.Text( - ki.name.label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: cs.foreground, + child: material.Tooltip( + message: ki.name.label, + child: material.Text( + displayName ?? ki.name.label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: cs.foreground, + ), ), ), ), diff --git a/test/features/redis/redis_key_tree_engine_test.dart b/test/features/redis/redis_key_tree_engine_test.dart new file mode 100644 index 00000000..7519f8c2 --- /dev/null +++ b/test/features/redis/redis_key_tree_engine_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/features/redis/redis_key_tree_engine.dart'; + +void main() { + group('RedisKeyTreeEngine', () { + RedisKeyInfo makeKey(String name, [String type = 'string', int ttl = -1]) { + return RedisKeyInfo( + name: RedisBulkValue.utf8(name), + type: type, + ttl: ttl, + ); + } + + test('buildTree returns empty tree for empty keys', () { + final tree = RedisKeyTreeEngine.buildTree(keys: const [], delimiter: ':'); + expect(tree.isEmpty, isTrue); + expect(tree.rootFolders, isEmpty); + expect(tree.rootLeaves, isEmpty); + expect(tree.totalKeyCount, 0); + }); + + test('buildTree groups keys into nested folders by colon delimiter', () { + final keys = [ + makeKey('user:100:profile'), + makeKey('user:100:settings'), + makeKey('user:200:avatar'), + makeKey('session:xyz'), + makeKey('standalone_key'), + ]; + + final tree = RedisKeyTreeEngine.buildTree(keys: keys, delimiter: ':'); + expect(tree.isEmpty, isFalse); + expect(tree.totalKeyCount, 5); + + // Root folders: 'session', 'user' + expect(tree.rootFolders.map((f) => f.name), ['session', 'user']); + expect(tree.rootLeaves.map((l) => l.name), ['standalone_key']); + + // 'session' folder + final sessionFolder = tree.rootFolders[0]; + expect(sessionFolder.fullPrefix, 'session:'); + expect(sessionFolder.folders, isEmpty); + expect(sessionFolder.leaves.length, 1); + expect(sessionFolder.leaves.first.name, 'xyz'); + expect(sessionFolder.totalKeyCount, 1); + + // 'user' folder + final userFolder = tree.rootFolders[1]; + expect(userFolder.fullPrefix, 'user:'); + expect(userFolder.folders.length, 2); + expect(userFolder.folders.map((f) => f.name), ['100', '200']); + expect(userFolder.leaves, isEmpty); + expect(userFolder.totalKeyCount, 3); + + // 'user:100' subfolder + final user100 = userFolder.folders[0]; + expect(user100.fullPrefix, 'user:100:'); + expect(user100.leaves.length, 2); + expect(user100.leaves.map((l) => l.name), ['profile', 'settings']); + expect(user100.totalKeyCount, 2); + expect(user100.allKeys.length, 2); + + // 'user:200' subfolder + final user200 = userFolder.folders[1]; + expect(user200.fullPrefix, 'user:200:'); + expect(user200.leaves.length, 1); + expect(user200.leaves.first.name, 'avatar'); + + // userFolder.allKeys returns all 3 nested keys + final allUserKeys = + userFolder.allKeys.map((k) => k.name.label).toList(); + expect(allUserKeys, [ + 'user:100:profile', + 'user:100:settings', + 'user:200:avatar', + ]); + }); + + test('buildTree groups by alternative slash or dot delimiter', () { + final slashKeys = [ + makeKey('api/v1/users'), + makeKey('api/v1/posts'), + makeKey('api/v2/users'), + ]; + + final slashTree = + RedisKeyTreeEngine.buildTree(keys: slashKeys, delimiter: '/'); + expect(slashTree.rootFolders.length, 1); + expect(slashTree.rootFolders.first.name, 'api'); + expect(slashTree.rootFolders.first.fullPrefix, 'api/'); + expect(slashTree.totalKeyCount, 3); + + final dotKeys = [ + makeKey('com.querya.desktop'), + makeKey('com.querya.mobile'), + ]; + final dotTree = + RedisKeyTreeEngine.buildTree(keys: dotKeys, delimiter: '.'); + expect(dotTree.rootFolders.first.name, 'com'); + expect(dotTree.rootFolders.first.fullPrefix, 'com.'); + expect(dotTree.totalKeyCount, 2); + }); + + test('empty delimiter keeps all keys as root leaves', () { + final keys = [ + makeKey('user:100:profile'), + makeKey('cache/item'), + ]; + final tree = RedisKeyTreeEngine.buildTree(keys: keys, delimiter: ''); + expect(tree.rootFolders, isEmpty); + expect(tree.rootLeaves.length, 2); + expect(tree.rootLeaves.map((l) => l.name), ['cache/item', 'user:100:profile']); + }); + + test('handles trailing delimiter gracefully without crashing', () { + final keys = [ + makeKey('trailing:'), + ]; + final tree = RedisKeyTreeEngine.buildTree(keys: keys, delimiter: ':'); + expect(tree.rootFolders.length, 1); + expect(tree.rootFolders.first.name, 'trailing'); + expect(tree.rootFolders.first.leaves.first.name, '(empty)'); + }); + }); +} diff --git a/test/features/redis/redis_keys_view_test.dart b/test/features/redis/redis_keys_view_test.dart index 968e15d9..62f78eba 100644 --- a/test/features/redis/redis_keys_view_test.dart +++ b/test/features/redis/redis_keys_view_test.dart @@ -276,6 +276,181 @@ void main() { expect(fake.scanCallCount, 11); await fake.disconnect(); }); + + testWidgets( + 'RedisKeysView toggles between Flat and Tree view modes and invokes onKeyTap', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(900, 700)); + final fake = RedisConnectionTestFake( + firstScanKeys: const [ + 'user:profile', + 'user:settings', + 'standalone', + ], + dbSizeResult: 3, + ); + await fake.connect(); + + String? tappedKey; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 900, + height: 700, + child: RedisKeysView( + connection: fake, + database: 0, + onKeyTap: (key, type) => tappedKey = key.label, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // In flat mode initially + expect(find.text('Flat'), findsOneWidget); + expect(find.text('Tree'), findsOneWidget); + expect(find.text('user:profile'), findsOneWidget); + expect(find.text('standalone'), findsOneWidget); + + // Switch to Tree view + await tester.tap(find.text('Tree')); + await tester.pumpAndSettle(); + + // Verify folder hierarchy + expect(find.text('user'), findsOneWidget); + expect(find.text('2 keys'), findsOneWidget); + expect(find.text('profile'), findsOneWidget); + expect(find.text('settings'), findsOneWidget); + expect(find.text('standalone'), findsOneWidget); + + // Tap a leaf key + await tester.tap(find.text('profile')); + await tester.pumpAndSettle(); + expect(tappedKey, 'user:profile'); + + // Switch back to Flat view + await tester.tap(find.text('Flat')); + await tester.pumpAndSettle(); + expect(find.text('user:profile'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets('RedisKeysView tree view collapse and expand folder', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(900, 700)); + final fake = RedisConnectionTestFake( + firstScanKeys: const [ + 'user:profile', + 'user:settings', + ], + dbSizeResult: 2, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 900, + height: 700, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Switch to Tree view + await tester.tap(find.text('Tree')); + await tester.pumpAndSettle(); + + expect(find.text('user'), findsOneWidget); + expect(find.text('profile'), findsOneWidget); + + // Collapse 'user' folder + await tester.tap(find.text('user')); + await tester.pumpAndSettle(); + + expect(find.text('user'), findsOneWidget); + expect(find.text('profile'), findsNothing); + + // Expand 'user' folder again + await tester.tap(find.text('user')); + await tester.pumpAndSettle(); + + expect(find.text('profile'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets( + 'RedisKeysView tree view folder delete cancels and executes delMany', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(900, 700)); + final fake = _DelTrackingFake( + firstScanKeys: const [ + 'user:profile', + 'user:settings', + 'standalone', + ], + dbSizeResult: 3, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 900, + height: 700, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Switch to Tree view + await tester.tap(find.text('Tree')); + await tester.pumpAndSettle(); + + final deleteFolderButton = find.byTooltip('Delete folder (2 keys)'); + expect(deleteFolderButton, findsOneWidget); + + // Tap delete folder, then Cancel + await tester.tap(deleteFolderButton); + await tester.pumpAndSettle(); + + expect(find.textContaining('DEL user:profile'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(fake.deleted, isEmpty); + + // Tap delete folder, then Confirm execution + await tester.tap(deleteFolderButton); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(material.Checkbox)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + + expect(fake.deleted, contains('user:profile')); + expect(fake.deleted, contains('user:settings')); + expect(find.text('user'), findsNothing); + expect(find.text('standalone'), findsOneWidget); + await fake.disconnect(); + }); } class _DelTrackingFake extends RedisConnectionTestFake { @@ -288,6 +463,14 @@ class _DelTrackingFake extends RedisConnectionTestFake { deleted.add(key is RedisBulkValue ? key.label : key.toString()); return super.del(key); } + + @override + Future delMany(Iterable keys) async { + for (final k in keys) { + deleted.add(k is RedisBulkValue ? k.label : k.toString()); + } + return super.delMany(keys); + } } class _MultiStepScanFake extends RedisConnectionTestFake {