mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
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.
This commit is contained in:
1 parent
cf567ca648
commit
a4d07f313c
30 files changed
+635
-225
No files matched your search
+46
-15
@@ -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)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={pendingLeave !== null}
|
||||
title={t("common.unsavedLeaveTitle")}
|
||||
message={t("common.unsavedLeaveMessage")}
|
||||
confirmText={t("common.unsavedLeaveConfirm")}
|
||||
cancelText={t("common.unsavedLeaveCancel")}
|
||||
zIndex="top"
|
||||
onConfirm={() => {
|
||||
const proceed = pendingLeave;
|
||||
setPendingLeave(null);
|
||||
discardUnsavedChanges();
|
||||
proceed?.();
|
||||
}}
|
||||
onCancel={() => setPendingLeave(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={launchDashboardOpen}
|
||||
title={t("hermes.webui.launchConfirmTitle")}
|
||||
|
||||
@@ -891,6 +891,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
trackUnsavedChanges
|
||||
title={`${t("usageScript.title")} - ${provider.name}`}
|
||||
onClose={onClose}
|
||||
footer={footer}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { HoverTip } from "@/components/ui/hover-tip";
|
||||
import { AppPageHeader } from "@/components/shell/AppPageHeader";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
import { useUnsavedChangesTracker } from "@/lib/unsavedChanges";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FullScreenPanelProps {
|
||||
@@ -28,6 +29,11 @@ interface FullScreenPanelProps {
|
||||
* 通过 `cn`(twMerge) 合并,传入如 `pt-3` 只覆盖顶部内边距,其余保持默认。
|
||||
*/
|
||||
contentClassName?: string;
|
||||
/**
|
||||
* 编辑表单:用户改过内容后,从侧栏、托盘、⌘, 离开当前页前会先确认。
|
||||
* 只给编辑页打开;授权中心、测速这类页面不需要。
|
||||
*/
|
||||
trackUnsavedChanges?: boolean;
|
||||
}
|
||||
|
||||
/** 外壳里的内容区(App 的 <main id="content-area">):二级页只盖住它,侧栏留着 */
|
||||
@@ -68,7 +74,12 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
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<FullScreenPanelProps> = ({
|
||||
<AppPageHeader
|
||||
variant="app"
|
||||
title={title}
|
||||
// 标题里常带用户起的名字,不限长度;必须能收缩截断,否则会把窗口按钮挤出可视区
|
||||
truncateTitle
|
||||
subtitle={subtitle}
|
||||
actions={actions}
|
||||
leading={
|
||||
@@ -164,7 +177,10 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto scroll-stable">
|
||||
<div className={cn("w-full space-y-6 px-6 py-6", contentClassName)}>
|
||||
<div
|
||||
className={cn("w-full space-y-6 px-6 py-6", contentClassName)}
|
||||
{...unsavedChangesHandlers}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<void>;
|
||||
onClose: () => void;
|
||||
/** 编辑时底栏左侧「删除…」 */
|
||||
onRequestDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
const emptyMeta = (): MetaDraft => ({
|
||||
@@ -125,7 +121,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
visibleAppIds = MCP_APP_IDS,
|
||||
onSave,
|
||||
onClose,
|
||||
onRequestDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const upsertMutation = useUpsertMcpServer();
|
||||
@@ -467,7 +462,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
|
||||
// ─── 渲染 ───────────────────────────────────────────────────────────
|
||||
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<McpFormModalProps> = ({
|
||||
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<McpFormModalProps> = ({
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !savingRef.current) onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
width={560}
|
||||
closeLabel={t("common.close")}
|
||||
<SheetPageContent
|
||||
title={title}
|
||||
closeLabel={t("common.back")}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<SheetHeader className="flex h-[52px] items-center space-y-0 border-b border-border pe-12 ps-6 pt-0">
|
||||
<SheetTitle className="truncate text-section" title={title}>
|
||||
{title}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetBody className="flex flex-col gap-5 px-6 pb-6 pt-5">
|
||||
{!isEdit && (
|
||||
<>
|
||||
@@ -864,7 +855,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
aria-labelledby="mcp-conn-title"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div data-unsaved-ignore className="flex items-center gap-3">
|
||||
<h3
|
||||
id="mcp-conn-title"
|
||||
className="m-0 flex-1 text-body font-semibold text-fg-1"
|
||||
@@ -1126,6 +1117,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
{hasSecrets && (
|
||||
<button
|
||||
type="button"
|
||||
data-unsaved-ignore
|
||||
aria-pressed={revealJson}
|
||||
onClick={toggleRevealJson}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-control pe-2 ps-1.5 text-body font-medium text-fg-2 transition-colors hover:bg-subtle hover:text-fg-1"
|
||||
@@ -1331,19 +1323,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
</SheetBody>
|
||||
|
||||
<SheetFooter className="h-14 px-6 py-0">
|
||||
{isEdit && onRequestDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
size="regular"
|
||||
className="-ms-2.5 text-danger-text hover:text-danger-text"
|
||||
disabled={saving}
|
||||
onClick={() => onRequestDelete(editingId as string)}
|
||||
>
|
||||
{t("mcpPage.drawer.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
@@ -1363,7 +1342,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
{saving ? t("common.saving") : submitLabel}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</SheetPageContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<UnifiedMcpPanelProps> = ({
|
||||
// 写入本身仍由写锁(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<UnifiedMcpPanelProps> = ({
|
||||
visibleAppIds={appIds}
|
||||
onSave={() => setDrawer(null)}
|
||||
onClose={() => setDrawer(null)}
|
||||
onRequestDelete={(id) => setDeleteId(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Sheet
|
||||
open
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent width={560} closeLabel={t("common.close")}>
|
||||
<div className="flex h-[52px] shrink-0 items-center border-b border-border pe-12 ps-6">
|
||||
<SheetTitle
|
||||
title={title}
|
||||
className="min-w-0 truncate text-title text-fg-1"
|
||||
>
|
||||
{title}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">{description}</SheetDescription>
|
||||
</div>
|
||||
<SheetPageContent title={title} closeLabel={t("common.back")}>
|
||||
<SheetDescription className="sr-only">{description}</SheetDescription>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto scroll-stable overscroll-contain px-6 pb-6 pt-5">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex h-14 shrink-0 items-center gap-2 border-t border-border px-6">
|
||||
{footer}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</SheetPageContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -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<string>;
|
||||
onClose: () => void;
|
||||
onChanged: () => Promise<void>;
|
||||
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 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
size="regular"
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(template)}
|
||||
className="-ms-2.5 text-danger-text"
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1037,7 +1019,6 @@ export const PiPromptTemplates = forwardRef<
|
||||
existingSlugs={existingSlugs}
|
||||
onClose={() => setEditor(null)}
|
||||
onChanged={refresh}
|
||||
onDelete={(template) => remove.mutate(template)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Notice } from "@/components/ui/notice";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from "./PromptLibrary";
|
||||
import { PromptPageFrame } from "./PromptPageFrame";
|
||||
import {
|
||||
PI_PROMPT_SAVE_MUTATION_KEY,
|
||||
PiPromptTemplates,
|
||||
PiSystemPromptFiles,
|
||||
usePiPromptTemplatesQuery,
|
||||
@@ -86,7 +88,11 @@ const PiPromptPanel: React.FC<PiPromptPanelProps> = ({
|
||||
const writePending = Boolean(togglingId) || busy;
|
||||
const interactionBlocked =
|
||||
loading || writePending || isFormOpen || dialogOpen;
|
||||
const navigationBlocked = writePending || isFormOpen || dialogOpen;
|
||||
// 系统提示 / 模板编辑页自己的保存,按 mutation key 单独看
|
||||
const nativeSaving =
|
||||
useIsMutating({ mutationKey: PI_PROMPT_SAVE_MUTATION_KEY }) > 0;
|
||||
// 编辑页只盖住内容区,不锁导航:离开页面就关掉它(同供应商编辑页);保存进行中仍锁
|
||||
const navigationBlocked = writePending || dialogOpen || nativeSaving;
|
||||
// 外观上的禁用晚 300ms 才出现:点一下启用这类很快的写入不让整列按钮闪一下变灰。
|
||||
// 拦截仍看 interactionBlocked;表单、对话框打开时照常立即禁用。
|
||||
const controlsDisabled =
|
||||
@@ -532,10 +538,6 @@ const PiPromptPanel: React.FC<PiPromptPanelProps> = ({
|
||||
initialData={editingId ? prompts[editingId] : undefined}
|
||||
onSave={handleSave}
|
||||
onClose={() => setIsFormOpen(false)}
|
||||
onDelete={(id) => {
|
||||
setIsFormOpen(false);
|
||||
performDelete(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,12 +2,10 @@ import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DisabledReason } from "@/components/ui/help-tip";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
SheetPageContent,
|
||||
} from "@/components/ui/sheet";
|
||||
import { APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
|
||||
import type { Prompt, AppId } from "@/lib/api";
|
||||
@@ -27,8 +25,6 @@ interface PromptFormPanelProps {
|
||||
initialData?: Prompt;
|
||||
onSave: (id: string, prompt: Prompt) => Promise<void | boolean>;
|
||||
onClose: () => void;
|
||||
/** 编辑时左下角的「删除」;不传就不显示 */
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
/** 和 Input / Textarea 同一套外观(ui/input.tsx 的 fieldClass) */
|
||||
@@ -60,7 +56,6 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
initialData,
|
||||
onSave,
|
||||
onClose,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const baseId = useId();
|
||||
@@ -137,7 +132,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
};
|
||||
|
||||
const title = editingId
|
||||
? t("prompts.editTitle", { name: initialData?.name ?? "" })
|
||||
? t("prompts.editTitle")
|
||||
: t("prompts.addTitle", { appName });
|
||||
|
||||
const submitLabel = !editingId
|
||||
@@ -162,28 +157,21 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
width={560}
|
||||
closeLabel={t("common.close")}
|
||||
<SheetPageContent
|
||||
title={title}
|
||||
closeLabel={t("common.back")}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (savingRef.current) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="flex h-[52px] shrink-0 items-center border-b border-border pe-12 ps-6">
|
||||
<SheetTitle
|
||||
title={title}
|
||||
className="min-w-0 truncate text-title text-fg-1"
|
||||
>
|
||||
{title}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("prompts.formDescription", { file: fileName })}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("prompts.formDescription", { file: fileName })}
|
||||
</SheetDescription>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto scroll-stable overscroll-contain px-6 pb-6 pt-5">
|
||||
<div className="flex shrink-0 flex-col gap-1.5">
|
||||
@@ -288,26 +276,6 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex h-14 shrink-0 items-center gap-2 border-t border-border px-6">
|
||||
{editingId && onDelete ? (
|
||||
<DisabledReason
|
||||
reason={isActive ? t("prompts.deleteBlockedReason") : undefined}
|
||||
side="top"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
size="regular"
|
||||
disabled={saving}
|
||||
onClick={() => onDelete(editingId)}
|
||||
className={cn(
|
||||
"-ms-2.5",
|
||||
isActive ? "text-fg-3" : "text-danger-text",
|
||||
)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</DisabledReason>
|
||||
) : null}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -328,7 +296,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
{saving ? t("common.saving") : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</SheetPageContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,7 +76,8 @@ const StandardPromptPanel: React.FC<PromptPanelProps> = ({
|
||||
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<PromptPanelProps> = ({
|
||||
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<PromptPanelProps> = ({
|
||||
initialData={editingId ? prompts[editingId] : undefined}
|
||||
onSave={handleSave}
|
||||
onClose={handleCloseForm}
|
||||
onDelete={handleDeleteFromDrawer}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -534,6 +534,7 @@ export function AddProviderDialog({
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={open}
|
||||
trackUnsavedChanges={step === "form"}
|
||||
title={t("provider.addNewProvider")}
|
||||
subtitle={
|
||||
stackLayout
|
||||
@@ -554,8 +555,8 @@ export function AddProviderDialog({
|
||||
step === "pick"
|
||||
? "flex h-full flex-col space-y-0 p-0"
|
||||
: appId === "pi"
|
||||
? "mx-0 max-w-[1008px] pb-0 pt-4"
|
||||
: "mx-0 max-w-[1008px] pt-4"
|
||||
? "pb-0 pt-4"
|
||||
: "pt-4"
|
||||
}
|
||||
>
|
||||
<PresetStepContext.Provider value={stepState}>
|
||||
|
||||
@@ -358,6 +358,7 @@ export function EditProviderDialog({
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={open}
|
||||
trackUnsavedChanges
|
||||
title={t("provider.editProviderNamed", { name: provider.name })}
|
||||
subtitle={
|
||||
stackLayout
|
||||
@@ -369,11 +370,7 @@ export function EditProviderDialog({
|
||||
}
|
||||
backLabel={t("provider.backToList")}
|
||||
onClose={handlePanelClose}
|
||||
contentClassName={
|
||||
appId === "pi"
|
||||
? "mx-0 max-w-[1008px] pb-0 pt-4"
|
||||
: "mx-0 max-w-[1008px] pt-4"
|
||||
}
|
||||
contentClassName={appId === "pi" ? "pb-0 pt-4" : "pt-4"}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -49,7 +49,9 @@ export function AppPageHeader({
|
||||
{...DRAG_REGION_ATTR}
|
||||
style={DRAG_REGION_STYLE as React.CSSProperties}
|
||||
>
|
||||
{/* Tauri 只认按下的元素自身带拖动属性:标题区、标题、占位条都要带,整条才能拖窗口 */}
|
||||
<div
|
||||
{...DRAG_REGION_ATTR}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2.5",
|
||||
truncateTitle ? "flex-1" : "shrink-0",
|
||||
@@ -69,6 +71,7 @@ export function AppPageHeader({
|
||||
</span>
|
||||
)}
|
||||
<h1
|
||||
{...DRAG_REGION_ATTR}
|
||||
className={cn(
|
||||
"m-0 whitespace-nowrap",
|
||||
variant === "app" ? "text-title" : "text-page",
|
||||
@@ -88,7 +91,7 @@ export function AppPageHeader({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!truncateTitle && <div className="flex-1" />}
|
||||
{!truncateTitle && <div {...DRAG_REGION_ATTR} className="flex-1" />}
|
||||
{actions && (
|
||||
<div
|
||||
className="flex min-w-0 shrink items-center justify-end gap-2"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { ChevronLeft, X } from "lucide-react";
|
||||
import { AppPageHeader } from "@/components/shell/AppPageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DRAG_REGION_ATTR } from "@/lib/platform";
|
||||
import { useUnsavedChangesTracker } from "@/lib/unsavedChanges";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -68,6 +72,66 @@ const SheetContent = React.forwardRef<
|
||||
);
|
||||
SheetContent.displayName = "SheetContent";
|
||||
|
||||
/** 登记整页编辑器里的改动;`display: contents` 不影响布局 */
|
||||
function UnsavedChangesScope({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="contents" {...useUnsavedChangesTracker()}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 整页版的 SheetContent:盖住内容区,页头带返回按钮(同 FullScreenPanel),内容随窗口铺满。
|
||||
* 配合 `<Sheet modal={false}>` 使用:侧栏和窗口拖动区照常可用;点外面不关。
|
||||
*/
|
||||
const SheetPageContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
||||
"title"
|
||||
> & {
|
||||
title: React.ReactNode;
|
||||
/** 返回按钮的无障碍名字 */
|
||||
closeLabel: string;
|
||||
}
|
||||
>(({ title, closeLabel, children, ...props }, ref) => (
|
||||
<DialogPrimitive.Portal
|
||||
container={document.getElementById("content-area") ?? undefined}
|
||||
>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className="absolute inset-0 z-[60] flex flex-col bg-app text-fg-1 outline-none"
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
{...props}
|
||||
>
|
||||
<AppPageHeader
|
||||
variant="app"
|
||||
truncateTitle
|
||||
title={
|
||||
<DialogPrimitive.Title asChild>
|
||||
<span {...DRAG_REGION_ATTR}>{title}</span>
|
||||
</DialogPrimitive.Title>
|
||||
}
|
||||
leading={
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="icon-compact"
|
||||
className="h-8 w-8"
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
}
|
||||
/>
|
||||
<UnsavedChangesScope>{children}</UnsavedChangesScope>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
));
|
||||
SheetPageContent.displayName = "SheetPageContent";
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
@@ -133,6 +197,7 @@ export {
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetPageContent,
|
||||
SheetHeader,
|
||||
SheetBody,
|
||||
SheetFooter,
|
||||
|
||||
@@ -346,6 +346,7 @@ requires_openai_auth = true`;
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
trackUnsavedChanges
|
||||
title={
|
||||
isEditMode
|
||||
? t("universalProvider.edit", { defaultValue: "编辑统一供应商" })
|
||||
|
||||
@@ -88,6 +88,7 @@ export function PricingEditModal({
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={open}
|
||||
trackUnsavedChanges
|
||||
title={
|
||||
isNew
|
||||
? t("usage.addPricing", "新增定价")
|
||||
|
||||
@@ -17,9 +17,13 @@ export function useAllMcpServers() {
|
||||
/**
|
||||
* 添加或更新 MCP 服务器
|
||||
*/
|
||||
/** 编辑页保存用的 mutation key:页面据此在保存进行中锁住导航 */
|
||||
export const MCP_UPSERT_MUTATION_KEY = ["mcp", "upsert"] as const;
|
||||
|
||||
export function useUpsertMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: MCP_UPSERT_MUTATION_KEY,
|
||||
mutationFn: (server: McpServer) => 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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "ここのスイッチで各アプリの設定を書き直す"
|
||||
},
|
||||
|
||||
@@ -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": "按這裡的開關重寫各應用設定"
|
||||
},
|
||||
|
||||
@@ -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": "按这里的开关重写各应用配置"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { SyntheticEvent } from "react";
|
||||
|
||||
/**
|
||||
* 整页编辑器的「未保存改动」登记。
|
||||
*
|
||||
* 编辑器只盖住内容区,侧栏、托盘、⌘, 仍能离开当前页,离开会卸载编辑器、丢掉草稿。
|
||||
* 编辑器把用户动过表单登记在这里,外壳的导航入口离开前先问一句。
|
||||
* 保存成功或关闭后编辑器卸载,登记随之撤销。
|
||||
*/
|
||||
const dirtyEditors = new Set<object>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
<FullScreenPanel isOpen title={title} onClose={() => undefined}>
|
||||
body
|
||||
</FullScreenPanel>,
|
||||
);
|
||||
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(
|
||||
<FullScreenPanel isOpen title="Edit MCP" onClose={() => undefined}>
|
||||
body
|
||||
</FullScreenPanel>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<void>(() => {}),
|
||||
});
|
||||
return (
|
||||
<button type="button" onClick={() => save.mutate()}>
|
||||
start-native-save
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const onNavigationBlockedChange = vi.fn();
|
||||
renderWithClient(
|
||||
<>
|
||||
<PromptPanel
|
||||
appId="pi"
|
||||
apps={["claude", "pi"]}
|
||||
onAppChange={vi.fn()}
|
||||
onNavigationBlockedChange={onNavigationBlockedChange}
|
||||
/>
|
||||
<PendingNativeSave />
|
||||
</>,
|
||||
);
|
||||
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(
|
||||
<PromptPanel appId="pi" apps={["pi"]} onAppChange={vi.fn()} />,
|
||||
|
||||
@@ -96,7 +96,6 @@ describe("PromptFormPanel", () => {
|
||||
});
|
||||
|
||||
it("names the overwritten file on the save button of the enabled prompt", () => {
|
||||
const onDelete = vi.fn();
|
||||
render(
|
||||
<PromptFormPanel
|
||||
appId="hermes"
|
||||
@@ -104,17 +103,16 @@ describe("PromptFormPanel", () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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<void | boolean>;
|
||||
onClose: () => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}) => (
|
||||
<div data-testid="prompt-form">
|
||||
{editingId}:{initialData?.name}
|
||||
@@ -111,11 +109,6 @@ vi.mock("@/components/prompts/PromptFormPanel", async (importOriginal) => {
|
||||
<button type="button" onClick={onClose}>
|
||||
form-close
|
||||
</button>
|
||||
{editingId && onDelete ? (
|
||||
<button type="button" onClick={() => onDelete(editingId)}>
|
||||
form-delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
<Sheet open onOpenChange={onOpenChange}>
|
||||
<SheetPageContent title="Edit prompt" closeLabel="back">
|
||||
body
|
||||
</SheetPageContent>
|
||||
</Sheet>,
|
||||
);
|
||||
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(
|
||||
<>
|
||||
<button type="button" onClick={onOutside}>
|
||||
sidebar
|
||||
</button>
|
||||
<Sheet open modal={false} onOpenChange={onOpenChange}>
|
||||
<SheetPageContent title="Edit MCP" closeLabel="back">
|
||||
body
|
||||
</SheetPageContent>
|
||||
</Sheet>
|
||||
</>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(<UnifiedMcpPanel onInteractionBlockedChange={onBlocked} />);
|
||||
const renderPanel = (
|
||||
onBlocked?: (blocked: boolean) => void,
|
||||
extra?: React.ReactNode,
|
||||
) =>
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<UnifiedMcpPanel onInteractionBlockedChange={onBlocked} />
|
||||
{extra}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
/** 模拟编辑页发起、一直没完成的保存 */
|
||||
function PendingEditorSave() {
|
||||
const save = useMutation({
|
||||
mutationKey: ["mcp", "upsert"],
|
||||
mutationFn: () => new Promise<void>(() => {}),
|
||||
});
|
||||
return (
|
||||
<button type="button" onClick={() => save.mutate()}>
|
||||
start-editor-save
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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, <PendingEditorSave />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -93,27 +93,35 @@ vi.mock("@/components/providers/AddProviderDialog", () => ({
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/EditProviderDialog", () => ({
|
||||
EditProviderDialog: ({ open, provider, onSubmit, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="edit-provider-dialog">
|
||||
<button
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
provider: {
|
||||
...provider,
|
||||
name: `${provider.name}-edited`,
|
||||
},
|
||||
originalId: provider.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
confirm-edit
|
||||
</button>
|
||||
<button onClick={() => onOpenChange(false)}>close-edit</button>
|
||||
</div>
|
||||
) : 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) => (
|
||||
<div data-testid="edit-provider-dialog" {...useUnsavedChangesTracker()}>
|
||||
<input aria-label="edit-field" />
|
||||
<button
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
provider: {
|
||||
...provider,
|
||||
name: `${provider.name}-edited`,
|
||||
},
|
||||
originalId: provider.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
confirm-edit
|
||||
</button>
|
||||
<button onClick={() => onOpenChange(false)}>close-edit</button>
|
||||
</div>
|
||||
);
|
||||
return {
|
||||
EditProviderDialog: (props: any) =>
|
||||
props.open ? <Body {...props} /> : 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);
|
||||
|
||||
@@ -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 (
|
||||
<div {...useUnsavedChangesTracker(enabled)}>
|
||||
<input aria-label="name" />
|
||||
<button type="button" role="checkbox" aria-checked="false">
|
||||
claude
|
||||
</button>
|
||||
<button type="button" aria-pressed="false" data-unsaved-ignore>
|
||||
reveal
|
||||
</button>
|
||||
<button type="button">expand</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("unsaved changes tracker", () => {
|
||||
afterEach(() => discardUnsavedChanges());
|
||||
|
||||
it("registers typing and edit controls, not view toggles or plain buttons", () => {
|
||||
render(<Editor />);
|
||||
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(<Editor />);
|
||||
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(<Editor enabled={false} />);
|
||||
fireEvent.input(screen.getByRole("textbox", { name: "name" }));
|
||||
expect(hasUnsavedChanges()).toBe(false);
|
||||
|
||||
view.rerender(<Editor />);
|
||||
fireEvent.input(screen.getByRole("textbox", { name: "name" }));
|
||||
expect(hasUnsavedChanges()).toBe(true);
|
||||
view.rerender(<Editor enabled={false} />);
|
||||
expect(hasUnsavedChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks FullScreenPanel only when asked and only while open", () => {
|
||||
const panel = (props: { open: boolean; track: boolean }) => (
|
||||
<FullScreenPanel
|
||||
isOpen={props.open}
|
||||
title="Edit"
|
||||
onClose={() => undefined}
|
||||
trackUnsavedChanges={props.track}
|
||||
>
|
||||
<input aria-label="field" />
|
||||
</FullScreenPanel>
|
||||
);
|
||||
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(
|
||||
<Sheet open modal={false}>
|
||||
<SheetPageContent title="Edit MCP" closeLabel="back">
|
||||
<textarea aria-label="body" />
|
||||
</SheetPageContent>
|
||||
</Sheet>,
|
||||
);
|
||||
fireEvent.input(screen.getByRole("textbox", { name: "body" }));
|
||||
expect(hasUnsavedChanges()).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user