Explorar o código

Add confirmation dialog for discarding changes.

System233 hai 10 meses
pai
achega
1fcdd33339

+ 0 - 3
webview-ui/src/components/settings/ApiConfigManager.tsx

@@ -299,9 +299,6 @@ const ApiConfigManager = ({
 					aria-labelledby="new-profile-title">
 					<DialogContent className="p-4 max-w-sm">
 						<DialogTitle>New Configuration Profile</DialogTitle>
-						<button className="absolute right-4 top-4" aria-label="Close dialog" onClick={resetCreateState}>
-							<span className="codicon codicon-close" />
-						</button>
 						<VSCodeTextField
 							ref={newProfileInputRef}
 							value={newProfileName}

+ 52 - 14
webview-ui/src/components/settings/SettingsView.tsx

@@ -5,11 +5,12 @@ import { validateApiConfiguration, validateModelId } from "../../utils/validate"
 import { vscode } from "../../utils/vscode"
 import ApiOptions from "./ApiOptions"
 import ExperimentalFeature from "./ExperimentalFeature"
-import { EXPERIMENT_IDS, experimentConfigsMap } from "../../../../src/shared/experiments"
+import { EXPERIMENT_IDS, experimentConfigsMap, ExperimentId } from "../../../../src/shared/experiments"
 import ApiConfigManager from "./ApiConfigManager"
 import { Dropdown } from "vscrui"
 import type { DropdownOption } from "vscrui"
 import { ApiConfiguration } from "../../../../src/shared/api"
+import ConfirmDialog from "../ui/comfirm-dialog"
 
 type SettingsViewProps = {
 	onDone: () => void
@@ -21,6 +22,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 	const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
 	const [commandInput, setCommandInput] = useState("")
 	const prevApiConfigName = useRef(extensionState.currentApiConfigName)
+	const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
 
 	// TODO: Reduce WebviewMessage/ExtensionState complexity
 	const [cachedState, setCachedState] = useState(extensionState)
@@ -67,7 +69,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 	}, [currentApiConfigName, extensionState, isChangeDetected])
 
 	const setCachedStateField = useCallback(
-		<K extends keyof ExtensionStateContextType>(field: K, value: ExtensionStateContextType[K]) =>
+		<K extends keyof ExtensionStateContextType>(field: K, value: ExtensionStateContextType[K]) => {
 			setCachedState((prevState) => {
 				if (prevState[field] === value) {
 					return prevState
@@ -77,7 +79,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 					...prevState,
 					[field]: value,
 				}
-			}),
+			})
+		},
 		[],
 	)
 
@@ -91,20 +94,27 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 				return {
 					...prevState,
 					apiConfiguration: {
-						...apiConfiguration,
+						...prevState.apiConfiguration,
 						[field]: value,
 					},
 				}
 			})
 		},
-		[apiConfiguration],
+		[],
 	)
 
-	const setExperimentEnabled = useCallback(
-		(id: string, enabled: boolean) =>
-			setCachedStateField("experiments", { ...cachedState.experiments, [id]: enabled }),
-		[cachedState.experiments, setCachedStateField],
-	)
+	const setExperimentEnabled = useCallback((id: ExperimentId, enabled: boolean) => {
+		setCachedState((prevState) => {
+			if (prevState.experiments?.[id] === enabled) {
+				return prevState
+			}
+			setChangeDetected(true)
+			return {
+				...prevState,
+				experiments: { ...prevState.experiments, [id]: enabled },
+			}
+		})
+	}, [])
 
 	const handleSubmit = () => {
 		const apiValidationResult = validateApiConfiguration(apiConfiguration)
@@ -171,6 +181,24 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 		setModelIdErrorMessage(modelIdValidationResult)
 	}, [apiConfiguration, extensionState.glamaModels, extensionState.openRouterModels])
 
