feat: add session deletion with per-provider cleanup and path safety

Add delete_session Tauri command dispatching to provider-specific deletion
logic for all 5 providers (Claude, Codex, Gemini, OpenCode, OpenClaw).
Includes path traversal protection via canonicalize + starts_with validation,
session ID verification against file contents, frontend confirmation dialog
with optimistic cache updates, i18n keys (zh/en/ja), and component tests.
This commit is contained in:
Jason
2026-03-06 23:09:38 +08:00
parent e18db31752
commit 8c3f18a9bd
17 changed files with 1043 additions and 15 deletions
+80 -1
View File
@@ -7,13 +7,19 @@ import {
RefreshCw,
Search,
Play,
Trash2,
MessageSquare,
Clock,
FolderOpen,
X,
} from "lucide-react";
import { useSessionMessagesQuery, useSessionsQuery } from "@/lib/query";
import {
useDeleteSessionMutation,
useSessionMessagesQuery,
useSessionsQuery,
} from "@/lib/query";
import { sessionsApi } from "@/lib/api";
import type { SessionMeta } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -25,6 +31,7 @@ import {
} from "@/components/ui/select";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import {
Tooltip,
TooltipContent,
@@ -66,6 +73,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
);
const [tocDialogOpen, setTocDialogOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [search, setSearch] = useState("");
@@ -113,6 +121,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
selectedSession?.providerId,
selectedSession?.sourcePath,
);
const deleteSessionMutation = useDeleteSessionMutation();
// 提取用户消息用于目录
const userMessagesToc = useMemo(() => {
@@ -184,6 +193,19 @@ export function SessionManagerPage({ appId }: { appId: string }) {
}
};
const handleDeleteConfirm = async () => {
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
return;
}
setDeleteTarget(null);
await deleteSessionMutation.mutateAsync({
providerId: deleteTarget.providerId,
sessionId: deleteTarget.sessionId,
sourcePath: deleteTarget.sourcePath,
});
};
return (
<TooltipProvider>
<div className="mx-auto px-4 sm:px-6 flex flex-col h-[calc(100vh-8rem)]">
@@ -517,6 +539,36 @@ export function SessionManagerPage({ appId }: { appId: string }) {
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="gap-1.5"
onClick={() => setDeleteTarget(selectedSession)}
disabled={
!selectedSession.sourcePath ||
deleteSessionMutation.isPending
}
>
<Trash2 className="size-3.5" />
<span className="hidden sm:inline">
{deleteSessionMutation.isPending
? t("sessionManager.deleting", {
defaultValue: "删除中...",
})
: t("sessionManager.delete", {
defaultValue: "删除会话",
})}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{t("sessionManager.deleteTooltip", {
defaultValue: "永久删除此本地会话记录",
})}
</TooltipContent>
</Tooltip>
</div>
</div>
@@ -629,6 +681,33 @@ export function SessionManagerPage({ appId }: { appId: string }) {
</div>
</div>
</div>
<ConfirmDialog
isOpen={Boolean(deleteTarget)}
title={t("sessionManager.deleteConfirmTitle", {
defaultValue: "删除会话",
})}
message={
deleteTarget
? t("sessionManager.deleteConfirmMessage", {
defaultValue:
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
title: formatSessionTitle(deleteTarget),
sessionId: deleteTarget.sessionId,
})
: ""
}
confirmText={t("sessionManager.deleteConfirmAction", {
defaultValue: "删除会话",
})}
cancelText={t("common.cancel", { defaultValue: "取消" })}
variant="destructive"
onConfirm={() => void handleDeleteConfirm()}
onCancel={() => {
if (!deleteSessionMutation.isPending) {
setDeleteTarget(null);
}
}}
/>
</TooltipProvider>
);
}
+8
View File
@@ -604,6 +604,14 @@
"projectDirCopied": "Directory copied",
"copySourcePath": "Copy source file",
"sourcePathCopied": "Source file copied",
"delete": "Delete session",
"deleting": "Deleting...",
"deleteTooltip": "Permanently delete this local session record",
"deleteConfirmTitle": "Delete session",
"deleteConfirmMessage": "This will permanently delete the local session \"{{title}}\"\nSession ID: {{sessionId}}\n\nThis action cannot be undone.",
"deleteConfirmAction": "Delete session",
"sessionDeleted": "Session deleted",
"deleteFailed": "Failed to delete session: {{error}}",
"loadingMessages": "Loading transcript...",
"emptySession": "No messages available",
"clickToCopyPath": "Click to copy path",
+8
View File
@@ -604,6 +604,14 @@
"projectDirCopied": "ディレクトリをコピーしました",
"copySourcePath": "元ファイルをコピー",
"sourcePathCopied": "元ファイルをコピーしました",
"delete": "セッションを削除",
"deleting": "削除中...",
"deleteTooltip": "このローカルセッション記録を完全に削除します",
"deleteConfirmTitle": "セッションを削除",
"deleteConfirmMessage": "ローカルセッション「{{title}}」を完全に削除します\nSession ID: {{sessionId}}\n\nこの操作は元に戻せません。",
"deleteConfirmAction": "セッションを削除",
"sessionDeleted": "セッションを削除しました",
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
"loadingMessages": "内容を読み込み中...",
"emptySession": "表示できる内容がありません",
"clickToCopyPath": "クリックしてパスをコピー",
+8
View File
@@ -604,6 +604,14 @@
"projectDirCopied": "目录已复制",
"copySourcePath": "复制原始文件",
"sourcePathCopied": "原始文件已复制",
"delete": "删除会话",
"deleting": "删除中...",
"deleteTooltip": "永久删除此本地会话记录",
"deleteConfirmTitle": "删除会话",
"deleteConfirmMessage": "将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
"deleteConfirmAction": "删除会话",
"sessionDeleted": "会话已删除",
"deleteFailed": "删除会话失败: {{error}}",
"loadingMessages": "加载会话内容中...",
"emptySession": "该会话暂无可展示内容",
"clickToCopyPath": "点击复制路径",
+15
View File
@@ -1,6 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import type { SessionMessage, SessionMeta } from "@/types";
export interface DeleteSessionOptions {
providerId: string;
sessionId: string;
sourcePath: string;
}
export const sessionsApi = {
async list(): Promise<SessionMeta[]> {
return await invoke("list_sessions");
@@ -13,6 +19,15 @@ export const sessionsApi = {
return await invoke("get_session_messages", { providerId, sourcePath });
},
async delete(options: DeleteSessionOptions): Promise<boolean> {
const { providerId, sessionId, sourcePath } = options;
return await invoke("delete_session", {
providerId,
sessionId,
sourcePath,
});
},
async launchTerminal(options: {
command: string;
cwd?: string | null;
+47 -2
View File
@@ -1,9 +1,10 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { providersApi, settingsApi, type AppId } from "@/lib/api";
import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api";
import type { DeleteSessionOptions } from "@/lib/api/sessions";
import type { SwitchResult } from "@/lib/api/providers";
import type { Provider, Settings } from "@/types";
import type { Provider, SessionMeta, Settings } from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils";
import { generateUUID } from "@/utils/uuid";
import { openclawKeys } from "@/hooks/useOpenClaw";
@@ -267,6 +268,50 @@ export const useSwitchProviderMutation = (appId: AppId) => {
});
};
export const useDeleteSessionMutation = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (input: DeleteSessionOptions) => {
await sessionsApi.delete(input);
return input;
},
onSuccess: async (input) => {
queryClient.setQueryData<SessionMeta[]>(["sessions"], (current) =>
(current ?? []).filter(
(session) =>
!(
session.providerId === input.providerId &&
session.sessionId === input.sessionId &&
session.sourcePath === input.sourcePath
),
),
);
queryClient.removeQueries({
queryKey: ["sessionMessages", input.providerId, input.sourcePath],
});
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
toast.success(
t("sessionManager.sessionDeleted", {
defaultValue: "会话已删除",
}),
);
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
toast.error(
t("sessionManager.deleteFailed", {
defaultValue: "删除会话失败: {{error}}",
error: detail,
}),
);
},
});
};
export const useSaveSettingsMutation = () => {
const queryClient = useQueryClient();