diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index ad58b4eef..14902c9f8 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -14,6 +14,7 @@ import { KeyRound, TerminalSquare, Asterisk, + History, } from "@lucide/vue"; import { RecycleScroller } from "vue-virtual-scroller"; import "vue-virtual-scroller/dist/vue-virtual-scroller.css"; @@ -29,7 +30,8 @@ import { Switch } from "@/components/ui/switch"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import RedisValueViewer from "./RedisValueViewer.vue"; import * as api from "@/lib/api"; -import type { RedisKeyInfo, RedisScanResult } from "@/lib/api"; +import type { RedisKeyInfo, RedisScanResult, HistoryEntry } from "@/lib/api"; +import { uuid } from "@/lib/utils"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { @@ -443,19 +445,46 @@ async function runRedisCommand(command: string) { if (result.safety === "confirm") { await loadKeys(); } + // Persist to history + persistRedisHistory(command, true, result.value); } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); appendCommandHistory({ prompt, command, - output: error instanceof Error ? error.message : String(error), + output: errorMessage, error: true, }); + // Persist failed command too + persistRedisHistory(command, false, null, errorMessage); } finally { commandRunning.value = false; scrollCommandTerminalToEnd(); } } +function persistRedisHistory(command: string, success: boolean, resultValue?: unknown, errorMessage?: string) { + const connName = connectionStore.getConfig(props.connectionId)?.name || ""; + const entry: HistoryEntry = { + id: uuid(), + connection_id: props.connectionId, + connection_name: connName, + database: String(commandDb.value), + sql: command, + executed_at: new Date().toISOString(), + execution_time_ms: 0, + success, + error: errorMessage, + activity_kind: "redis_command", + operation: command.split(" ")[0].toUpperCase(), + target: "", + affected_rows: null, + rollback_sql: null, + details_json: resultValue != null ? JSON.stringify(resultValue) : null, + }; + void api.saveHistory(entry); +} + async function openCommandPanel() { activeSidePanel.value = "command"; await nextTick(); @@ -794,9 +823,38 @@ function resumeRedisBrowserBackgroundWork() { registerRedisDbFlushedListener(); } +async function loadPersistedRedisHistory() { + try { + const entries = await api.loadRedisHistory(200, 0); + if (entries.length === 0) return; + // Merge persisted entries into in-memory commandHistory (newest first, reversed for display order) + const persisted: RedisCommandHistoryEntry[] = entries.reverse().map((entry) => ({ + id: ++commandHistoryId, + prompt: `db${entry.database}>`, + command: entry.sql, + output: entry.details_json ? formatRedisCommandResult(JSON.parse(entry.details_json)) : entry.error || "", + error: !entry.success, + })); + commandHistory.value = [...persisted, ...commandHistory.value]; + scrollCommandTerminalToEnd(); + } catch { + // Silently ignore load errors — history is best-effort + } +} + +async function clearPersistedRedisHistory() { + try { + await api.clearRedisHistory(); + toast(t("redis.historyCleared")); + } catch { + // Silently ignore + } +} + onMounted(() => { resumeRedisBrowserBackgroundWork(); void loadKeys(); + void loadPersistedRedisHistory(); }); onActivated(resumeRedisBrowserBackgroundWork); @@ -1019,7 +1077,7 @@ defineExpose({ focusSearch });
-
+
@@ -1034,6 +1092,16 @@ defineExpose({ focusSearch }); {{ t("redis.commandLine") }} +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index c18dc4ccd..8e165e496 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1481,6 +1481,8 @@ export default { formatJson: "Format", compressJson: "Compress", jsonFormatError: "Invalid JSON format", + clearHistory: "Clear command history", + historyCleared: "Redis command history cleared", }, mongo: { documents: "{count} documents", @@ -1536,6 +1538,7 @@ export default { schema_change: "Schema change", import: "Import", transfer: "Transfer", + redis_command: "Redis Command", }, kindShort: { query: "SQL", @@ -1543,6 +1546,7 @@ export default { schema_change: "DDL", import: "Import", transfer: "Move", + redis_command: "Redis", }, detail: { kind: "Type", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 61a2cfa48..2b8288515 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1269,6 +1269,8 @@ export default { jsonView: "Vista JSON", rawContent: "Contenido original", wordWrap: "Ajuste de línea", + clearHistory: "Borrar historial de comandos", + historyCleared: "Historial de comandos Redis borrado", }, mongo: { documents: "{count} documentos", @@ -1321,6 +1323,7 @@ export default { schema_change: "Cambio de esquema", import: "Importación", transfer: "Transferencia", + redis_command: "Comando Redis", }, kindShort: { query: "SQL", @@ -1328,6 +1331,7 @@ export default { schema_change: "DDL", import: "Import", transfer: "Mover", + redis_command: "Redis", }, detail: { kind: "Tipo", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ee53260ac..d957a896b 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1402,6 +1402,8 @@ export default { jsonView: "Visualizzazione JSON", rawContent: "Contenuto grezzo", wordWrap: "A capo automatico", + clearHistory: "Cancella cronologia comandi", + historyCleared: "Cronologia comandi Redis cancellata", }, mongo: { documents: "{count} documenti", @@ -1458,6 +1460,7 @@ export default { schema_change: "Modifica schema", import: "Importazione", transfer: "Trasferimento", + redis_command: "Comando Redis", }, kindShort: { query: "SQL", @@ -1465,6 +1468,7 @@ export default { schema_change: "DDL", import: "Importa", transfer: "Sposta", + redis_command: "Redis", }, detail: { kind: "Tipo", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 43f4d85c0..8bb395b16 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1397,6 +1397,8 @@ export default { jsonView: "Visão JSON", rawContent: "Conteúdo bruto", wordWrap: "Quebra de linha", + clearHistory: "Limpar histórico de comandos", + historyCleared: "Histórico de comandos Redis limpo", }, mongo: { documents: "{count} documentos", @@ -1452,6 +1454,7 @@ export default { schema_change: "Alteração de schema", import: "Importação", transfer: "Transferência", + redis_command: "Comando Redis", }, kindShort: { query: "SQL", @@ -1459,6 +1462,7 @@ export default { schema_change: "DDL", import: "Importar", transfer: "Mover", + redis_command: "Redis", }, detail: { kind: "Tipo", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 19c59b6ce..8a3434da9 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1453,6 +1453,8 @@ export default { formatJson: "格式化", compressJson: "压缩", jsonFormatError: "JSON 格式不合法", + clearHistory: "清除命令历史", + historyCleared: "Redis 命令历史已清除", }, mongo: { documents: "{count} 个文档", @@ -1508,6 +1510,7 @@ export default { schema_change: "结构变更", import: "导入", transfer: "传输", + redis_command: "Redis 命令", }, kindShort: { query: "查询", @@ -1515,6 +1518,7 @@ export default { schema_change: "结构", import: "导入", transfer: "传输", + redis_command: "Redis", }, detail: { kind: "类型", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3e00ba65d..59b0f58a7 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1329,6 +1329,8 @@ export default { jsonView: "JSON 檢視", rawContent: "原始內容", wordWrap: "自動換行", + clearHistory: "清除命令歷史", + historyCleared: "Redis 命令歷史已清除", }, mongo: { documents: "{count} 個文件", @@ -1384,6 +1386,7 @@ export default { schema_change: "結構變更", import: "匯入", transfer: "傳輸", + redis_command: "Redis 命令", }, kindShort: { query: "查詢", @@ -1391,6 +1394,7 @@ export default { schema_change: "結構", import: "匯入", transfer: "傳輸", + redis_command: "Redis", }, detail: { kind: "類型", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index b2250305d..190dfdd4c 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -269,7 +269,9 @@ export const mongoDeleteDocuments = forward("mongoDeleteDocuments"); // History export const saveHistory = forward("saveHistory"); export const loadHistory = forward("loadHistory"); +export const loadRedisHistory = forward("loadRedisHistory"); export const clearHistory = forward("clearHistory"); +export const clearRedisHistory = forward("clearRedisHistory"); export const deleteHistoryEntry = forward("deleteHistoryEntry"); // Updates diff --git a/apps/desktop/src/lib/historyActivityKind.ts b/apps/desktop/src/lib/historyActivityKind.ts index 3d65e506b..c906b20bd 100644 --- a/apps/desktop/src/lib/historyActivityKind.ts +++ b/apps/desktop/src/lib/historyActivityKind.ts @@ -1,4 +1,4 @@ -export type HistoryActivityKind = "query" | "data_change" | "schema_change" | "import" | "transfer"; +export type HistoryActivityKind = "query" | "data_change" | "schema_change" | "import" | "transfer" | "redis_command"; export type HistoryActivitySource = { activity_kind?: HistoryActivityKind; diff --git a/apps/desktop/src/lib/historyAiAnalysis.ts b/apps/desktop/src/lib/historyAiAnalysis.ts index 3e889b7f4..121b86693 100644 --- a/apps/desktop/src/lib/historyAiAnalysis.ts +++ b/apps/desktop/src/lib/historyAiAnalysis.ts @@ -8,7 +8,7 @@ export type HistoryAiAnalysisEntry = { execution_time_ms: number; success: boolean; error?: string | null; - activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer"; + activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer" | "redis_command"; operation?: string; target?: string; affected_rows?: number | null; diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index ef559d084..2c276c68e 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -1602,14 +1602,23 @@ export async function saveHistory(entry: HistoryEntry): Promise { return post("/api/history/save", { entry }); } -export async function loadHistory(limit: number, offset: number): Promise { - return get(`/api/history?${qs({ limit, offset })}`); +export async function loadHistory(limit: number, offset: number, activityKind?: string): Promise { + return get(`/api/history?${qs({ limit, offset, activity_kind: activityKind })}`); +} + +export async function loadRedisHistory(limit = 100, offset = 0): Promise { + return loadHistory(limit, offset, "redis_command"); } export async function clearHistory(): Promise { return del("/api/history"); } +export async function clearRedisHistory(): Promise { + const entries = await loadRedisHistory(1000, 0); + await Promise.all(entries.map((e) => deleteHistoryEntry(e.id))); +} + export async function deleteHistoryEntry(id: string): Promise { return del(`/api/history/${id}`); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 34e98f0d6..7504d0997 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1490,7 +1490,7 @@ export interface HistoryEntry { execution_time_ms: number; success: boolean; error?: string; - activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer"; + activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer" | "redis_command"; operation?: string; target?: string; affected_rows?: number | null; @@ -1502,14 +1502,23 @@ export async function saveHistory(entry: HistoryEntry): Promise { return invoke("save_history", { entry }); } -export async function loadHistory(limit: number, offset: number): Promise { - return invoke("load_history", { limit, offset }); +export async function loadHistory(limit: number, offset: number, activityKind?: string): Promise { + return invoke("load_history", { limit, offset, activityKind: activityKind ?? null }); +} + +export async function loadRedisHistory(limit = 100, offset = 0): Promise { + return loadHistory(limit, offset, "redis_command"); } export async function clearHistory(): Promise { return invoke("clear_history"); } +export async function clearRedisHistory(): Promise { + const entries = await loadRedisHistory(1000, 0); + await Promise.all(entries.map((e) => deleteHistoryEntry(e.id))); +} + export async function deleteHistoryEntry(id: string): Promise { return invoke("delete_history_entry", { id }); } diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 1fcda9f50..aeb79d330 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -338,44 +338,61 @@ impl Storage { .await } - pub async fn load_history_entries(&self, limit: usize, offset: usize) -> Result, String> { + pub async fn load_history_entries( + &self, + limit: usize, + offset: usize, + activity_kind: Option, + ) -> Result, String> { self.with_conn(move |conn| { - let mut stmt = conn - .prepare( - "SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \ - error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \ - FROM history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2", - ) - .map_err(|e| e.to_string())?; - let rows = stmt - .query_map(params![limit as i64, offset as i64], |row| { - Ok(HistoryEntry { - id: row.get(0)?, - connection_name: row.get(1)?, - database: row.get(2)?, - sql: row.get(3)?, - executed_at: row.get(4)?, - execution_time_ms: row.get::<_, i64>(5)? as u128, - success: row.get(6)?, - error: row.get(7)?, - activity_kind: { - let value: String = row.get(8)?; - if value.is_empty() { - "query".to_string() - } else { - value - } - }, - connection_id: row.get(9)?, - operation: row.get(10)?, - target: row.get(11)?, - affected_rows: row.get(12)?, - rollback_sql: row.get(13)?, - details_json: row.get(14)?, - }) + let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result { + Ok(HistoryEntry { + id: row.get(0)?, + connection_name: row.get(1)?, + database: row.get(2)?, + sql: row.get(3)?, + executed_at: row.get(4)?, + execution_time_ms: row.get::<_, i64>(5)? as u128, + success: row.get(6)?, + error: row.get(7)?, + activity_kind: { + let value: String = row.get(8)?; + if value.is_empty() { "query".to_string() } else { value } + }, + connection_id: row.get(9)?, + operation: row.get(10)?, + target: row.get(11)?, + affected_rows: row.get(12)?, + rollback_sql: row.get(13)?, + details_json: row.get(14)?, }) - .map_err(|e| e.to_string())?; - rows.collect::, _>>().map_err(|e| e.to_string()) + }; + + if let Some(kind) = activity_kind { + let mut stmt = conn + .prepare( + "SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \ + error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \ + FROM history WHERE activity_kind = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![kind, limit as i64, offset as i64], map_row) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + } else { + let mut stmt = conn + .prepare( + "SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \ + error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \ + FROM history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![limit as i64, offset as i64], map_row) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + } }) .await } diff --git a/crates/dbx-web/src/routes/history.rs b/crates/dbx-web/src/routes/history.rs index df9415757..972b877a6 100644 --- a/crates/dbx-web/src/routes/history.rs +++ b/crates/dbx-web/src/routes/history.rs @@ -12,6 +12,7 @@ use crate::state::WebState; pub struct HistoryQuery { pub limit: Option, pub offset: Option, + pub activity_kind: Option, } #[derive(Deserialize)] @@ -34,7 +35,7 @@ pub async fn load_history( ) -> Result>, AppError> { let limit = q.limit.unwrap_or(100); let offset = q.offset.unwrap_or(0); - let entries = state.app.storage.load_history_entries(limit, offset).await.map_err(AppError)?; + let entries = state.app.storage.load_history_entries(limit, offset, q.activity_kind).await.map_err(AppError)?; Ok(Json(entries)) } diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index e81f045d7..05e00c78d 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -14,8 +14,9 @@ pub async fn load_history( state: State<'_, Arc>, limit: usize, offset: usize, + activity_kind: Option, ) -> Result, String> { - state.storage.load_history_entries(limit, offset).await + state.storage.load_history_entries(limit, offset, activity_kind).await } #[tauri::command]