mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-08 22:21:23 +00:00
feat: replace unsaved changes dialog with auto-save system
- Remove disruptive "Unsaved Changes" confirmation dialog - Implement automatic saving with 500ms debouncing - Add visual status indicators (orange for saving, green for saved, red for errors) - Prevent profile switching during active save operations - Update translation strings to reflect new behavior - Simplify checkUnsaveChanges to work with auto-save - Background saves continue even when closing settings panel Fixes #7599
This commit is contained in:
parent
2e59347a9d
commit
ec14092c38
2 changed files with 169 additions and 102 deletions
|
|
@ -18,12 +18,13 @@ import {
|
|||
Database,
|
||||
SquareTerminal,
|
||||
FlaskConical,
|
||||
AlertTriangle,
|
||||
Globe,
|
||||
Info,
|
||||
MessageSquare,
|
||||
LucideIcon,
|
||||
Circle,
|
||||
} from "lucide-react"
|
||||
import { useDebounce } from "react-use"
|
||||
|
||||
import type { ProviderSettings, ExperimentId, TelemetrySetting } from "@roo-code/types"
|
||||
|
||||
|
|
@ -31,22 +32,7 @@ import { vscode } from "@src/utils/vscode"
|
|||
import { cn } from "@src/lib/utils"
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import { ExtensionStateContextType, useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
StandardTooltip,
|
||||
} from "@src/components/ui"
|
||||
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, StandardTooltip } from "@src/components/ui"
|
||||
|
||||
import { Tab, TabContent, TabHeader, TabList, TabTrigger } from "../common/Tab"
|
||||
import { SetCachedStateField, SetExperimentEnabled } from "./types"
|
||||
|
|
@ -103,17 +89,16 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
const extensionState = useExtensionState()
|
||||
const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt } = extensionState
|
||||
|
||||
const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
|
||||
const [isChangeDetected, setChangeDetected] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
|
||||
const [activeTab, setActiveTab] = useState<SectionName>(
|
||||
targetSection && sectionNames.includes(targetSection as SectionName)
|
||||
? (targetSection as SectionName)
|
||||
: "providers",
|
||||
)
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
|
||||
const [saveError, setSaveError] = useState<string | undefined>(undefined)
|
||||
|
||||
const prevApiConfigName = useRef(currentApiConfigName)
|
||||
const confirmDialogHandler = useRef<() => void>()
|
||||
|
||||
const [cachedState, setCachedState] = useState(extensionState)
|
||||
|
||||
|
|
@ -196,14 +181,14 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
|
||||
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
|
||||
prevApiConfigName.current = currentApiConfigName
|
||||
setChangeDetected(false)
|
||||
}, [currentApiConfigName, extensionState, isChangeDetected])
|
||||
setSaveStatus("idle")
|
||||
}, [currentApiConfigName, extensionState])
|
||||
|
||||
// Bust the cache when settings are imported.
|
||||
useEffect(() => {
|
||||
if (settingsImportedAt) {
|
||||
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
|
||||
setChangeDetected(false)
|
||||
setSaveStatus("idle")
|
||||
}
|
||||
}, [settingsImportedAt, extensionState])
|
||||
|
||||
|
|
@ -213,7 +198,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
return prevState
|
||||
}
|
||||
|
||||
setChangeDetected(true)
|
||||
setSaveStatus("idle") // Reset to idle when changes are made
|
||||
return { ...prevState, [field]: value }
|
||||
})
|
||||
}, [])
|
||||
|
|
@ -232,7 +217,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
const isInitialSync = !isUserAction && previousValue === undefined && value !== undefined
|
||||
|
||||
if (!isInitialSync) {
|
||||
setChangeDetected(true)
|
||||
setSaveStatus("idle") // Reset to idle when changes are made
|
||||
}
|
||||
return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } }
|
||||
})
|
||||
|
|
@ -246,7 +231,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
return prevState
|
||||
}
|
||||
|
||||
setChangeDetected(true)
|
||||
setSaveStatus("idle") // Reset to idle when changes are made
|
||||
return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } }
|
||||
})
|
||||
}, [])
|
||||
|
|
@ -257,21 +242,21 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
return prevState
|
||||
}
|
||||
|
||||
setChangeDetected(true)
|
||||
setSaveStatus("idle") // Reset to idle when changes are made
|
||||
return { ...prevState, telemetrySetting: setting }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setOpenRouterImageApiKey = useCallback((apiKey: string) => {
|
||||
setCachedState((prevState) => {
|
||||
setChangeDetected(true)
|
||||
setSaveStatus("idle") // Reset to idle when changes are made
|
||||
return { ...prevState, openRouterImageApiKey: apiKey }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setImageGenerationSelectedModel = useCallback((model: string) => {
|
||||
setCachedState((prevState) => {
|
||||
setChangeDetected(true)
|
||||
setSaveStatus("idle") // Reset to idle when changes are made
|
||||
return { ...prevState, openRouterImageGenerationSelectedModel: model }
|
||||
})
|
||||
}, [])
|
||||
|
|
@ -282,15 +267,24 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
return prevState
|
||||
}
|
||||
|
||||
setChangeDetected(true)
|
||||
setSaveStatus("idle") // Reset to idle when changes are made
|
||||
return { ...prevState, customSupportPrompts: prompts }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isSettingValid = !errorMessage
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isSettingValid) {
|
||||
const performSave = useCallback(() => {
|
||||
if (!isSettingValid) {
|
||||
setSaveStatus("error")
|
||||
setSaveError(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
setSaveStatus("saving")
|
||||
setSaveError(undefined)
|
||||
|
||||
try {
|
||||
vscode.postMessage({ type: "language", text: language })
|
||||
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
|
||||
vscode.postMessage({
|
||||
|
|
@ -364,44 +358,126 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
type: "openRouterImageGenerationSelectedModel",
|
||||
text: openRouterImageGenerationSelectedModel,
|
||||
})
|
||||
setChangeDetected(false)
|
||||
}
|
||||
}
|
||||
|
||||
setSaveStatus("saved")
|
||||
// Reset to idle after showing saved status for a bit
|
||||
setTimeout(() => {
|
||||
setSaveStatus("idle")
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
setSaveStatus("error")
|
||||
setSaveError(error instanceof Error ? error.message : "Failed to save settings")
|
||||
}
|
||||
}, [
|
||||
isSettingValid,
|
||||
errorMessage,
|
||||
language,
|
||||
alwaysAllowReadOnly,
|
||||
alwaysAllowReadOnlyOutsideWorkspace,
|
||||
alwaysAllowWrite,
|
||||
alwaysAllowWriteOutsideWorkspace,
|
||||
alwaysAllowWriteProtected,
|
||||
alwaysAllowExecute,
|
||||
alwaysAllowBrowser,
|
||||
alwaysAllowMcp,
|
||||
allowedCommands,
|
||||
deniedCommands,
|
||||
allowedMaxRequests,
|
||||
allowedMaxCost,
|
||||
autoCondenseContext,
|
||||
autoCondenseContextPercent,
|
||||
browserToolEnabled,
|
||||
soundEnabled,
|
||||
ttsEnabled,
|
||||
ttsSpeed,
|
||||
soundVolume,
|
||||
diffEnabled,
|
||||
enableCheckpoints,
|
||||
browserViewportSize,
|
||||
remoteBrowserHost,
|
||||
remoteBrowserEnabled,
|
||||
fuzzyMatchThreshold,
|
||||
writeDelayMs,
|
||||
screenshotQuality,
|
||||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
terminalShellIntegrationTimeout,
|
||||
terminalShellIntegrationDisabled,
|
||||
terminalCommandDelay,
|
||||
terminalPowershellCounter,
|
||||
terminalZshClearEolMark,
|
||||
terminalZshOhMy,
|
||||
terminalZshP10k,
|
||||
terminalZdotdir,
|
||||
terminalCompressProgressBar,
|
||||
mcpEnabled,
|
||||
alwaysApproveResubmit,
|
||||
requestDelaySeconds,
|
||||
maxOpenTabsContext,
|
||||
maxWorkspaceFiles,
|
||||
showRooIgnoredFiles,
|
||||
maxReadFileLine,
|
||||
maxImageFileSize,
|
||||
maxTotalImageSize,
|
||||
cachedState.maxConcurrentFileReads,
|
||||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
currentApiConfigName,
|
||||
experiments,
|
||||
alwaysAllowModeSwitch,
|
||||
alwaysAllowSubtasks,
|
||||
alwaysAllowFollowupQuestions,
|
||||
alwaysAllowUpdateTodoList,
|
||||
followupAutoApproveTimeoutMs,
|
||||
condensingApiConfigId,
|
||||
customCondensingPrompt,
|
||||
customSupportPrompts,
|
||||
includeTaskHistoryInEnhance,
|
||||
apiConfiguration,
|
||||
telemetrySetting,
|
||||
profileThresholds,
|
||||
openRouterImageApiKey,
|
||||
openRouterImageGenerationSelectedModel,
|
||||
])
|
||||
|
||||
// Auto-save with debouncing
|
||||
useDebounce(
|
||||
() => {
|
||||
// Only auto-save if we have changes (status is idle) and settings are valid
|
||||
if (saveStatus === "idle" && isSettingValid) {
|
||||
performSave()
|
||||
}
|
||||
},
|
||||
500, // 500ms debounce
|
||||
[saveStatus, isSettingValid, performSave],
|
||||
)
|
||||
|
||||
// Simplified checkUnsaveChanges - just execute the action immediately since we auto-save
|
||||
const checkUnsaveChanges = useCallback(
|
||||
(then: () => void) => {
|
||||
if (isChangeDetected) {
|
||||
confirmDialogHandler.current = then
|
||||
setDiscardDialogShow(true)
|
||||
// If we're currently saving, wait for it to complete
|
||||
if (saveStatus === "saving") {
|
||||
// Wait a bit for save to complete, then execute
|
||||
setTimeout(then, 100)
|
||||
} else {
|
||||
then()
|
||||
}
|
||||
},
|
||||
[isChangeDetected],
|
||||
[saveStatus],
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges])
|
||||
|
||||
const onConfirmDialogResult = useCallback(
|
||||
(confirm: boolean) => {
|
||||
if (confirm) {
|
||||
// Discard changes: Reset state and flag
|
||||
setCachedState(extensionState) // Revert to original state
|
||||
setChangeDetected(false) // Reset change flag
|
||||
confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch)
|
||||
}
|
||||
// If confirm is false (Cancel), do nothing, dialog closes automatically
|
||||
},
|
||||
[extensionState], // Depend on extensionState to get the latest original state
|
||||
)
|
||||
|
||||
// Handle tab changes with unsaved changes check
|
||||
// Handle tab changes - no need to check for unsaved changes anymore
|
||||
const handleTabChange = useCallback(
|
||||
(newTab: SectionName) => {
|
||||
// Directly switch tab without checking for unsaved changes
|
||||
// Prevent tab switching while saving
|
||||
if (saveStatus === "saving") {
|
||||
return
|
||||
}
|
||||
setActiveTab(newTab)
|
||||
},
|
||||
[], // No dependency on isChangeDetected needed anymore
|
||||
[saveStatus],
|
||||
)
|
||||
|
||||
// Store direct DOM element refs for each tab
|
||||
|
|
@ -494,26 +570,32 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
<div className="flex items-center gap-1">
|
||||
<h3 className="text-vscode-foreground m-0">{t("settings:header.title")}</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<StandardTooltip
|
||||
content={
|
||||
!isSettingValid
|
||||
? errorMessage
|
||||
: isChangeDetected
|
||||
? t("settings:header.saveButtonTooltip")
|
||||
: t("settings:header.nothingChangedTooltip")
|
||||
}>
|
||||
<Button
|
||||
variant={isSettingValid ? "default" : "secondary"}
|
||||
className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
|
||||
onClick={handleSubmit}
|
||||
disabled={!isChangeDetected || !isSettingValid}
|
||||
data-testid="save-button">
|
||||
{t("settings:common.save")}
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-1">
|
||||
{saveStatus === "saving" && (
|
||||
<StandardTooltip content={t("settings:status.saving")}>
|
||||
<Circle className="w-3 h-3 fill-orange-500 text-orange-500" />
|
||||
</StandardTooltip>
|
||||
)}
|
||||
{saveStatus === "saved" && (
|
||||
<StandardTooltip content={t("settings:status.saved")}>
|
||||
<Circle className="w-3 h-3 fill-green-500 text-green-500" />
|
||||
</StandardTooltip>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<StandardTooltip content={saveError || t("settings:status.error")}>
|
||||
<Circle className="w-3 h-3 fill-vscode-errorForeground text-vscode-errorForeground" />
|
||||
</StandardTooltip>
|
||||
)}
|
||||
{saveStatus === "idle" && errorMessage && (
|
||||
<StandardTooltip content={errorMessage}>
|
||||
<Circle className="w-3 h-3 fill-gray-500 text-gray-500" />
|
||||
</StandardTooltip>
|
||||
)}
|
||||
</div>
|
||||
<StandardTooltip content={t("settings:header.doneButtonTooltip")}>
|
||||
<Button variant="secondary" onClick={() => checkUnsaveChanges(onDone)}>
|
||||
<Button variant="secondary" onClick={onDone}>
|
||||
{t("settings:common.done")}
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
|
|
@ -594,11 +676,13 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
<ApiConfigManager
|
||||
currentApiConfigName={currentApiConfigName}
|
||||
listApiConfigMeta={listApiConfigMeta}
|
||||
onSelectConfig={(configName: string) =>
|
||||
checkUnsaveChanges(() =>
|
||||
vscode.postMessage({ type: "loadApiConfiguration", text: configName }),
|
||||
)
|
||||
}
|
||||
onSelectConfig={(configName: string) => {
|
||||
// Prevent profile switching while saving
|
||||
if (saveStatus === "saving") {
|
||||
return
|
||||
}
|
||||
vscode.postMessage({ type: "loadApiConfiguration", text: configName })
|
||||
}}
|
||||
onDeleteConfig={(configName: string) =>
|
||||
vscode.postMessage({ type: "deleteApiConfiguration", text: configName })
|
||||
}
|
||||
|
|
@ -764,28 +848,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
)}
|
||||
</TabContent>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500" />
|
||||
{t("settings:unsavedChangesDialog.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings:unsavedChangesDialog.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => onConfirmDialogResult(false)}>
|
||||
{t("settings:unsavedChangesDialog.cancelButton")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onConfirmDialogResult(true)}>
|
||||
{t("settings:unsavedChangesDialog.discardButton")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Tab>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@
|
|||
"title": "Settings",
|
||||
"saveButtonTooltip": "Save changes",
|
||||
"nothingChangedTooltip": "Nothing changed",
|
||||
"doneButtonTooltip": "Discard unsaved changes and close settings panel"
|
||||
"doneButtonTooltip": "Close settings panel"
|
||||
},
|
||||
"status": {
|
||||
"saving": "Saving changes...",
|
||||
"saved": "All changes saved",
|
||||
"error": "Failed to save changes"
|
||||
},
|
||||
"unsavedChangesDialog": {
|
||||
"title": "Unsaved Changes",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue