Skip to content

Commit 27982f9

Browse files
fix(ui): make the settings save all-or-nothing and test appearance persistence
1 parent dc8880c commit 27982f9

28 files changed

Lines changed: 567 additions & 30 deletions

‎packages/types/src/vscode-extension-host.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface ExtensionMessage {
5050
| typeof VsCodeLmModelsMessageType.vsCodeLmModels
5151
| typeof VsCodeLmModelsMessageType.githubCopilotModels
5252
| typeof VsCodeLmModelsMessageType.githubCopilotSignInResult
53+
| "settingsSaveResult"
5354
| "vsCodeLmApiAvailable"
5455
| "updatePrompt"
5556
| "systemPrompt"

‎src/core/webview/__tests__/webviewMessageHandler.spec.ts‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1905,6 +1905,146 @@ describe("webviewMessageHandler - mcpEnabled", () => {
19051905
})
19061906
})
19071907

1908+
describe("webviewMessageHandler - updateSettings is all-or-nothing", () => {
1909+
const stored: Record<string, unknown> = {}
1910+
1911+
beforeEach(() => {
1912+
vi.clearAllMocks()
1913+
for (const key of Object.keys(stored)) delete stored[key]
1914+
Object.assign(stored, { soundEnabled: false, writeDelayMs: 1000, language: "en" })
1915+
vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => stored[key] as never)
1916+
vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => {
1917+
stored[key] = value
1918+
})
1919+
})
1920+
1921+
it("stores every setting and confirms the save by request id", async () => {
1922+
await webviewMessageHandler(mockClineProvider, {
1923+
type: "updateSettings",
1924+
requestId: "save-1",
1925+
updatedSettings: { soundEnabled: true, writeDelayMs: 2000 },
1926+
})
1927+
1928+
expect(stored).toMatchObject({ soundEnabled: true, writeDelayMs: 2000 })
1929+
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
1930+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
1931+
type: "settingsSaveResult",
1932+
requestId: "save-1",
1933+
success: true,
1934+
})
1935+
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
1936+
})
1937+
1938+
it("restores the settings already written when a later one fails, and reports the failure", async () => {
1939+
const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()!
1940+
vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => {
1941+
if (key === "writeDelayMs" && value === 2000) throw new Error("storage unavailable")
1942+
return real(key as never, value as never)
1943+
})
1944+
1945+
await webviewMessageHandler(mockClineProvider, {
1946+
type: "updateSettings",
1947+
requestId: "save-2",
1948+
updatedSettings: { soundEnabled: true, writeDelayMs: 2000 },
1949+
})
1950+
1951+
// soundEnabled was written first, then rolled back; writeDelayMs never changed.
1952+
expect(stored).toMatchObject({ soundEnabled: false, writeDelayMs: 1000 })
1953+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
1954+
type: "settingsSaveResult",
1955+
requestId: "save-2",
1956+
success: false,
1957+
error: "storage unavailable",
1958+
})
1959+
expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith(
1960+
expect.objectContaining({ success: true }),
1961+
)
1962+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.settings_save_failed")
1963+
expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("rolled back"))
1964+
// The webview is re-synced with what is really stored.
1965+
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
1966+
})
1967+
1968+
it("rolls back in reverse order, so the first-written value is restored last", async () => {
1969+
const order: string[] = []
1970+
const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()!
1971+
vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => {
1972+
order.push(`${key}=${String(value)}`)
1973+
if (key === "terminalCommandDelay" && value === 50) throw new Error("boom")
1974+
return real(key as never, value as never)
1975+
})
1976+
stored.terminalCommandDelay = 0
1977+
1978+
await webviewMessageHandler(mockClineProvider, {
1979+
type: "updateSettings",
1980+
requestId: "save-3",
1981+
updatedSettings: { soundEnabled: true, writeDelayMs: 2000, terminalCommandDelay: 50 },
1982+
})
1983+
1984+
expect(stored).toMatchObject({ soundEnabled: false, writeDelayMs: 1000, terminalCommandDelay: 0 })
1985+
1986+
// Writes go forward in order; restores come back in the opposite order.
1987+
const failedAt = order.indexOf("terminalCommandDelay=50")
1988+
const restoreOrder = order
1989+
.slice(failedAt + 1)
1990+
.map((entry) => entry.split("=")[0])
1991+
.filter((key, index, all) => all.indexOf(key) === index)
1992+
expect(restoreOrder.indexOf("writeDelayMs")).toBeGreaterThanOrEqual(0)
1993+
expect(restoreOrder.indexOf("soundEnabled")).toBeGreaterThan(restoreOrder.indexOf("writeDelayMs"))
1994+
})
1995+
1996+
it("keeps going and still reports the failure when a restore itself fails", async () => {
1997+
const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()!
1998+
vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => {
1999+
if (key === "writeDelayMs") throw new Error("writeDelayMs is broken")
2000+
if (key === "soundEnabled" && value === false) throw new Error("cannot restore")
2001+
return real(key as never, value as never)
2002+
})
2003+
2004+
await webviewMessageHandler(mockClineProvider, {
2005+
type: "updateSettings",
2006+
requestId: "save-4",
2007+
updatedSettings: { soundEnabled: true, writeDelayMs: 2000 },
2008+
})
2009+
2010+
expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Could not restore soundEnabled"))
2011+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith(
2012+
expect.objectContaining({ type: "settingsSaveResult", requestId: "save-4", success: false }),
2013+
)
2014+
})
2015+
2016+
it("reports a non-Error failure by its text", async () => {
2017+
vi.mocked(mockClineProvider.contextProxy.setValue).mockRejectedValue("plain string failure")
2018+
2019+
await webviewMessageHandler(mockClineProvider, {
2020+
type: "updateSettings",
2021+
requestId: "save-5",
2022+
updatedSettings: { soundEnabled: true },
2023+
})
2024+
2025+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith(
2026+
expect.objectContaining({ success: false, error: "plain string failure" }),
2027+
)
2028+
})
2029+
2030+
it("still saves and re-syncs, without a result message, when the request has no id", async () => {
2031+
await webviewMessageHandler(mockClineProvider, {
2032+
type: "updateSettings",
2033+
updatedSettings: { soundEnabled: true },
2034+
})
2035+
2036+
expect(stored.soundEnabled).toBe(true)
2037+
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
2038+
expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled()
2039+
})
2040+
2041+
it("does nothing when there are no settings to save", async () => {
2042+
await webviewMessageHandler(mockClineProvider, { type: "updateSettings", requestId: "save-6" })
2043+
expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled()
2044+
expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled()
2045+
})
2046+
})
2047+
19082048
describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => {
19092049
beforeEach(() => {
19102050
vi.clearAllMocks()

‎src/core/webview/webviewMessageHandler.ts‎

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -737,18 +737,20 @@ export const webviewMessageHandler = async (
737737
}
738738
break
739739

740-
case "updateSettings":
741-
if (message.updatedSettings) {
742-
if (message.updatedSettings.destructiveCommandGuardEnabled === true) {
740+
case "updateSettings": {
741+
const updatedSettings = message.updatedSettings
742+
const saveRequestId = message.requestId
743+
if (updatedSettings) {
744+
if (updatedSettings.destructiveCommandGuardEnabled === true) {
743745
try {
744746
const { ensureDcgInstalled } = await import("../../services/destructive-command-guard")
745747
const binaryPath = await ensureDcgInstalled(provider.context.globalStorageUri.fsPath)
746748
if (!binaryPath) {
747-
message.updatedSettings.destructiveCommandGuardEnabled = false
749+
updatedSettings.destructiveCommandGuardEnabled = false
748750
vscode.window.showErrorMessage(t("common:errors.destructiveCommandGuard.unavailable"))
749751
}
750752
} catch (error) {
751-
message.updatedSettings.destructiveCommandGuardEnabled = false
753+
updatedSettings.destructiveCommandGuardEnabled = false
752754
vscode.window.showErrorMessage(
753755
t("common:errors.destructiveCommandGuard.enableFailed", {
754756
error: error instanceof Error ? error.message : String(error),
@@ -757,7 +759,8 @@ export const webviewMessageHandler = async (
757759
}
758760
}
759761

760-
for (const [key, value] of Object.entries(message.updatedSettings)) {
762+
/** Applies one setting (its side effect, then the stored value). Returns whether a value was stored. */
763+
const applySetting = async (key: string, value: unknown): Promise<boolean> => {
761764
let newValue = value
762765

763766
if (key === "language") {
@@ -854,7 +857,7 @@ export const webviewMessageHandler = async (
854857
}
855858
} else if (key === "experiments") {
856859
if (!value) {
857-
continue
860+
return false
858861
}
859862

860863
newValue = {
@@ -863,17 +866,60 @@ export const webviewMessageHandler = async (
863866
}
864867
} else if (key === "customSupportPrompts") {
865868
if (!value) {
866-
continue
869+
return false
867870
}
868871
}
869872

870-
await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue)
873+
await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue as never)
874+
return true
871875
}
872876

873-
await provider.postStateToWebview()
874-
}
877+
// Every key is snapshotted before it is written. If any write fails, the ones already applied are
878+
// restored (stored value and side effect), so a save is all-or-nothing and the webview can keep
879+
// the user's edits and let them retry.
880+
const written: Array<{ key: string; previous: unknown }> = []
881+
try {
882+
for (const [key, value] of Object.entries(updatedSettings)) {
883+
written.push({ key, previous: provider.contextProxy.getValue(key as keyof RooCodeSettings) })
884+
await applySetting(key, value)
885+
}
875886

887+
await provider.postStateToWebview()
888+
if (saveRequestId) {
889+
await provider.postMessageToWebview({
890+
type: "settingsSaveResult",
891+
requestId: saveRequestId,
892+
success: true,
893+
})
894+
}
895+
} catch (error) {
896+
const reason = error instanceof Error ? error.message : String(error)
897+
for (const { key, previous } of written.reverse()) {
898+
try {
899+
await applySetting(key, previous)
900+
// Put back exactly what was stored, whatever defaulting the side effect applied.
901+
await provider.contextProxy.setValue(key as keyof RooCodeSettings, previous as never)
902+
} catch (rollbackError) {
903+
provider.log(
904+
`[updateSettings] Could not restore ${key}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
905+
)
906+
}
907+
}
908+
provider.log(`[updateSettings] Save failed and was rolled back: ${reason}`)
909+
vscode.window.showErrorMessage(t("common:errors.settings_save_failed", { error: reason }))
910+
await provider.postStateToWebview()
911+
if (saveRequestId) {
912+
await provider.postMessageToWebview({
913+
type: "settingsSaveResult",
914+
requestId: saveRequestId,
915+
success: false,
916+
error: reason,
917+
})
918+
}
919+
}
920+
}
876921
break
922+
}
877923

878924
case "terminalOperation":
879925
if (message.terminalOperation) {

‎src/i18n/locales/ca/common.json‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/i18n/locales/de/common.json‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/i18n/locales/en/common.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"cannot_access_path": "Cannot access path {{path}}: {{error}}",
5959
"path_outside_workspace": "Path is outside the workspace",
6060
"settings_import_failed": "Settings import failed: {{error}}.",
61+
"settings_save_failed": "Settings could not be saved and were restored to their previous values: {{error}}",
6162
"mistake_limit_guidance": "This may indicate a failure in the model's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. \"Try breaking down the task into smaller steps\").",
6263
"violated_organization_allowlist": "Failed to run task: the current profile isn't compatible with your organization settings",
6364
"condense_failed": "Failed to condense context",

‎src/i18n/locales/es/common.json‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/i18n/locales/fr/common.json‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/i18n/locales/hi/common.json‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/i18n/locales/id/common.json‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)