diff --git a/src-tauri/src/commands/session_manager.rs b/src-tauri/src/commands/session_manager.rs index 8f3caee1..434cd426 100644 --- a/src-tauri/src/commands/session_manager.rs +++ b/src-tauri/src/commands/session_manager.rs @@ -74,3 +74,12 @@ pub async fn delete_session( .await .map_err(|e| format!("Failed to delete session: {e}"))? } + +#[tauri::command] +pub async fn delete_sessions( + items: Vec, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items)) + .await + .map_err(|e| format!("Failed to delete sessions: {e}")) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 05463cee..b9011834 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1021,6 +1021,7 @@ pub fn run() { commands::list_sessions, commands::get_session_messages, commands::delete_session, + commands::delete_sessions, commands::launch_session_terminal, commands::get_tool_versions, // Provider terminal diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index 498dc469..747f169e 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -1,7 +1,7 @@ pub mod providers; pub mod terminal; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use providers::{claude, codex, gemini, openclaw, opencode}; @@ -36,6 +36,25 @@ pub struct SessionMessage { pub ts: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSessionRequest { + pub provider_id: String, + pub session_id: String, + pub source_path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSessionOutcome { + pub provider_id: String, + pub session_id: String, + pub source_path: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + pub fn scan_sessions() -> Vec { let (r1, r2, r3, r4, r5) = std::thread::scope(|s| { let h1 = s.spawn(codex::scan_sessions); @@ -99,6 +118,16 @@ pub fn delete_session( delete_session_with_root(provider_id, session_id, Path::new(source_path), &root) } +pub fn delete_sessions(requests: &[DeleteSessionRequest]) -> Vec { + collect_delete_session_outcomes(requests, |request| { + delete_session( + &request.provider_id, + &request.session_id, + &request.source_path, + ) + }) +} + fn delete_session_with_root( provider_id: &str, session_id: &str, @@ -147,6 +176,41 @@ fn canonicalize_existing_path(path: &Path, label: &str) -> Result( + requests: &[DeleteSessionRequest], + mut deleter: F, +) -> Vec +where + F: FnMut(&DeleteSessionRequest) -> Result, +{ + requests + .iter() + .map(|request| match deleter(request) { + Ok(true) => DeleteSessionOutcome { + provider_id: request.provider_id.clone(), + session_id: request.session_id.clone(), + source_path: request.source_path.clone(), + success: true, + error: None, + }, + Ok(false) => DeleteSessionOutcome { + provider_id: request.provider_id.clone(), + session_id: request.session_id.clone(), + source_path: request.source_path.clone(), + success: false, + error: Some("Session was not deleted".to_string()), + }, + Err(error) => DeleteSessionOutcome { + provider_id: request.provider_id.clone(), + session_id: request.session_id.clone(), + source_path: request.source_path.clone(), + success: false, + error: Some(error), + }, + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -175,4 +239,44 @@ mod tests { assert!(err.contains("session source not found")); } + + #[test] + fn batch_delete_collects_successes_and_failures_in_order() { + let requests = vec![ + DeleteSessionRequest { + provider_id: "codex".to_string(), + session_id: "s1".to_string(), + source_path: "/tmp/s1".to_string(), + }, + DeleteSessionRequest { + provider_id: "claude".to_string(), + session_id: "s2".to_string(), + source_path: "/tmp/s2".to_string(), + }, + DeleteSessionRequest { + provider_id: "gemini".to_string(), + session_id: "s3".to_string(), + source_path: "/tmp/s3".to_string(), + }, + ]; + + let outcomes = collect_delete_session_outcomes(&requests, |request| { + match request.session_id.as_str() { + "s1" => Ok(true), + "s2" => Err("boom".to_string()), + _ => Ok(false), + } + }); + + assert_eq!(outcomes.len(), 3); + assert!(outcomes[0].success); + assert_eq!(outcomes[0].error, None); + assert!(!outcomes[1].success); + assert_eq!(outcomes[1].error.as_deref(), Some("boom")); + assert!(!outcomes[2].success); + assert_eq!( + outcomes[2].error.as_deref(), + Some("Session was not deleted") + ); + } } diff --git a/src/components/sessions/SessionItem.tsx b/src/components/sessions/SessionItem.tsx index 15de67e6..dc2841c3 100644 --- a/src/components/sessions/SessionItem.tsx +++ b/src/components/sessions/SessionItem.tsx @@ -1,5 +1,6 @@ import { ChevronRight, Clock } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { Checkbox } from "@/components/ui/checkbox"; import { Tooltip, TooltipContent, @@ -19,13 +20,21 @@ import { interface SessionItemProps { session: SessionMeta; isSelected: boolean; + selectionMode: boolean; + isChecked: boolean; + isCheckDisabled?: boolean; onSelect: (key: string) => void; + onToggleChecked: (checked: boolean) => void; } export function SessionItem({ session, isSelected, + selectionMode, + isChecked, + isCheckDisabled = false, onSelect, + onToggleChecked, }: SessionItemProps) { const { t } = useTranslation(); const title = formatSessionTitle(session); @@ -33,46 +42,64 @@ export function SessionItem({ const sessionKey = getSessionKey(session); return ( - +
+ + + {lastActive + ? formatRelativeTime(lastActive, t) + : t("common.unknown")} + +
+ + ); } diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index ce925f57..c73f09ab 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useSessionSearch } from "@/hooks/useSessionSearch"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; import { Copy, RefreshCw, @@ -12,6 +13,7 @@ import { Clock, FolderOpen, X, + CheckSquare, } from "lucide-react"; import { useDeleteSessionMutation, @@ -63,6 +65,7 @@ type ProviderFilter = export function SessionManagerPage({ appId }: { appId: string }) { const { t } = useTranslation(); + const queryClient = useQueryClient(); const { data, isLoading, refetch } = useSessionsQuery(); const sessions = data ?? []; const detailRef = useRef(null); @@ -73,7 +76,14 @@ export function SessionManagerPage({ appId }: { appId: string }) { ); const [tocDialogOpen, setTocDialogOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteTargets, setDeleteTargets] = useState( + null, + ); + const [selectedSessionKeys, setSelectedSessionKeys] = useState>( + () => new Set(), + ); + const [isBatchDeleting, setIsBatchDeleting] = useState(false); + const [selectionMode, setSelectionMode] = useState(false); const searchInputRef = useRef(null); const [search, setSearch] = useState(""); @@ -122,6 +132,25 @@ export function SessionManagerPage({ appId }: { appId: string }) { selectedSession?.sourcePath, ); const deleteSessionMutation = useDeleteSessionMutation(); + const isDeleting = deleteSessionMutation.isPending || isBatchDeleting; + + useEffect(() => { + const validKeys = new Set( + sessions.map((session) => getSessionKey(session)), + ); + setSelectedSessionKeys((current) => { + let changed = false; + const next = new Set(); + current.forEach((key) => { + if (validKeys.has(key)) { + next.add(key); + } else { + changed = true; + } + }); + return changed ? next : current; + }); + }, [sessions]); // 提取用户消息用于目录 const userMessagesToc = useMemo(() => { @@ -194,16 +223,195 @@ export function SessionManagerPage({ appId }: { appId: string }) { }; const handleDeleteConfirm = async () => { - if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) { + if (!deleteTargets || deleteTargets.length === 0 || isDeleting) { return; } - setDeleteTarget(null); - await deleteSessionMutation.mutateAsync({ - providerId: deleteTarget.providerId, - sessionId: deleteTarget.sessionId, - sourcePath: deleteTarget.sourcePath, + const targets = deleteTargets.filter((session) => session.sourcePath); + setDeleteTargets(null); + + if (targets.length === 0) { + return; + } + + if (targets.length === 1) { + const [target] = targets; + await deleteSessionMutation.mutateAsync({ + providerId: target.providerId, + sessionId: target.sessionId, + sourcePath: target.sourcePath!, + }); + setSelectedSessionKeys((current) => { + const next = new Set(current); + next.delete(getSessionKey(target)); + return next; + }); + return; + } + + setIsBatchDeleting(true); + try { + const results = await sessionsApi.deleteMany( + targets.map((session) => ({ + providerId: session.providerId, + sessionId: session.sessionId, + sourcePath: session.sourcePath!, + })), + ); + + const deletedKeys = results + .filter((result) => result.success) + .map( + (result) => + `${result.providerId}:${result.sessionId}:${result.sourcePath ?? ""}`, + ); + + const failedErrors = results + .filter((result) => !result.success) + .map((result) => result.error || t("common.unknown")); + + if (deletedKeys.length > 0) { + const deletedKeySet = new Set(deletedKeys); + queryClient.setQueryData(["sessions"], (current) => + (current ?? []).filter( + (session) => !deletedKeySet.has(getSessionKey(session)), + ), + ); + } + + results + .filter((result) => result.success) + .forEach((result) => { + queryClient.removeQueries({ + queryKey: ["sessionMessages", result.providerId, result.sourcePath], + }); + }); + + setSelectedSessionKeys((current) => { + const next = new Set(current); + deletedKeys.forEach((key) => next.delete(key)); + return next; + }); + + await queryClient.invalidateQueries({ queryKey: ["sessions"] }); + + if (deletedKeys.length > 0) { + toast.success( + t("sessionManager.batchDeleteSuccess", { + defaultValue: "已删除 {{count}} 个会话", + count: deletedKeys.length, + }), + ); + } + + if (failedErrors.length > 0) { + toast.error( + t("sessionManager.batchDeleteFailed", { + defaultValue: "{{failed}} 个会话删除失败", + failed: failedErrors.length, + }), + { + description: failedErrors[0], + }, + ); + } + } catch (error) { + toast.error( + extractErrorMessage(error) || + t("sessionManager.batchDeleteRequestFailed", { + defaultValue: "批量删除失败,请稍后重试", + }), + ); + } finally { + setIsBatchDeleting(false); + } + }; + + const deletableFilteredSessions = useMemo( + () => filteredSessions.filter((session) => Boolean(session.sourcePath)), + [filteredSessions], + ); + + const selectedSessions = useMemo( + () => + sessions.filter((session) => + selectedSessionKeys.has(getSessionKey(session)), + ), + [sessions, selectedSessionKeys], + ); + + const selectedDeletableSessions = useMemo( + () => selectedSessions.filter((session) => Boolean(session.sourcePath)), + [selectedSessions], + ); + + useEffect(() => { + if (!selectionMode) return; + + const visibleKeys = new Set( + deletableFilteredSessions.map((session) => getSessionKey(session)), + ); + + setSelectedSessionKeys((current) => { + let changed = false; + const next = new Set(); + + current.forEach((key) => { + if (visibleKeys.has(key)) { + next.add(key); + } else { + changed = true; + } + }); + + return changed ? next : current; }); + }, [deletableFilteredSessions, selectionMode]); + + const allFilteredSelected = + deletableFilteredSessions.length > 0 && + deletableFilteredSessions.every((session) => + selectedSessionKeys.has(getSessionKey(session)), + ); + + const toggleSessionChecked = (session: SessionMeta, checked: boolean) => { + if (!session.sourcePath) return; + const key = getSessionKey(session); + setSelectedSessionKeys((current) => { + const next = new Set(current); + if (checked) { + next.add(key); + } else { + next.delete(key); + } + return next; + }); + }; + + const handleToggleSelectAll = () => { + setSelectedSessionKeys((current) => { + const next = new Set(current); + if (allFilteredSelected) { + deletableFilteredSessions.forEach((session) => + next.delete(getSessionKey(session)), + ); + } else { + deletableFilteredSessions.forEach((session) => + next.add(getSessionKey(session)), + ); + } + return next; + }); + }; + + const openBatchDeleteDialog = () => { + if (selectedDeletableSessions.length === 0) return; + setDeleteTargets(selectedDeletableSessions); + }; + + const exitSelectionMode = () => { + setSelectionMode(false); + setSelectedSessionKeys(new Set()); }; return ( @@ -219,174 +427,315 @@ export function SessionManagerPage({ appId }: { appId: string }) { {isSearchOpen ? ( -
- - setSearch(event.target.value)} - placeholder={t("sessionManager.searchPlaceholder")} - className="h-8 pl-8 pr-8 text-sm" - autoFocus - onKeyDown={(e) => { - if (e.key === "Escape") { +
+
+ + setSearch(event.target.value)} + placeholder={t("sessionManager.searchPlaceholder")} + className="h-8 pl-8 pr-8 text-sm" + autoFocus + onKeyDown={(e) => { + if (e.key === "Escape") { + setIsSearchOpen(false); + setSearch(""); + } + }} + onBlur={() => { + if (search.trim() === "") { + setIsSearchOpen(false); + } + }} + /> + -
- ) : ( -
-
- - {t("sessionManager.sessionList")} - - - {filteredSessions.length} - + }} + > + +
-
+ {selectionMode && ( - {t("sessionManager.searchSessions")} + {t("sessionManager.exitBatchModeTooltip", { + defaultValue: "退出批量管理", + })} - - - - - - - {t("common.refresh")} - + + + + + + + {t("common.refresh")} + +
+ {selectionMode && ( +
+
+ + {t("sessionManager.selectedCount", { + defaultValue: "已选 {{count}} 项", + count: selectedDeletableSessions.length, + })} + + + {t("sessionManager.batchModeHint", { + defaultValue: "勾选要删除的会话", + })} + +
+
+
+ {deletableFilteredSessions.length > 0 && ( + + )} + +
+ +
+
+ )}
)} @@ -416,7 +765,15 @@ export function SessionManagerPage({ appId }: { appId: string }) { key={getSessionKey(session)} session={session} isSelected={isSelected} + selectionMode={selectionMode} + isChecked={selectedSessionKeys.has( + getSessionKey(session), + )} + isCheckDisabled={!session.sourcePath} onSelect={setSelectedKey} + onToggleChecked={(checked) => + toggleSessionChecked(session, checked) + } /> ); })} @@ -548,15 +905,16 @@ export function SessionManagerPage({ appId }: { appId: string }) { size="sm" variant="destructive" className="gap-1.5" - onClick={() => setDeleteTarget(selectedSession)} + onClick={() => + setDeleteTargets([selectedSession]) + } disabled={ - !selectedSession.sourcePath || - deleteSessionMutation.isPending + !selectedSession.sourcePath || isDeleting } > - {deleteSessionMutation.isPending + {isDeleting ? t("sessionManager.deleting", { defaultValue: "删除中...", }) @@ -685,29 +1043,47 @@ export function SessionManagerPage({ appId }: { appId: string }) {
1 + ? t("sessionManager.batchDeleteConfirmTitle", { + defaultValue: "批量删除会话", + }) + : t("sessionManager.deleteConfirmTitle", { + defaultValue: "删除会话", + }) + } + message={ + deleteTargets && deleteTargets.length > 1 + ? t("sessionManager.batchDeleteConfirmMessage", { + defaultValue: + "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。", + count: deleteTargets.length, + }) + : deleteTargets?.[0] + ? t("sessionManager.deleteConfirmMessage", { + defaultValue: + "将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。", + title: formatSessionTitle(deleteTargets[0]), + sessionId: deleteTargets[0].sessionId, + }) + : "" + } + confirmText={ + deleteTargets && deleteTargets.length > 1 + ? t("sessionManager.batchDeleteConfirmAction", { + defaultValue: "删除所选会话", + }) + : t("sessionManager.deleteConfirmAction", { + defaultValue: "删除会话", }) - : "" } - confirmText={t("sessionManager.deleteConfirmAction", { - defaultValue: "删除会话", - })} cancelText={t("common.cancel", { defaultValue: "取消" })} variant="destructive" onConfirm={() => void handleDeleteConfirm()} onCancel={() => { - if (!deleteSessionMutation.isPending) { - setDeleteTarget(null); + if (!isDeleting) { + setDeleteTargets(null); } }} /> diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ca639040..df104df1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -613,6 +613,16 @@ "searchSessions": "Search sessions", "providerFilterAll": "All", "sessionList": "Sessions", + "manageBatchTooltip": "Enter batch management", + "exitBatchModeTooltip": "Exit batch management", + "batchModeHint": "Select sessions to delete", + "selectForBatch": "Select session", + "selectedCount": "{{count}} selected", + "selectAllFiltered": "Select all", + "clearFilteredSelection": "Clear selection", + "clearSelection": "Clear", + "deleteSelected": "Delete", + "batchDeleting": "Deleting...", "loadingSessions": "Loading sessions...", "noSessions": "No sessions found", "selectSession": "Select a session to view details", @@ -641,6 +651,12 @@ "deleteConfirmAction": "Delete session", "sessionDeleted": "Session deleted", "deleteFailed": "Failed to delete session: {{error}}", + "batchDeleteConfirmTitle": "Delete selected sessions", + "batchDeleteConfirmMessage": "This will permanently delete {{count}} selected local sessions.\n\nThis action cannot be undone.", + "batchDeleteConfirmAction": "Delete selected", + "batchDeleteSuccess": "Deleted {{count}} sessions", + "batchDeleteFailed": "{{failed}} sessions could not be deleted", + "batchDeleteRequestFailed": "Batch delete failed. Please try again later.", "loadingMessages": "Loading transcript...", "emptySession": "No messages available", "clickToCopyPath": "Click to copy path", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 76f4808a..e663263b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -613,6 +613,16 @@ "searchSessions": "セッションを検索", "providerFilterAll": "すべて", "sessionList": "セッション一覧", + "manageBatchTooltip": "一括管理に入る", + "exitBatchModeTooltip": "一括管理を終了", + "batchModeHint": "削除するセッションを選択", + "selectForBatch": "セッションを選択", + "selectedCount": "{{count}} 件を選択中", + "selectAllFiltered": "一覧を全選択", + "clearFilteredSelection": "全選択を解除", + "clearSelection": "クリア", + "deleteSelected": "削除", + "batchDeleting": "削除中...", "loadingSessions": "セッションを読み込み中...", "noSessions": "セッションが見つかりません", "selectSession": "セッションを選択してください", @@ -641,6 +651,12 @@ "deleteConfirmAction": "セッションを削除", "sessionDeleted": "セッションを削除しました", "deleteFailed": "セッションの削除に失敗しました: {{error}}", + "batchDeleteConfirmTitle": "選択したセッションを削除", + "batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。", + "batchDeleteConfirmAction": "選択した項目を削除", + "batchDeleteSuccess": "{{count}} 件のセッションを削除しました", + "batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした", + "batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。", "loadingMessages": "内容を読み込み中...", "emptySession": "表示できる内容がありません", "clickToCopyPath": "クリックしてパスをコピー", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1b80ae69..76b94e8c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -613,6 +613,16 @@ "searchSessions": "搜索会话", "providerFilterAll": "全部", "sessionList": "会话列表", + "manageBatchTooltip": "进入批量管理", + "exitBatchModeTooltip": "退出批量管理", + "batchModeHint": "勾选要删除的会话", + "selectForBatch": "选择会话", + "selectedCount": "已选 {{count}} 项", + "selectAllFiltered": "全选当前", + "clearFilteredSelection": "取消全选", + "clearSelection": "清空已选", + "deleteSelected": "批量删除", + "batchDeleting": "删除中...", "loadingSessions": "加载会话中...", "noSessions": "未发现会话", "selectSession": "请选择会话查看详情", @@ -641,6 +651,12 @@ "deleteConfirmAction": "删除会话", "sessionDeleted": "会话已删除", "deleteFailed": "删除会话失败: {{error}}", + "batchDeleteConfirmTitle": "批量删除会话", + "batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。", + "batchDeleteConfirmAction": "删除所选会话", + "batchDeleteSuccess": "已删除 {{count}} 个会话", + "batchDeleteFailed": "{{failed}} 个会话删除失败", + "batchDeleteRequestFailed": "批量删除失败,请稍后重试", "loadingMessages": "加载会话内容中...", "emptySession": "该会话暂无可展示内容", "clickToCopyPath": "点击复制路径", diff --git a/src/lib/api/sessions.ts b/src/lib/api/sessions.ts index d9a4f61d..0fbb1052 100644 --- a/src/lib/api/sessions.ts +++ b/src/lib/api/sessions.ts @@ -7,6 +7,11 @@ export interface DeleteSessionOptions { sourcePath: string; } +export interface DeleteSessionResult extends DeleteSessionOptions { + success: boolean; + error?: string; +} + export const sessionsApi = { async list(): Promise { return await invoke("list_sessions"); @@ -28,6 +33,12 @@ export const sessionsApi = { }); }, + async deleteMany( + items: DeleteSessionOptions[], + ): Promise { + return await invoke("delete_sessions", { items }); + }, + async launchTerminal(options: { command: string; cwd?: string | null; diff --git a/tests/components/SessionManagerPage.test.tsx b/tests/components/SessionManagerPage.test.tsx index b1301eee..a6e99115 100644 --- a/tests/components/SessionManagerPage.test.tsx +++ b/tests/components/SessionManagerPage.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { + act, fireEvent, render, screen, @@ -8,6 +9,7 @@ import { } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SessionManagerPage } from "@/components/sessions/SessionManagerPage"; +import { sessionsApi } from "@/lib/api/sessions"; import type { SessionMessage, SessionMeta } from "@/types"; import { setSessionFixtures } from "../msw/state"; @@ -62,16 +64,19 @@ const renderPage = () => { }, }); - return render( - - - , - ); + return { + client, + ...render( + + + , + ), + }; }; const openSearch = () => { - const searchButton = Array.from(screen.getAllByRole("button")).find((button) => - button.querySelector(".lucide-search"), + const searchButton = Array.from(screen.getAllByRole("button")).find( + (button) => button.querySelector(".lucide-search"), ); if (!searchButton) { @@ -81,10 +86,23 @@ const openSearch = () => { fireEvent.click(searchButton); }; +const closeSearch = () => { + const closeButton = Array.from(screen.getAllByRole("button")).find( + (button) => button.querySelector(".lucide-x"), + ); + + if (!closeButton) { + throw new Error("Search close button not found"); + } + + fireEvent.click(closeButton); +}; + describe("SessionManagerPage", () => { beforeEach(() => { toastSuccessMock.mockReset(); toastErrorMock.mockReset(); + Element.prototype.scrollIntoView = vi.fn(); const sessions: SessionMeta[] = [ { @@ -178,11 +196,136 @@ describe("SessionManagerPage", () => { expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(), ); - expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument(); + expect( + screen.getByText("sessionManager.selectSession"), + ).toBeInTheDocument(); expect( screen.queryByText("sessionManager.emptySession"), ).not.toBeInTheDocument(); expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastSuccessMock).toHaveBeenCalled(); }); + + it("restores batch delete controls when deleteMany rejects", async () => { + const deleteManySpy = vi + .spyOn(sessionsApi, "deleteMany") + .mockRejectedValueOnce(new Error("network error")); + + renderPage(); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "Alpha Session" }), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByRole("button", { name: /批量管理/i })); + fireEvent.click(screen.getByRole("button", { name: /全选当前/i })); + fireEvent.click(screen.getByRole("button", { name: /批量删除/i })); + + const dialog = screen.getByTestId("confirm-dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /删除所选会话/i }), + ); + + await waitFor(() => + expect(toastErrorMock).toHaveBeenCalledWith("network error"), + ); + + await waitFor(() => + expect( + screen.getByRole("button", { name: /批量删除/i }), + ).not.toBeDisabled(), + ); + + deleteManySpy.mockRestore(); + }); + + it("keeps the exit batch mode button visible when search hides all sessions", async () => { + renderPage(); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "Alpha Session" }), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByRole("button", { name: /批量管理/i })); + openSearch(); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "NoSuchSession" }, + }); + + await waitFor(() => expect(screen.queryByText("Alpha Session")).toBeNull()); + + expect(screen.getByRole("button", { name: /退出批量管理/i })).toBeVisible(); + }); + + it("drops hidden selections when search narrows the result set", async () => { + renderPage(); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "Alpha Session" }), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByRole("button", { name: /批量管理/i })); + fireEvent.click(screen.getByRole("button", { name: /全选当前/i })); + + expect(screen.getByText("已选 2 项")).toBeInTheDocument(); + + openSearch(); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Alpha" }, + }); + + await waitFor(() => + expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(), + ); + + closeSearch(); + + await waitFor(() => + expect(screen.getByText("已选 1 项")).toBeInTheDocument(), + ); + }); + + it("removes successfully deleted sessions from the UI before refetch completes", async () => { + const view = renderPage(); + let resolveInvalidate!: () => void; + const invalidateSpy = vi + .spyOn(view.client, "invalidateQueries") + .mockImplementation( + () => + new Promise((resolve) => { + resolveInvalidate = () => resolve(undefined); + }), + ); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "Alpha Session" }), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByRole("button", { name: /批量管理/i })); + fireEvent.click(screen.getByRole("button", { name: /全选当前/i })); + fireEvent.click(screen.getByRole("button", { name: /批量删除/i })); + + const dialog = screen.getByTestId("confirm-dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /删除所选会话/i }), + ); + + await waitFor(() => { + expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(); + expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(); + }); + + await act(async () => { + resolveInvalidate(); + }); + invalidateSpy.mockRestore(); + }); }); diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 5867b1ed..40231b92 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -129,6 +129,29 @@ export const handlers = [ return success(deleteSession(providerId, sessionId, sourcePath)); }), + http.post(`${TAURI_ENDPOINT}/delete_sessions`, async ({ request }) => { + const { items = [] } = await withJson<{ + items?: { + providerId: string; + sessionId: string; + sourcePath: string; + }[]; + }>(request); + + return success( + items.map((item) => ({ + providerId: item.providerId, + sessionId: item.sessionId, + sourcePath: item.sourcePath, + success: deleteSession( + item.providerId, + item.sessionId, + item.sourcePath, + ), + })), + ); + }), + // MCP APIs http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => { const { app } = await withJson<{ app: AppId }>(request);