From a4d07f313c783b0b16598c135d278a68d7653530 Mon Sep 17 00:00:00 2001 From: Owlbay Date: Tue, 6 Oct 2026 10:23:04 +0800 Subject: [PATCH] feat(ui): edit MCP and prompts in full pages and confirm before leaving unsaved editors (#7842) MCP, prompt and Pi native prompt editors open as non-modal full pages (SheetPageContent) inside the content area, so the sidebar and window drag region stay usable. Provider editors drop the 1008px cap and follow the window width like the list pages. Full-page editors register unsaved changes (input, change, and clicks on checkbox / switch / radio / option / aria-pressed controls; display-only toggles opt out with data-unsaved-ignore). Leaving through the sidebar, Cmd+, or the tray asks before discarding; back, cancel and Escape still close directly. Editor saves keep navigation locked via mutation keys. Also truncates long page titles, makes the whole page header a drag region, and removes the editor delete buttons that no caller used. --- src/App.tsx | 61 ++++++++--- src/components/UsageScriptModal.tsx | 1 + src/components/common/FullScreenPanel.tsx | 18 +++- src/components/mcp/McpFormModal.tsx | 41 ++----- src/components/mcp/UnifiedMcpPanel.tsx | 18 +++- .../prompts/PiNativePromptResources.tsx | 39 ++----- src/components/prompts/PiPromptPanel.tsx | 12 ++- src/components/prompts/PromptFormPanel.tsx | 52 ++------- src/components/prompts/PromptPanel.tsx | 10 +- .../providers/AddProviderDialog.tsx | 5 +- .../providers/EditProviderDialog.tsx | 7 +- src/components/shell/AppPageHeader.tsx | 5 +- src/components/ui/sheet.tsx | 67 +++++++++++- .../universal/UniversalProviderFormModal.tsx | 1 + src/components/usage/PricingEditModal.tsx | 1 + src/hooks/useMcp.ts | 4 + src/i18n/locales/en.json | 12 ++- src/i18n/locales/ja.json | 12 ++- src/i18n/locales/zh-TW.json | 12 ++- src/i18n/locales/zh.json | 12 ++- src/lib/unsavedChanges.ts | 68 ++++++++++++ tests/components/AddProviderDialog.test.tsx | 18 ++-- tests/components/FullScreenPanel.test.tsx | 36 ++++++- tests/components/PiPromptPanel.test.tsx | 38 ++++++- tests/components/PromptFormPanel.test.tsx | 10 +- tests/components/PromptPanel.test.tsx | 20 ---- tests/components/Sheet.test.tsx | 39 +++++++ tests/components/UnifiedMcpPanel.test.tsx | 45 +++++++- tests/integration/App.test.tsx | 101 ++++++++++++++---- tests/lib/unsavedChanges.test.tsx | 95 ++++++++++++++++ 30 files changed, 635 insertions(+), 225 deletions(-) create mode 100644 src/lib/unsavedChanges.ts create mode 100644 tests/lib/unsavedChanges.test.tsx 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( + + +