fix(settings): Apply cannot keep a later Save open, refreshes only after a saved PUT (review)

- Split the one-shot keep-open intent from the in-flight guard and consume it before the
  first await, so a Save clicked while an Apply is in flight closes the modal.
- Refresh the dependent groups only when the settings PUT returned ok (_apiPut answers null
  or a non-ok response instead of throwing), and also when only the webhook save failed.
- Find the 'Apply or Save' hint by a data marker, not its English text; add zh-CN strings
  for the new toast and title.
- Tests run the real saveAppSettings() (Apply then Save mid-flight, a failed PUT, a webhook-only
  failure, a plain Save).
- Narrow the changeset and JSDoc to MCP sync and CLI management; touch the tray comment, the
  architecture note and the wiki.
This commit is contained in:
Devvyn committed 2026-10-09 19:46:09 +08:00
1 parent 9c63c41283
commit c75d62ce04
7 files changed
+157 -31

No files matched your search

+2
View File
@@ -718,6 +718,8 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
+1 -1
View File
@@ -3552,7 +3552,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
}
/* Save + Close are the two ways out of the sheet (save-and-close vs
discard-and-close), hit in the same corner with the same thumb, so here —
discard-and-close; Apply saves but keeps the sheet open), hit in the same corner with the same thumb, so here —
and only here, since Save is header-only below 860px — they share a
recessed tray and matching pill geometry instead of reading as a fat
accent pill parked beside a stray × glyph. Tray colors come from skin
+29 -16
View File
@@ -1220,12 +1220,12 @@ Object.assign(CodemanApp.prototype, {
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
const show = (html) => {
if (out) { out.style.display = 'block'; out.innerHTML = html; }
const show = (html, hint = '') => {
if (out) { out.style.display = 'block'; out.innerHTML = html; out.dataset.hint = hint; }
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
show('Apply or Save settings to turn MCP sync on first, then preview or sync.');
show('Apply or Save settings to turn MCP sync on first, then preview or sync.', 'save-first');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
@@ -2507,24 +2507,30 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Apply button: the same save as Save, but the modal stays open and the groups that depend on
* a saved value (MCP sync, custom model endpoints, CLI management) are refreshed in place, so
* a switch that unlocks more settings needs no close-and-reopen. It is a wrapper rather than
* an option on saveAppSettings() so that function's signature (which tests locate by text)
* stays as it was.
* Apply button: the same save as Save, but the modal stays open and the MCP sync group (its
* Preview and Sync need the saved flag) and the CLI management writes are refreshed in place, so
* turning either on needs no close-and-reopen. It is a wrapper rather than an option on
* saveAppSettings() so that function's signature (which tests locate by text) stays as it was.
*
* `_keepSettingsOpenOnce` is the one-shot intent and `_applyInFlight` the double-click guard:
* saveAppSettings() consumes the intent before its first await, so a Save clicked while an Apply
* is still in flight is an ordinary Save and closes the modal.
*/
async applyAppSettings() {
if (this._applyingSettings) return;
this._applyingSettings = true;
if (this._applyInFlight) return;
this._applyInFlight = true;
this._keepSettingsOpenOnce = true;
try {
await this.saveAppSettings();
} finally {
this._applyingSettings = false;
this._applyInFlight = false;
this._keepSettingsOpenOnce = false;
}
},
async saveAppSettings() {
const keepOpen = this._applyingSettings === true;
const keepOpen = this._keepSettingsOpenOnce === true;
this._keepSettingsOpenOnce = false;
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prev = this.loadAppSettingsFromStorage();
@@ -2867,6 +2873,7 @@ Object.assign(CodemanApp.prototype, {
...serverSettings
} = settings;
let webhookError = '';
let serverSaved = false;
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
@@ -2888,6 +2895,10 @@ Object.assign(CodemanApp.prototype, {
return;
}
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
// evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok;
// Save model configuration separately
await this.saveModelConfigFromSettings();
@@ -2911,11 +2922,13 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Settings saved locally', 'warning');
}
// Only when the settings PUT landed: after a 400 or a dropped connection the server still has the
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
} else if (keepOpen) {
this._refreshSettingsAfterApply(settings);
} else {
} else if (!keepOpen) {
this.closeAppSettings();
}
@@ -2945,7 +2958,7 @@ Object.assign(CodemanApp.prototype, {
// The MCP routes read the saved flag, so switching it on is only usable from now.
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
const out = this.$('mcpSyncResult');
if (this._mcpSyncSavedOn && out && out.textContent.startsWith('Apply or Save settings')) {
if (this._mcpSyncSavedOn && out && out.dataset?.hint === 'save-first') {
out.style.display = 'none';
out.innerHTML = '';
}