feat(redis): persist command history across sessions (#933)

This commit is contained in:
t8y2 2026-06-10 04:33:36 +08:00
parent d08385e75f
commit d0bf6c3b53
15 changed files with 179 additions and 48 deletions

View File

@ -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 });
<Pane :size="64" :min-size="36">
<div class="h-full min-w-0 bg-background flex flex-col overflow-hidden">
<Tabs v-model="activeSidePanel" :unmount-on-hide="false" class="h-full min-h-0 gap-0">
<div class="h-9 shrink-0 border-b bg-background px-3 flex items-center">
<div class="h-9 shrink-0 border-b bg-background px-3 flex items-center justify-between">
<TabsList class="h-7 gap-1 p-0.5">
<TabsTrigger value="detail" class="h-6 flex-none gap-1.5 rounded-md px-2 text-xs">
<KeyRound class="size-3.5" />
@ -1034,6 +1092,16 @@ defineExpose({ focusSearch });
{{ t("redis.commandLine") }}
</TabsTrigger>
</TabsList>
<Button
v-if="activeSidePanel === 'command'"
variant="ghost"
size="icon"
class="h-6 w-6"
:title="t('redis.clearHistory')"
@click="clearPersistedRedisHistory"
>
<History class="size-3.5" />
</Button>
</div>
<TabsContent value="detail" class="m-0 min-h-0 flex-1 flex flex-col">

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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: "类型",

View File

@ -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: "類型",

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -1602,14 +1602,23 @@ export async function saveHistory(entry: HistoryEntry): Promise<void> {
return post("/api/history/save", { entry });
}
export async function loadHistory(limit: number, offset: number): Promise<HistoryEntry[]> {
return get(`/api/history?${qs({ limit, offset })}`);
export async function loadHistory(limit: number, offset: number, activityKind?: string): Promise<HistoryEntry[]> {
return get(`/api/history?${qs({ limit, offset, activity_kind: activityKind })}`);
}
export async function loadRedisHistory(limit = 100, offset = 0): Promise<HistoryEntry[]> {
return loadHistory(limit, offset, "redis_command");
}
export async function clearHistory(): Promise<void> {
return del("/api/history");
}
export async function clearRedisHistory(): Promise<void> {
const entries = await loadRedisHistory(1000, 0);
await Promise.all(entries.map((e) => deleteHistoryEntry(e.id)));
}
export async function deleteHistoryEntry(id: string): Promise<void> {
return del(`/api/history/${id}`);
}

View File

@ -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<void> {
return invoke("save_history", { entry });
}
export async function loadHistory(limit: number, offset: number): Promise<HistoryEntry[]> {
return invoke("load_history", { limit, offset });
export async function loadHistory(limit: number, offset: number, activityKind?: string): Promise<HistoryEntry[]> {
return invoke("load_history", { limit, offset, activityKind: activityKind ?? null });
}
export async function loadRedisHistory(limit = 100, offset = 0): Promise<HistoryEntry[]> {
return loadHistory(limit, offset, "redis_command");
}
export async function clearHistory(): Promise<void> {
return invoke("clear_history");
}
export async function clearRedisHistory(): Promise<void> {
const entries = await loadRedisHistory(1000, 0);
await Promise.all(entries.map((e) => deleteHistoryEntry(e.id)));
}
export async function deleteHistoryEntry(id: string): Promise<void> {
return invoke("delete_history_entry", { id });
}

View File

@ -338,44 +338,61 @@ impl Storage {
.await
}
pub async fn load_history_entries(&self, limit: usize, offset: usize) -> Result<Vec<HistoryEntry>, String> {
pub async fn load_history_entries(
&self,
limit: usize,
offset: usize,
activity_kind: Option<String>,
) -> Result<Vec<HistoryEntry>, 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<HistoryEntry> {
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::<Result<Vec<_>, _>>().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::<Result<Vec<_>, _>>().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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
})
.await
}

View File

@ -12,6 +12,7 @@ use crate::state::WebState;
pub struct HistoryQuery {
pub limit: Option<usize>,
pub offset: Option<usize>,
pub activity_kind: Option<String>,
}
#[derive(Deserialize)]
@ -34,7 +35,7 @@ pub async fn load_history(
) -> Result<Json<Vec<HistoryEntry>>, 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))
}

View File

@ -14,8 +14,9 @@ pub async fn load_history(
state: State<'_, Arc<AppState>>,
limit: usize,
offset: usize,
activity_kind: Option<String>,
) -> Result<Vec<HistoryEntry>, String> {
state.storage.load_history_entries(limit, offset).await
state.storage.load_history_entries(limit, offset, activity_kind).await
}
#[tauri::command]