+	const confirmDialogHandler = useRef<() => void>()
+	const onConfirmDialogResult = useCallback((confirm: boolean) => {
+		if (confirm) {
+			confirmDialogHandler.current?.()
+		}
+	}, [])
+	const checkUnsaveChanges = useCallback(
+		(then: () => void) => {
+			if (isChangeDetected) {
+				confirmDialogHandler.current = then
+				setDiscardDialogShow(true)
+			} else {
+				then()
+			}
+		},
+		[isChangeDetected],
+	)
+
 	const handleResetState = () => {
 		vscode.postMessage({ type: "resetState" })
 	}
@@ -215,6 +243,14 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 				flexDirection: "column",
 				overflow: "hidden",
 			}}>
+			<ConfirmDialog
+				icon="codicon-warning"
+				title="Unsaved changes"
+				message="Do you want to discard changes and continue?"
+				show={isDiscardDialogShow}
+				onResult={onConfirmDialogResult}
+				onClose={() => setDiscardDialogShow(false)}
+				aria-labelledby="unsave-warning-dialog"></ConfirmDialog>
 			<div
 				style={{
 					display: "flex",
@@ -240,7 +276,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 					<VSCodeButton
 						appearance="secondary"
 						title="Discard unsaved changes and close settings panel"
-						onClick={onDone}>
+						onClick={() => checkUnsaveChanges(onDone)}>
 						Done
 					</VSCodeButton>
 				</div>
@@ -254,9 +290,11 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 							currentApiConfigName={currentApiConfigName}
 							listApiConfigMeta={extensionState.listApiConfigMeta}
 							onSelectConfig={(configName: string) => {
-								vscode.postMessage({
-									type: "loadApiConfiguration",
-									text: configName,
+								checkUnsaveChanges(() => {
+									vscode.postMessage({
+										type: "loadApiConfiguration",
+										text: configName,
+									})
 								})
 							}}
 							onDeleteConfig={(configName: string) => {

+ 58 - 0
webview-ui/src/components/ui/comfirm-dialog.tsx

@@ -0,0 +1,58 @@
+import { Dialog, DialogContent, DialogTitle } from "./dialog"
+import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
+import { useCallback } from "react"
+
+export interface ConfirmDialogProps {
+	show: boolean
+	icon: string
+	title?: string
+	message: string
+	onResult: (confirm: boolean) => void
+	onClose: () => void
+}
+export const ConfirmDialog = ({ onResult, onClose, icon, show, title, message }: ConfirmDialogProps) => {
+	const onCloseConfirmDialog = useCallback(
+		(confirm: boolean) => {
+			onResult(confirm)
+			onClose()
+		},
+		[onClose, onResult],
+	)
+	return (
+		<Dialog
+			open={show}
+			onOpenChange={(open) => {
+				!open && onCloseConfirmDialog(false)
+			}}
+			aria-labelledby="unsave-warning-dialog">
+			<DialogContent className="p-4 max-w-sm">
+				<DialogTitle>{title}</DialogTitle>
+				<p className="text-lg mt-2" data-testid="error-message">
+					<span
+						style={{ fontSize: "2em" }}
+						className={`codicon align-middle mr-1 ${icon || "codicon-warning"}`}
+					/>
+					<span>{message}</span>
+				</p>
+				<div className="flex justify-end gap-2 mt-4">
+					<VSCodeButton
+						appearance="primary"
+						onClick={() => {
+							onCloseConfirmDialog(true)
+						}}>
+						Yes
+					</VSCodeButton>
+					<VSCodeButton
+						appearance="secondary"
+						onClick={() => {
+							onCloseConfirmDialog(false)
+						}}>
+						No
+					</VSCodeButton>
+				</div>
+			</DialogContent>
+		</Dialog>
+	)
+}
+
+export default ConfirmDialog

+ 2 - 2
webview-ui/src/components/ui/dialog.tsx

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
 	<DialogPrimitive.Overlay
 		ref={ref}
 		className={cn(
-			"fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+			"fixed inset-0 z-50 bg-black/50  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
 			className,
 		)}
 		{...props}
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
 			)}
 			{...props}>
 			{children}
-			<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+			<DialogPrimitive.Close className="cursor-pointer absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
 				<Cross2Icon className="h-4 w-4" />
 				<span className="sr-only">Close</span>
 			</DialogPrimitive.Close>