diff --git a/src/App.tsx b/src/App.tsx index 241c08909..66b5cf9e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,6 +73,7 @@ import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { discardUnsavedChanges, hasUnsavedChanges } from "@/lib/unsavedChanges"; import { SettingsPage } from "@/components/settings/SettingsPage"; import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel"; import { AppsPage } from "@/components/apps/AppsPage"; @@ -619,8 +620,17 @@ function App() { setUsageProvider(null); }; + // 整页编辑器里有未保存的修改:先确认,确认放弃后再执行这次导航 + const [pendingLeave, setPendingLeave] = useState<(() => void) | null>(null); + const confirmLeave = (proceed: () => void) => { + if (!hasUnsavedChanges()) return false; + setPendingLeave(() => proceed); + return true; + }; + const openSettings = (section: SettingsSection = "general") => { if (managementBusyRef.current) return; + if (confirmLeave(() => openSettings(section))) return; closeProviderPanels(); if (currentViewRef.current !== "settings") { settingsReturnViewRef.current = currentViewRef.current; @@ -635,6 +645,7 @@ function App() { const selectApp = (app: AppId) => { if (managementBusyRef.current) return; + if (confirmLeave(() => selectApp(app))) return; closeProviderPanels(); setActiveApp(app); localStorage.setItem(APP_STORAGE_KEY, app); @@ -647,6 +658,7 @@ function App() { return; } if (managementBusyRef.current) return; + if (confirmLeave(() => openPage(page))) return; closeProviderPanels(); if (page === "prompts" && currentViewRef.current !== "prompts") { // 提示词页默认选中侧栏里最后选的那个应用 @@ -659,21 +671,24 @@ function App() { useTrayAppPageSeen(currentView === "providers" ? activeApp : null); useTrayNavigation((navigation) => { - if (navigation.section) { - openSettings(navigation.section); - return; - } - if (!navigation.app) return; - selectApp(navigation.app); - // 托盘先换应用:新应用那格还没报上来,按它实际生效的模式 - if (navigation.intent === "add") openAddProvider(undefined); - if (navigation.intent === "needsRoute" && navigation.providerId) { - setTrayNeedsRoute({ - app: navigation.app, - providerId: navigation.providerId, - nonce: Date.now(), - }); - } + const navigate = () => { + if (navigation.section) { + openSettings(navigation.section); + return; + } + if (!navigation.app) return; + selectApp(navigation.app); + // 托盘先换应用:新应用那格还没报上来,按它实际生效的模式 + if (navigation.intent === "add") openAddProvider(undefined); + if (navigation.intent === "needsRoute" && navigation.providerId) { + setTrayNeedsRoute({ + app: navigation.app, + providerId: navigation.providerId, + nonce: Date.now(), + }); + } + }; + if (managementBusyRef.current || !confirmLeave(navigate)) navigate(); }); // 侧栏、⌘K 进用量统计看全部应用;只有应用页 ⋯ 进来时带应用筛选 @@ -1576,6 +1591,22 @@ function App() { onCancel={() => setConfirmAction(null)} /> + { + const proceed = pendingLeave; + setPendingLeave(null); + discardUnsavedChanges(); + proceed?.(); + }} + onCancel={() => setPendingLeave(null)} + /> + = ({ return ( ):二级页只盖住它,侧栏留着 */ @@ -68,7 +74,12 @@ export const FullScreenPanel: React.FC = ({ footer, contentClassName, motionPreset = "fade", + trackUnsavedChanges = false, }) => { + // 面板关掉后组件可能还挂着:只在打开时登记,关掉即撤销 + const unsavedChangesHandlers = useUnsavedChangesTracker( + trackUnsavedChanges && isOpen, + ); const { t } = useTranslation(); const prefersReducedMotion = useReducedMotion(); const shouldSlideFromRight = @@ -145,6 +156,8 @@ export const FullScreenPanel: React.FC = ({ = ({ />
-
+
{children}
diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index e5d3450f5..0052cf46f 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -8,10 +8,8 @@ import { SegmentedControl } from "@/components/ui/segmented-control"; import { Sheet, SheetBody, - SheetContent, + SheetPageContent, SheetFooter, - SheetHeader, - SheetTitle, } from "@/components/ui/sheet"; import { HoverTip } from "@/components/ui/hover-tip"; import { AppGlyph, APP_DISPLAY_NAME } from "@/components/shell/AppGlyph"; @@ -79,8 +77,6 @@ interface McpFormModalProps { visibleAppIds?: McpAppId[]; onSave: () => void | Promise; onClose: () => void; - /** 编辑时底栏左侧「删除…」 */ - onRequestDelete?: (id: string) => void; } const emptyMeta = (): MetaDraft => ({ @@ -125,7 +121,6 @@ const McpFormModal: React.FC = ({ visibleAppIds = MCP_APP_IDS, onSave, onClose, - onRequestDelete, }) => { const { t } = useTranslation(); const upsertMutation = useUpsertMcpServer(); @@ -467,7 +462,7 @@ const McpFormModal: React.FC = ({ // ─── 渲染 ─────────────────────────────────────────────────────────── const title = isEdit - ? t("mcpPage.drawer.editTitle", { id: editingId }) + ? t("mcpPage.drawer.editTitle") : t("mcpPage.drawer.addTitle"); const batchChosen = batch?.items.filter((item) => item.include) ?? []; @@ -554,6 +549,7 @@ const McpFormModal: React.FC = ({ type="button" variant="quiet" size="icon-compact" + data-unsaved-ignore aria-pressed={shown} aria-label={t("mcpPage.drawer.revealValue", { name: rowName, @@ -610,21 +606,16 @@ const McpFormModal: React.FC = ({ return ( { if (!open && !savingRef.current) onClose(); }} > - - - - {title} - - - {!isEdit && ( <> @@ -864,7 +855,7 @@ const McpFormModal: React.FC = ({ aria-labelledby="mcp-conn-title" className="flex flex-col gap-4" > -
+

= ({ {hasSecrets && ( - )} -
- + ); }; diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index c461442e5..146fb37da 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -17,7 +17,9 @@ import { DialogTitle } from "@/components/ui/dialog"; import { HoverTip } from "@/components/ui/hover-tip"; import { AppPageHeader } from "@/components/shell/AppPageHeader"; import { APP_DISPLAY_NAME } from "@/components/shell/AppGlyph"; +import { useIsMutating } from "@tanstack/react-query"; import { + MCP_UPSERT_MUTATION_KEY, useAllMcpServers, useBulkToggleMcpApp, useDeleteMcpServer, @@ -159,9 +161,20 @@ const UnifiedMcpPanel: React.FC = ({ // 写入本身仍由写锁(writeLockRef / interactionBlocked)拦着。 const controlsDisabled = useDelayedFlag(interactionBlocked); + // 编辑页自己的保存不在 mutationPending 里,按 mutation key 单独看 + const editorSaving = + useIsMutating({ mutationKey: MCP_UPSERT_MUTATION_KEY }) > 0; + // 报给外壳的导航锁不算编辑页:编辑页只盖住内容区,离开页面就关掉它(同供应商编辑页); + // 写入进行中(含编辑页自己的保存)仍锁 + const navigationBlocked = + writePending || + mutationPending || + editorSaving || + deleteId !== null || + importReport !== null; React.useEffect(() => { - onInteractionBlockedChange?.(interactionBlocked); - }, [interactionBlocked, onInteractionBlockedChange]); + onInteractionBlockedChange?.(navigationBlocked); + }, [navigationBlocked, onInteractionBlockedChange]); React.useEffect( () => () => onInteractionBlockedChange?.(false), @@ -1008,7 +1021,6 @@ const UnifiedMcpPanel: React.FC = ({ visibleAppIds={appIds} onSave={() => setDrawer(null)} onClose={() => setDrawer(null)} - onRequestDelete={(id) => setDeleteId(id)} /> )} diff --git a/src/components/prompts/PiNativePromptResources.tsx b/src/components/prompts/PiNativePromptResources.tsx index b043d429b..b315b8d84 100644 --- a/src/components/prompts/PiNativePromptResources.tsx +++ b/src/components/prompts/PiNativePromptResources.tsx @@ -29,9 +29,8 @@ import { } from "@/components/ui/dropdown-menu"; import { Sheet, - SheetContent, SheetDescription, - SheetTitle, + SheetPageContent, } from "@/components/ui/sheet"; import { HoverTip } from "@/components/ui/hover-tip"; import { @@ -88,6 +87,8 @@ const promptFileKey = (kind: EditablePiPromptFileKind) => ["pi", "promptFile", kind] as const; export const promptTemplatesKey = ["pi", "promptTemplates"] as const; +/** 编辑页保存用的 mutation key:页面据此在保存进行中锁住导航 */ +export const PI_PROMPT_SAVE_MUTATION_KEY = ["pi", "promptSave"] as const; export function usePiPromptTemplatesQuery() { return useQuery({ @@ -100,7 +101,7 @@ function showMutationError(error: unknown, fallback: string) { toast.error(extractErrorMessage(error) || fallback); } -/** 抽屉外壳(宽 560):标题栏 52、正文滚动、底栏 56。 */ +/** 编辑页外壳:整页(带返回按钮的页头)、正文滚动、底栏 56。 */ function PromptDrawer({ title, description, @@ -120,27 +121,20 @@ function PromptDrawer({ return ( { if (!open && !busy) onClose(); }} > - -
- - {title} - - {description} -
+ + {description}
{children}
{footer}
-
+
); } @@ -167,6 +161,7 @@ function PiInstructionFileEditor({ const queryKey = promptFileKey(file.kind); const save = useMutation({ + mutationKey: PI_PROMPT_SAVE_MUTATION_KEY, mutationFn: () => promptsApi.replacePiPromptFile(file.kind, baseSnapshot.revision, draft), onSuccess: (nextSnapshot) => { @@ -513,7 +508,6 @@ interface PiPromptTemplateEditorProps { existingSlugs: Set; onClose: () => void; onChanged: () => Promise; - onDelete?: (template: PiPromptTemplate) => void; } function PiPromptTemplateEditor({ @@ -521,7 +515,6 @@ function PiPromptTemplateEditor({ existingSlugs, onClose, onChanged, - onDelete, }: PiPromptTemplateEditorProps) { const { t } = useTranslation(); const baseId = useId(); @@ -573,6 +566,7 @@ function PiPromptTemplateEditor({ }); const save = useMutation({ + mutationKey: PI_PROMPT_SAVE_MUTATION_KEY, mutationFn: () => promptsApi.upsertPiPromptTemplate( normalizedSlug, @@ -623,18 +617,6 @@ function PiPromptTemplateEditor({ busy={busy} footer={ <> - {!isCreate && onDelete ? ( - - ) : null}
- - ) : null}
- + ); }; diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 114f98396..650047145 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -76,7 +76,8 @@ const StandardPromptPanel: React.FC = ({ const dialogOpen = copyingId !== null; const interactionBlocked = loading || reloadPending || writePending || isFormOpen || dialogOpen; - const navigationBlocked = writePending || isFormOpen || dialogOpen; + // 编辑页只盖住内容区,不锁导航:离开页面就关掉它(同供应商编辑页) + const navigationBlocked = writePending || dialogOpen; // 外观上的禁用晚 300ms 才出现:切回窗口时的重读、点一下启用这类很快的操作, // 不让整列按钮闪一下变灰。拦截仍看 interactionBlocked 和写锁;表单、对话框打开时照常立即禁用。 const controlsDisabled = @@ -239,12 +240,6 @@ const StandardPromptPanel: React.FC = ({ void performDelete(id); }; - const handleDeleteFromDrawer = (id: string) => { - if (writeLockRef.current) return; - closeOverlay(); - void performDelete(id); - }; - const handleToggle = async (id: string, enabled: boolean) => { const before = prompts; const target = before[id]; @@ -565,7 +560,6 @@ const StandardPromptPanel: React.FC = ({ initialData={editingId ? prompts[editingId] : undefined} onSave={handleSave} onClose={handleCloseForm} - onDelete={handleDeleteFromDrawer} /> )} diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index e977c6766..d145fafba 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -534,6 +534,7 @@ export function AddProviderDialog({ return ( diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 71c42df22..eea0ac76d 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -358,6 +358,7 @@ export function EditProviderDialog({ return ( + + } + /> + {children} + + +)); +SheetPageContent.displayName = "SheetPageContent"; + const SheetHeader = ({ className, ...props @@ -133,6 +197,7 @@ export { SheetTrigger, SheetClose, SheetContent, + SheetPageContent, SheetHeader, SheetBody, SheetFooter, diff --git a/src/components/universal/UniversalProviderFormModal.tsx b/src/components/universal/UniversalProviderFormModal.tsx index 4feb355c8..1dc71af3c 100644 --- a/src/components/universal/UniversalProviderFormModal.tsx +++ b/src/components/universal/UniversalProviderFormModal.tsx @@ -346,6 +346,7 @@ requires_openai_auth = true`; return ( mcpApi.upsertUnifiedServer(server), // The database is updated before live configs are synchronized, so an // error can still leave a persisted change that the list must reflect. diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6ad4cb0d0..b362d8142 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -4,6 +4,10 @@ "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI" }, "common": { + "unsavedLeaveTitle": "Unsaved changes", + "unsavedLeaveMessage": "If you leave, the changes on this editor page will not be saved.", + "unsavedLeaveConfirm": "Discard and leave", + "unsavedLeaveCancel": "Keep editing", "add": "Add", "edit": "Edit", "delete": "Delete", @@ -2604,7 +2608,7 @@ "prompts": { "add": "Add Prompt", "addTitle": "Add prompt · {{appName}}", - "editTitle": "Edit prompt · {{name}}", + "editTitle": "Edit prompt", "enabled": "Enabled", "enable": "Enable", "loading": "Loading...", @@ -2672,7 +2676,6 @@ "copyContent": "Copy text", "copiedContent": "Copied the text of \"{{name}}\"", "deleteBlocked": "Disable it before deleting", - "deleteBlockedReason": "An enabled prompt can't be deleted. Disable it first.", "emptyTitle": "No {{app}} prompts yet", "emptyTarget": "The enabled prompt is written to {{path}}.", "emptyFile": "{{path}} already has {{size}} of content that isn't in CC Switch yet", @@ -4243,7 +4246,7 @@ }, "drawer": { "addTitle": "Add MCP", - "editTitle": "Edit MCP · {{id}}", + "editTitle": "Edit MCP", "templates": "Start from a template", "paste": "Paste a config", "pastePlaceholder": "Paste JSON or TOML; mcpServers, Codex and OpenCode formats are recognized", @@ -4333,8 +4336,7 @@ "tags": "Tags", "tagsPlaceholder": "Comma-separated, e.g. docs, web", "homepage": "Homepage", - "docs": "Docs", - "delete": "Delete" + "docs": "Docs" }, "resync": "Rewrite every app's config from these switches" }, diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8812d07af..d9710b3ee 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -4,6 +4,10 @@ "description": "Claude Code・Codex・Gemini CLI のためのオールインワンアシスタント" }, "common": { + "unsavedLeaveTitle": "未保存の変更があります", + "unsavedLeaveMessage": "移動すると、この編集ページの変更は保存されません。", + "unsavedLeaveConfirm": "破棄して移動", + "unsavedLeaveCancel": "編集を続ける", "add": "追加", "edit": "編集", "delete": "削除", @@ -2604,7 +2608,7 @@ "prompts": { "add": "プロンプトを追加", "addTitle": "プロンプトを追加 · {{appName}}", - "editTitle": "プロンプトを編集 · {{name}}", + "editTitle": "プロンプトを編集", "enabled": "有効", "enable": "有効にする", "loading": "読み込み中...", @@ -2672,7 +2676,6 @@ "copyContent": "本文をコピー", "copiedContent": "「{{name}}」の本文をコピーしました", "deleteBlocked": "有効なものは削除できません。先に無効にしてください", - "deleteBlockedReason": "有効なプロンプトは削除できません。先に無効にしてください。", "emptyTitle": "{{app}} のプロンプトはまだありません", "emptyTarget": "有効にしたものが {{path}} に書き込まれます。", "emptyFile": "{{path}} には CC Switch にまだない {{size}} の内容があります", @@ -4243,7 +4246,7 @@ }, "drawer": { "addTitle": "MCP を追加", - "editTitle": "MCP を編集 · {{id}}", + "editTitle": "MCP を編集", "templates": "テンプレートから始める", "paste": "設定を貼り付け", "pastePlaceholder": "JSON か TOML を貼り付け。mcpServers・Codex・OpenCode の書式を自動認識します", @@ -4333,8 +4336,7 @@ "tags": "タグ", "tagsPlaceholder": "カンマ区切り(例:docs, web)", "homepage": "ホームページ", - "docs": "ドキュメント", - "delete": "削除" + "docs": "ドキュメント" }, "resync": "ここのスイッチで各アプリの設定を書き直す" }, diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 9af79b0f1..43a2c7d21 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -4,6 +4,10 @@ "description": "Claude Code / Codex / Gemini CLI 全方位輔助工具" }, "common": { + "unsavedLeaveTitle": "有未儲存的修改", + "unsavedLeaveMessage": "離開後,目前編輯頁裡的修改不會儲存。", + "unsavedLeaveConfirm": "放棄修改並離開", + "unsavedLeaveCancel": "繼續編輯", "add": "新增", "edit": "編輯", "delete": "刪除", @@ -2605,7 +2609,7 @@ "prompts": { "add": "新增提示詞", "addTitle": "新增提示詞 · {{appName}}", - "editTitle": "編輯提示詞 · {{name}}", + "editTitle": "編輯提示詞", "enabled": "已啟用", "enable": "啟用", "loading": "載入中...", @@ -2673,7 +2677,6 @@ "copyContent": "複製正文", "copiedContent": "已複製「{{name}}」的正文", "deleteBlocked": "啟用中的不能刪除,先停用", - "deleteBlockedReason": "啟用中的不能刪除,先停用。", "emptyTitle": "{{app}} 還沒有提示詞", "emptyTarget": "啟用的那條會寫進 {{path}}。", "emptyFile": "{{path}} 裡已有 {{size}} 內容,還不在 CC Switch 裡", @@ -4244,7 +4247,7 @@ }, "drawer": { "addTitle": "新增 MCP", - "editTitle": "編輯 MCP · {{id}}", + "editTitle": "編輯 MCP", "templates": "從範本開始", "paste": "貼上設定", "pastePlaceholder": "貼上 JSON 或 TOML,自動辨識 mcpServers、Codex、OpenCode 的寫法", @@ -4334,8 +4337,7 @@ "tags": "標籤", "tagsPlaceholder": "用逗號分隔,例如 docs, web", "homepage": "首頁", - "docs": "文件", - "delete": "刪除" + "docs": "文件" }, "resync": "按這裡的開關重寫各應用設定" }, diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3e240d93d..7f4c049f3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -4,6 +4,10 @@ "description": "Claude Code / Codex / Gemini CLI 全方位辅助工具" }, "common": { + "unsavedLeaveTitle": "有未保存的修改", + "unsavedLeaveMessage": "离开后,当前编辑页里的修改不会保存。", + "unsavedLeaveConfirm": "放弃修改并离开", + "unsavedLeaveCancel": "继续编辑", "add": "添加", "edit": "编辑", "delete": "删除", @@ -2604,7 +2608,7 @@ "prompts": { "add": "添加提示词", "addTitle": "添加提示词 · {{appName}}", - "editTitle": "编辑提示词 · {{name}}", + "editTitle": "编辑提示词", "enabled": "已启用", "enable": "启用", "loading": "加载中...", @@ -2672,7 +2676,6 @@ "copyContent": "复制正文", "copiedContent": "已复制「{{name}}」的正文", "deleteBlocked": "启用中的不能删除,先停用", - "deleteBlockedReason": "启用中的不能删除,先停用。", "emptyTitle": "{{app}} 还没有提示词", "emptyTarget": "启用的那条会写进 {{path}}。", "emptyFile": "{{path}} 里已有 {{size}} 内容,还不在 CC Switch 里", @@ -4243,7 +4246,7 @@ }, "drawer": { "addTitle": "添加 MCP", - "editTitle": "编辑 MCP · {{id}}", + "editTitle": "编辑 MCP", "templates": "从模板开始", "paste": "粘贴配置", "pastePlaceholder": "粘贴 JSON 或 TOML,自动识别 mcpServers、Codex、OpenCode 的写法", @@ -4333,8 +4336,7 @@ "tags": "标签", "tagsPlaceholder": "用逗号分隔,例如 docs, web", "homepage": "主页", - "docs": "文档", - "delete": "删除" + "docs": "文档" }, "resync": "按这里的开关重写各应用配置" }, diff --git a/src/lib/unsavedChanges.ts b/src/lib/unsavedChanges.ts new file mode 100644 index 000000000..632a9553b --- /dev/null +++ b/src/lib/unsavedChanges.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { SyntheticEvent } from "react"; + +/** + * 整页编辑器的「未保存改动」登记。 + * + * 编辑器只盖住内容区,侧栏、托盘、⌘, 仍能离开当前页,离开会卸载编辑器、丢掉草稿。 + * 编辑器把用户动过表单登记在这里,外壳的导航入口离开前先问一句。 + * 保存成功或关闭后编辑器卸载,登记随之撤销。 + */ +const dirtyEditors = new Set(); + +export const hasUnsavedChanges = () => dirtyEditors.size > 0; + +/** 用户确认放弃后清空登记,随后的导航不再重复询问 */ +export const discardUnsavedChanges = () => dirtyEditors.clear(); + +/** + * 这些控件被点一下就算改了:勾选、开关、单选、下拉选项、分段按钮。 + * 只切换显示的控件(显示密钥、表单 / JSON)放在 `data-unsaved-ignore` 里排除。 + */ +const EDIT_CONTROL_SELECTOR = [ + '[role="checkbox"]', + '[role="switch"]', + '[role="radio"]', + '[role="option"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + "[aria-pressed]", +].join(","); + +/** + * 挂到编辑器内容区:文字输入、原生选择变化、以及点到上面那些控件时登记为有未保存改动。 + * React 的合成事件沿组件树冒泡,渲染到 body 的下拉选项也能收到。 + * `enabled` 为 false 时不登记(例如新增供应商还在选预设)。 + */ +export function useUnsavedChangesTracker(enabled = true) { + const token = useRef({}).current; + useEffect(() => { + if (!enabled) dirtyEditors.delete(token); + return () => { + dirtyEditors.delete(token); + }; + }, [enabled, token]); + + const markDirty = useCallback(() => { + if (enabled) dirtyEditors.add(token); + }, [enabled, token]); + + const onClickCapture = useCallback( + (event: SyntheticEvent) => { + const target = event.target as Element | null; + if ( + target?.closest?.(EDIT_CONTROL_SELECTOR) && + !target.closest("[data-unsaved-ignore]") + ) { + markDirty(); + } + }, + [markDirty], + ); + + return { + onInputCapture: markDirty, + onChangeCapture: markDirty, + onClickCapture, + }; +} diff --git a/tests/components/AddProviderDialog.test.tsx b/tests/components/AddProviderDialog.test.tsx index eb99c0ebb..3366a3f9d 100644 --- a/tests/components/AddProviderDialog.test.tsx +++ b/tests/components/AddProviderDialog.test.tsx @@ -125,7 +125,7 @@ describe("AddProviderDialog", () => { // Claude 的表单要等 live 底读回来才渲染。 await screen.findByRole("button", { name: "manage-auth" }); fireEvent.click( - screen.getByRole("button", { + await screen.findByRole("button", { name: "common.add", }), ); @@ -164,7 +164,7 @@ describe("AddProviderDialog", () => { await screen.findByRole("button", { name: "manage-auth" }); fireEvent.click( - screen.getByRole("button", { + await screen.findByRole("button", { name: "common.add", }), ); @@ -185,7 +185,7 @@ describe("AddProviderDialog", () => { "%s 新增时带上表单投影出的底,和编辑器同一套保存规则", async (appId) => { const handleSubmit = vi.fn().mockResolvedValue(undefined); - const projected = { config: "[ui]\ntheme = \"dark\"\n" }; + const projected = { config: '[ui]\ntheme = "dark"\n' }; const draft = { config: "" }; mockProjectedBase = projected; mockProjectedDraft = draft; @@ -205,7 +205,9 @@ describe("AddProviderDialog", () => { ); await screen.findByRole("button", { name: "manage-auth" }); - fireEvent.click(screen.getByRole("button", { name: "common.add" })); + fireEvent.click( + await screen.findByRole("button", { name: "common.add" }), + ); await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); expect(handleSubmit.mock.calls[0][0].editorSave).toEqual({ @@ -235,7 +237,7 @@ describe("AddProviderDialog", () => { ); await screen.findByRole("button", { name: "manage-auth" }); - fireEvent.click(screen.getByRole("button", { name: "common.add" })); + fireEvent.click(await screen.findByRole("button", { name: "common.add" })); await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); expect(handleSubmit.mock.calls[0][0].editorSave).toBeUndefined(); @@ -274,7 +276,7 @@ describe("AddProviderDialog", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: "common.add" })); + fireEvent.click(await screen.findByRole("button", { name: "common.add" })); await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); expect(handleSubmit).toHaveBeenCalledWith( @@ -349,7 +351,7 @@ context_window = 500000 />, ); - fireEvent.click(screen.getByRole("button", { name: "common.add" })); + fireEvent.click(await screen.findByRole("button", { name: "common.add" })); await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); @@ -392,7 +394,7 @@ context_window = 500000 />, ); - fireEvent.click(screen.getByRole("button", { name: "common.add" })); + fireEvent.click(await screen.findByRole("button", { name: "common.add" })); await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); expect(handleSubmit.mock.calls[0][0]).toMatchObject({ providerKey: "pi-provider", diff --git a/tests/components/FullScreenPanel.test.tsx b/tests/components/FullScreenPanel.test.tsx index f19d6ce32..744e7d1d1 100644 --- a/tests/components/FullScreenPanel.test.tsx +++ b/tests/components/FullScreenPanel.test.tsx @@ -1,6 +1,7 @@ -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import { DRAG_REGION_ENABLED } from "@/lib/platform"; const Panels = ({ innerOpen }: { innerOpen: boolean }) => ( <> @@ -31,3 +32,36 @@ describe("FullScreenPanel body scroll locking", () => { expect(document.body.style.overflow).toBe("clip"); }); }); + +describe("FullScreenPanel header", () => { + it("lets a long title shrink and truncate instead of pushing the window controls away", () => { + const title = "Edit " + "a-very-long-user-provided-name-".repeat(8); + render( + undefined}> + body + , + ); + const heading = screen.getByRole("heading", { name: title }); + expect(heading).toHaveClass("min-w-0", "truncate"); + expect(heading.parentElement).toHaveClass("flex-1"); + expect(heading.parentElement).not.toHaveClass("shrink-0"); + }); + + it("keeps the whole title area as a window drag region", () => { + render( + undefined}> + body + , + ); + const heading = screen.getByRole("heading", { name: "Edit MCP" }); + // Tauri 只认按下的元素自身带属性:标题和铺满页头的标题区都得带 + for (const element of [heading, heading.parentElement!]) { + expect(element.hasAttribute("data-tauri-drag-region")).toBe( + DRAG_REGION_ENABLED, + ); + } + expect( + screen.getByRole("button", { name: "common.back" }), + ).not.toHaveAttribute("data-tauri-drag-region"); + }); +}); diff --git a/tests/components/PiPromptPanel.test.tsx b/tests/components/PiPromptPanel.test.tsx index ccb86c9ba..be718ba96 100644 --- a/tests/components/PiPromptPanel.test.tsx +++ b/tests/components/PiPromptPanel.test.tsx @@ -1,4 +1,8 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + QueryClient, + QueryClientProvider, + useMutation, +} from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -119,6 +123,38 @@ describe("Pi prompts page", () => { expect(screen.getByText("~/.pi/agent/prompts/")).toBeInTheDocument(); }); + it("locks navigation while a system prompt or template editor is saving", async () => { + function PendingNativeSave() { + const save = useMutation({ + mutationKey: ["pi", "promptSave"], + mutationFn: () => new Promise(() => {}), + }); + return ( + + ); + } + const onNavigationBlockedChange = vi.fn(); + renderWithClient( + <> + + + , + ); + await screen.findByRole("button", { name: "prompts.add" }); + expect(onNavigationBlockedChange).toHaveBeenLastCalledWith(false); + fireEvent.click(screen.getByRole("button", { name: "start-native-save" })); + await waitFor(() => + expect(onNavigationBlockedChange).toHaveBeenLastCalledWith(true), + ); + }); + it("warns about an external AGENTS.md and stores it in the library", async () => { renderWithClient( , diff --git a/tests/components/PromptFormPanel.test.tsx b/tests/components/PromptFormPanel.test.tsx index e3f658963..ef113a007 100644 --- a/tests/components/PromptFormPanel.test.tsx +++ b/tests/components/PromptFormPanel.test.tsx @@ -96,7 +96,6 @@ describe("PromptFormPanel", () => { }); it("names the overwritten file on the save button of the enabled prompt", () => { - const onDelete = vi.fn(); render( { initialData={active} onSave={vi.fn()} onClose={vi.fn()} - onDelete={onDelete} />, ); expect( screen.getByRole("button", { name: "prompts.saveAndOverwrite:SOUL.md" }), ).toBeInTheDocument(); - const del = screen.getByRole("button", { name: "common.delete" }); - expect(del).toHaveAttribute("aria-disabled", "true"); - fireEvent.click(del); - expect(onDelete).not.toHaveBeenCalled(); + // 删除只在列表里,编辑页底栏只有取消和保存 + expect( + screen.queryByRole("button", { name: "common.delete" }), + ).not.toBeInTheDocument(); }); it("keeps Pi content as typed and writes AGENTS.md for the enabled prompt", async () => { diff --git a/tests/components/PromptPanel.test.tsx b/tests/components/PromptPanel.test.tsx index 01452176a..ea0928606 100644 --- a/tests/components/PromptPanel.test.tsx +++ b/tests/components/PromptPanel.test.tsx @@ -81,13 +81,11 @@ vi.mock("@/components/prompts/PromptFormPanel", async (importOriginal) => { initialData, onSave, onClose, - onDelete, }: { editingId?: string; initialData?: Prompt; onSave: (id: string, prompt: Prompt) => Promise; onClose: () => void; - onDelete?: (id: string) => void; }) => (
{editingId}:{initialData?.name} @@ -111,11 +109,6 @@ vi.mock("@/components/prompts/PromptFormPanel", async (importOriginal) => { - {editingId && onDelete ? ( - - ) : null}
), }; @@ -385,19 +378,6 @@ describe("PromptPanel", () => { expect(within(menu).getByText("prompts.deleteBlocked")).toBeInTheDocument(); }); - it("deletes from the drawer after closing it", async () => { - renderPanel(); - await waitForPanelReady(); - - fireEvent.click(editButton("Harbor Prompt")); - fireEvent.click(screen.getByRole("button", { name: "form-delete" })); - - await waitFor(() => - expect(mocks.deletePrompt).toHaveBeenCalledWith("second-record"), - ); - expect(screen.queryByTestId("prompt-form")).not.toBeInTheDocument(); - }); - it("disabling says the file was emptied and undo re-enables it", async () => { renderPanel({ appId: "hermes" }); await waitForPanelReady(); diff --git a/tests/components/Sheet.test.tsx b/tests/components/Sheet.test.tsx index 36d81040c..263fbaecd 100644 --- a/tests/components/Sheet.test.tsx +++ b/tests/components/Sheet.test.tsx @@ -5,6 +5,7 @@ import { Sheet, SheetContent, SheetDescription, + SheetPageContent, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; @@ -56,4 +57,42 @@ describe("SheetContent", () => { await clickOutside(); expect(onOpenChange).toHaveBeenCalledWith(false); }); + + it("page mode renders a page header whose back button closes the sheet", () => { + const onOpenChange = vi.fn(); + render( + + + body + + , + ); + expect( + screen.getByRole("dialog", { name: "Edit prompt" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "back" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("non-modal page mode leaves the rest of the window interactive", () => { + const onOpenChange = vi.fn(); + const onOutside = vi.fn(); + render( + <> + + + + body + + + , + ); + expect(document.body.style.pointerEvents).not.toBe("none"); + fireEvent.pointerDown(screen.getByRole("button", { name: "sidebar" })); + fireEvent.click(screen.getByRole("button", { name: "sidebar" })); + expect(onOutside).toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); }); diff --git a/tests/components/UnifiedMcpPanel.test.tsx b/tests/components/UnifiedMcpPanel.test.tsx index 6bf538d7c..abd1fbf0a 100644 --- a/tests/components/UnifiedMcpPanel.test.tsx +++ b/tests/components/UnifiedMcpPanel.test.tsx @@ -1,3 +1,8 @@ +import { + QueryClient, + QueryClientProvider, + useMutation, +} from "@tanstack/react-query"; import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -22,6 +27,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/hooks/useMcp", () => ({ + MCP_UPSERT_MUTATION_KEY: ["mcp", "upsert"], useAllMcpServers: () => ({ data: mocks.serversMap, isLoading: mocks.isLoading, @@ -93,8 +99,29 @@ function makeServer(id: string, overrides: ServerOverrides = {}): McpServer { } as McpServer; } -const renderPanel = (onBlocked?: (blocked: boolean) => void) => - render(); +const renderPanel = ( + onBlocked?: (blocked: boolean) => void, + extra?: React.ReactNode, +) => + render( + + + {extra} + , + ); + +/** 模拟编辑页发起、一直没完成的保存 */ +function PendingEditorSave() { + const save = useMutation({ + mutationKey: ["mcp", "upsert"], + mutationFn: () => new Promise(() => {}), + }); + return ( + + ); +} const rowNames = () => within(screen.getByRole("list", { name: "mcpPage.listLabel" })) @@ -411,12 +438,22 @@ describe("UnifiedMcpPanel", () => { expect(screen.getByText("mcpPage.import.added")).toBeInTheDocument(); }); - it("reports the blocked state while the drawer is open", async () => { + it("locks navigation while the editor page is saving", async () => { + const onBlocked = vi.fn(); + renderPanel(onBlocked, ); + expect(onBlocked).toHaveBeenLastCalledWith(false); + await userEvent.click( + screen.getByRole("button", { name: "start-editor-save" }), + ); + await waitFor(() => expect(onBlocked).toHaveBeenLastCalledWith(true)); + }); + + it("does not lock navigation just because the editor page is open", async () => { const onBlocked = vi.fn(); mocks.serversMap = { alpha: makeServer("alpha") }; renderPanel(onBlocked); expect(onBlocked).toHaveBeenLastCalledWith(false); await userEvent.click(screen.getByRole("button", { name: "mcpPage.add" })); - expect(onBlocked).toHaveBeenLastCalledWith(true); + expect(onBlocked).toHaveBeenLastCalledWith(false); }); }); diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx index da114448b..0fe5e1d9e 100644 --- a/tests/integration/App.test.tsx +++ b/tests/integration/App.test.tsx @@ -93,27 +93,35 @@ vi.mock("@/components/providers/AddProviderDialog", () => ({ ) : null, })); -vi.mock("@/components/providers/EditProviderDialog", () => ({ - EditProviderDialog: ({ open, provider, onSubmit, onOpenChange }: any) => - open ? ( -
- - -
- ) : null, -})); +vi.mock("@/components/providers/EditProviderDialog", async () => { + const { useUnsavedChangesTracker } = await vi.importActual< + typeof import("@/lib/unsavedChanges") + >("@/lib/unsavedChanges"); + // 和真的编辑页一样登记改动(真的由 FullScreenPanel 的 trackUnsavedChanges 负责) + const Body = ({ provider, onSubmit, onOpenChange }: any) => ( +
+ + + +
+ ); + return { + EditProviderDialog: (props: any) => + props.open ? : null, + }; +}); vi.mock("@/components/UsageScriptModal", () => ({ default: ({ isOpen, provider, onSave, onClose }: any) => @@ -308,7 +316,9 @@ describe("App integration with MSW", () => { "codex-1", ), ); - expect(screen.queryByTestId("edit-provider-dialog")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("edit-provider-dialog"), + ).not.toBeInTheDocument(); fireEvent.click(screen.getByText("usage")); expect(screen.getByTestId("usage-modal")).toBeInTheDocument(); @@ -330,6 +340,53 @@ describe("App integration with MSW", () => { expect(screen.queryByTestId("add-provider-dialog")).not.toBeInTheDocument(); }, 10_000); + it("asks before leaving an editor page with unsaved changes", async () => { + const { default: App } = await import("@/App"); + renderApp(App); + await waitFor(() => + expect(screen.getByTestId("provider-list").textContent).toContain( + "claude-1", + ), + ); + + // 没改过:照常离开,不问 + fireEvent.click(screen.getByText("edit")); + fireEvent.click(sidebarApp("Codex")); + await waitFor(() => + expect(screen.getByTestId("provider-list").textContent).toContain( + "codex-1", + ), + ); + expect(screen.queryByTestId("confirm-dialog")).not.toBeInTheDocument(); + + // 改过:先问;继续编辑就留在原页 + // (ConfirmDialog 在这个文件里是 mock:只渲染消息和 confirm-delete / cancel-delete) + fireEvent.click(screen.getByText("edit")); + fireEvent.input(screen.getByLabelText("edit-field"), { + target: { value: "draft" }, + }); + fireEvent.click(sidebarApp("Claude Code")); + expect(await screen.findByTestId("confirm-message")).toHaveTextContent( + "common.unsavedLeaveMessage", + ); + fireEvent.click(screen.getByText("cancel-delete")); + expect(screen.getByTestId("edit-provider-dialog")).toBeInTheDocument(); + expect(screen.getByTestId("provider-list").textContent).toContain( + "codex-1", + ); + + // ⌘, 也问;放弃后才离开 + fireEvent.keyDown(window, { key: ",", metaKey: true }); + expect(await screen.findByTestId("confirm-message")).toHaveTextContent( + "common.unsavedLeaveMessage", + ); + fireEvent.click(screen.getByText("confirm-delete")); + expect(await screen.findByTestId("settings-page")).toBeInTheDocument(); + expect( + screen.queryByTestId("edit-provider-dialog"), + ).not.toBeInTheDocument(); + }, 10_000); + it("shows toast when auto sync fails in background", async () => { const { default: App } = await import("@/App"); renderApp(App); diff --git a/tests/lib/unsavedChanges.test.tsx b/tests/lib/unsavedChanges.test.tsx new file mode 100644 index 000000000..c0da56f9b --- /dev/null +++ b/tests/lib/unsavedChanges.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import { Sheet, SheetPageContent } from "@/components/ui/sheet"; +import { + discardUnsavedChanges, + hasUnsavedChanges, + useUnsavedChangesTracker, +} from "@/lib/unsavedChanges"; + +function Editor({ enabled = true }: { enabled?: boolean }) { + return ( +
+ + + + +
+ ); +} + +describe("unsaved changes tracker", () => { + afterEach(() => discardUnsavedChanges()); + + it("registers typing and edit controls, not view toggles or plain buttons", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "reveal" })); + fireEvent.click(screen.getByRole("button", { name: "expand" })); + expect(hasUnsavedChanges()).toBe(false); + + fireEvent.click(screen.getByRole("checkbox", { name: "claude" })); + expect(hasUnsavedChanges()).toBe(true); + }); + + it("registers text input and drops the entry on unmount", () => { + const view = render(); + fireEvent.input(screen.getByRole("textbox", { name: "name" }), { + target: { value: "x" }, + }); + expect(hasUnsavedChanges()).toBe(true); + view.unmount(); + expect(hasUnsavedChanges()).toBe(false); + }); + + it("does nothing while disabled and forgets edits once disabled", () => { + const view = render(); + fireEvent.input(screen.getByRole("textbox", { name: "name" })); + expect(hasUnsavedChanges()).toBe(false); + + view.rerender(); + fireEvent.input(screen.getByRole("textbox", { name: "name" })); + expect(hasUnsavedChanges()).toBe(true); + view.rerender(); + expect(hasUnsavedChanges()).toBe(false); + }); + + it("tracks FullScreenPanel only when asked and only while open", () => { + const panel = (props: { open: boolean; track: boolean }) => ( + undefined} + trackUnsavedChanges={props.track} + > + + + ); + const view = render(panel({ open: true, track: false })); + fireEvent.input(screen.getByRole("textbox", { name: "field" })); + expect(hasUnsavedChanges()).toBe(false); + + view.rerender(panel({ open: true, track: true })); + fireEvent.input(screen.getByRole("textbox", { name: "field" })); + expect(hasUnsavedChanges()).toBe(true); + view.rerender(panel({ open: false, track: true })); + expect(hasUnsavedChanges()).toBe(false); + }); + + it("tracks sheet editor pages", () => { + render( + + +