mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "クリックしてパスをコピー",
|
||||
|
||||
@@ -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": "点击复制路径",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user