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>
);
}