fix(settings): 热键冲突不再拒绝保存——升级迁移停用冲突默认键 + 保存自动化解(#904) (#907)
* fix(settings): 热键冲突不再拒绝保存——升级迁移停用冲突默认键 + 保存自动化解(#904) #904:1.3.15 新增的选区润色默认键(Windows=右 Alt)在旧配置升级时被 无条件注入。若用户录音键恰为右 Alt,则偏好文件出现持久冲突,之后任何 set_settings(含切「自动」录音方式)都被 reject_hotkey_collisions 整体 拒绝,前端又静默吞错——表现为界面显示已切换、重启后回退按住说话。 修复: - types.rs:旧文件升级时若默认注入的选区润色键与录音键相同,改为停用 新功能而非写入冲突值; - commands/settings.rs:保存兜底 reconcile_hotkey_collisions——热键 冲突/非法不再拒绝整份保存,按核心度自动恢复旧值或停用冲突的非核心键 (录音键永不参与调整),其余设置照常落盘并写日志; - HotkeySettingsContext.tsx:保存失败回滚乐观状态并弹出失败提示,杜绝 「假保存」; - 回归测试:升级迁移停用冲突键、历史冲突下切模式仍可保存、非核心键 重叠/非法自动化解;4 个旧「保存被拒」测试同步改为新兜底语义。 验证:新增测试全过;cargo test --lib 924 通过、2 失败均与改动前基线 一致(cooldown 时序抖动 + Windows 符号链接权限环境测试);前端 tsc --noEmit 与 frontend-test-runner 全过。 * fix(settings): 升级迁移按重叠判定停用冲突的选区润色默认键(#904)
This commit is contained in:
1 parent
d0e190f20c
commit
12de016c32
4 files changed
+303
-36
No files matched your search
@@ -867,7 +867,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_rejects_less_computer_dictation_overlap() {
|
||||
fn persist_settings_reconciles_less_computer_dictation_overlap_and_saves() {
|
||||
let writer = FakeSettingsWriter::default();
|
||||
let binding = ShortcutBinding {
|
||||
primary: "LeftControl".into(),
|
||||
@@ -879,11 +879,11 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
persist_settings(&writer, prefs),
|
||||
Err("Less Computer 快捷键不能和听写快捷键相同".into())
|
||||
);
|
||||
assert!(writer.saved.lock().unwrap().is_none());
|
||||
// 兜底(#904):冲突不再拒绝整份保存,较低优先级的 Less Computer 键被停用。
|
||||
persist_settings(&writer, prefs).unwrap();
|
||||
let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
|
||||
assert_eq!(saved.dictation_hotkey.primary, "LeftControl");
|
||||
assert!(saved.coding_agent_voice_hotkey.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1249,7 +1249,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_rejects_dictation_translation_overlap() {
|
||||
fn persist_settings_reconciles_dictation_translation_overlap_and_saves() {
|
||||
let writer = FakeSettingsWriter::default();
|
||||
let binding = ShortcutBinding {
|
||||
primary: "RightControl".into(),
|
||||
@@ -1261,15 +1261,15 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
persist_settings(&writer, prefs),
|
||||
Err("翻译快捷键不能和听写快捷键相同".into())
|
||||
);
|
||||
assert!(writer.saved.lock().unwrap().is_none());
|
||||
// 兜底(#904):冲突不再拒绝整份保存,翻译键恢复为旧默认 Shift。
|
||||
persist_settings(&writer, prefs).unwrap();
|
||||
let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
|
||||
assert_eq!(saved.dictation_hotkey.primary, "RightControl");
|
||||
assert_eq!(saved.translation_hotkey.primary, "Shift");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_rejects_translation_switch_style_overlap() {
|
||||
fn persist_settings_reconciles_translation_switch_style_overlap_and_saves() {
|
||||
let writer = FakeSettingsWriter::default();
|
||||
let binding = ShortcutBinding {
|
||||
primary: "T".into(),
|
||||
@@ -1281,15 +1281,16 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
persist_settings(&writer, prefs),
|
||||
Err("切换风格快捷键不能和翻译快捷键相同".into())
|
||||
);
|
||||
assert!(writer.saved.lock().unwrap().is_none());
|
||||
// 兜底(#904):冲突不再拒绝整份保存,切换风格键恢复为旧默认。
|
||||
persist_settings(&writer, prefs).unwrap();
|
||||
let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
|
||||
let defaults = UserPreferences::default();
|
||||
assert_eq!(saved.translation_hotkey.primary, "T");
|
||||
assert_eq!(saved.switch_style_hotkey, defaults.switch_style_hotkey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_rejects_switch_style_open_app_overlap() {
|
||||
fn persist_settings_reconciles_switch_style_open_app_overlap_and_saves() {
|
||||
let writer = FakeSettingsWriter::default();
|
||||
let binding = ShortcutBinding {
|
||||
primary: "K".into(),
|
||||
@@ -1297,15 +1298,16 @@ mod tests {
|
||||
};
|
||||
let prefs = UserPreferences {
|
||||
switch_style_hotkey: Some(binding.clone()),
|
||||
open_app_hotkey: Some(binding),
|
||||
open_app_hotkey: Some(binding.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
persist_settings(&writer, prefs),
|
||||
Err("打开应用快捷键不能和切换风格快捷键相同".into())
|
||||
);
|
||||
assert!(writer.saved.lock().unwrap().is_none());
|
||||
// 兜底(#904):冲突不再拒绝整份保存,打开应用键恢复为旧默认。
|
||||
persist_settings(&writer, prefs).unwrap();
|
||||
let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
|
||||
let defaults = UserPreferences::default();
|
||||
assert_eq!(saved.switch_style_hotkey, Some(binding));
|
||||
assert_eq!(saved.open_app_hotkey, defaults.open_app_hotkey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -144,6 +144,114 @@ impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 非核心热键,用于保存兜底的冲突化解。dictation 是核心热键,永不参与调整。
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum NonCoreHotkey {
|
||||
Translation,
|
||||
Qa,
|
||||
SwitchStyle,
|
||||
OpenApp,
|
||||
SelectionPolish,
|
||||
LessComputer,
|
||||
}
|
||||
|
||||
impl NonCoreHotkey {
|
||||
fn get(&self, prefs: &UserPreferences) -> Option<ShortcutBinding> {
|
||||
match self {
|
||||
Self::Translation => Some(prefs.translation_hotkey.clone()),
|
||||
Self::Qa => prefs.qa_hotkey.clone(),
|
||||
Self::SwitchStyle => prefs.switch_style_hotkey.clone(),
|
||||
Self::OpenApp => prefs.open_app_hotkey.clone(),
|
||||
Self::SelectionPolish => prefs.selection_polish_hotkey.clone(),
|
||||
Self::LessComputer => prefs.coding_agent_voice_hotkey.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, prefs: &mut UserPreferences, value: Option<ShortcutBinding>) {
|
||||
match self {
|
||||
// translation 是必填键,None 表示恢复失败时保持旧值不动。
|
||||
Self::Translation => {
|
||||
if let Some(value) = value {
|
||||
prefs.translation_hotkey = value;
|
||||
}
|
||||
}
|
||||
Self::Qa => prefs.qa_hotkey = value,
|
||||
Self::SwitchStyle => prefs.switch_style_hotkey = value,
|
||||
Self::OpenApp => prefs.open_app_hotkey = value,
|
||||
Self::SelectionPolish => prefs.selection_polish_hotkey = value,
|
||||
Self::LessComputer => prefs.coding_agent_voice_hotkey = value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个非核心热键是否非法。与 `reject_non_dictation_side_specific_shortcuts`
|
||||
/// 的逐键校验保持精确一致,避免把非冲突键一并停用。
|
||||
fn non_core_hotkey_invalid(key: NonCoreHotkey, binding: &ShortcutBinding) -> bool {
|
||||
if crate::shortcut_binding::reject_side_specific_non_dictation(binding).is_err() {
|
||||
return true;
|
||||
}
|
||||
match key {
|
||||
NonCoreHotkey::SelectionPolish => {
|
||||
crate::shortcut_binding::validate_binding(binding).is_err()
|
||||
|| reject_bare_shift_dictation_shortcut(binding).is_err()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存兜底(#904):热键冲突不能把整份设置挡在保存之外。
|
||||
///
|
||||
/// 按核心度从高到低处理每个非核心热键:凡与更高优先级键重叠、或本身非法
|
||||
/// (侧特定修饰键等)的,恢复为旧值;旧值仍冲突/非法(历史遗留,例如 1.3.15
|
||||
/// 升级注入的选区润色默认键与录音键重复)时停用(translation 回退默认 Shift)。
|
||||
/// 返回被调整的键数量。dictation 永远保留,不参与调整。
|
||||
pub(crate) fn reconcile_hotkey_collisions(
|
||||
prefs: &mut UserPreferences,
|
||||
previous: &UserPreferences,
|
||||
) -> usize {
|
||||
// 处理顺序 = 核心度从高到低:处理某项时,更高优先级的键已定稿。
|
||||
const ORDER: [NonCoreHotkey; 6] = [
|
||||
NonCoreHotkey::Translation,
|
||||
NonCoreHotkey::Qa,
|
||||
NonCoreHotkey::SwitchStyle,
|
||||
NonCoreHotkey::OpenApp,
|
||||
NonCoreHotkey::SelectionPolish,
|
||||
NonCoreHotkey::LessComputer,
|
||||
];
|
||||
let mut higher: Vec<ShortcutBinding> = vec![prefs.dictation_hotkey.clone()];
|
||||
let mut adjusted = 0;
|
||||
for key in ORDER {
|
||||
let Some(current) = key.get(prefs) else {
|
||||
continue;
|
||||
};
|
||||
let collides = higher
|
||||
.iter()
|
||||
.any(|held| crate::shortcut_binding::bindings_overlap(held, ¤t));
|
||||
if !collides && !non_core_hotkey_invalid(key, ¤t) {
|
||||
higher.push(current);
|
||||
continue;
|
||||
}
|
||||
let fallback = key.get(previous).filter(|candidate| {
|
||||
!higher
|
||||
.iter()
|
||||
.any(|held| crate::shortcut_binding::bindings_overlap(held, candidate))
|
||||
&& !non_core_hotkey_invalid(key, candidate)
|
||||
});
|
||||
// translation 不能停用:旧值仍冲突/非法时回退到默认 Shift(不会与任何键重叠)。
|
||||
let resolved = if key == NonCoreHotkey::Translation && fallback.is_none() {
|
||||
Some(UserPreferences::default().translation_hotkey.clone())
|
||||
} else {
|
||||
fallback
|
||||
};
|
||||
key.set(prefs, resolved.clone());
|
||||
adjusted += 1;
|
||||
if let Some(value) = resolved {
|
||||
higher.push(value);
|
||||
}
|
||||
}
|
||||
adjusted
|
||||
}
|
||||
|
||||
pub(crate) fn persist_settings<T: SettingsWriter>(
|
||||
coord: &T,
|
||||
prefs: UserPreferences,
|
||||
@@ -163,7 +271,17 @@ pub(crate) fn persist_settings_with_keyboard_apply<T: SettingsWriter>(
|
||||
let mut previous = coord.read_settings();
|
||||
sync_dictation_hotkey_legacy_fields(&mut previous);
|
||||
sync_dictation_hotkey_legacy_fields(&mut prefs);
|
||||
reject_hotkey_collisions(&prefs)?;
|
||||
if let Err(collision_error) = reject_hotkey_collisions(&prefs) {
|
||||
// 兜底(#904):热键冲突(含历史遗留的重复键)不能拒绝整份设置保存。
|
||||
// 自动把冲突/非法的非核心热键恢复旧值或停用,其余设置照常落盘。
|
||||
let adjusted = reconcile_hotkey_collisions(&mut prefs, &previous);
|
||||
reject_hotkey_collisions(&prefs).map_err(|leftover| {
|
||||
format!("{collision_error}; 自动化解 {adjusted} 项后仍无法通过校验: {leftover}")
|
||||
})?;
|
||||
log::warn!(
|
||||
"[settings] 热键冲突已自动化解(调整 {adjusted} 项)后保存: {collision_error}"
|
||||
);
|
||||
}
|
||||
let dictation_shortcut_changed = previous.dictation_hotkey != prefs.dictation_hotkey;
|
||||
let dictation_mode_changed = previous.hotkey.mode != prefs.hotkey.mode;
|
||||
let qa_changed = previous.qa_hotkey != prefs.qa_hotkey;
|
||||
@@ -448,6 +566,97 @@ mod tests {
|
||||
assert_eq!(saved.default_mode, PolishMode::Light);
|
||||
assert_eq!(saved.microphone_device_name, "External Mic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_clears_legacy_dictation_selection_polish_duplication() {
|
||||
// #904 历史遗留:1.3.15 升级注入的选区润色默认键(右 Alt)与录音键相同。
|
||||
let prefs = UserPreferences {
|
||||
hotkey: crate::types::HotkeyBinding {
|
||||
trigger: crate::types::HotkeyTrigger::RightAlt,
|
||||
mode: crate::types::HotkeyMode::Hold,
|
||||
keys: None,
|
||||
},
|
||||
dictation_hotkey: ShortcutBinding {
|
||||
primary: "RightAlt".into(),
|
||||
modifiers: vec![],
|
||||
},
|
||||
selection_polish_hotkey: Some(ShortcutBinding {
|
||||
primary: "RightAlt".into(),
|
||||
modifiers: vec![],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut next = prefs.clone();
|
||||
|
||||
let adjusted = reconcile_hotkey_collisions(&mut next, &prefs);
|
||||
|
||||
assert!(adjusted >= 1);
|
||||
assert!(next.selection_polish_hotkey.is_none());
|
||||
assert!(reject_hotkey_collisions(&next).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_reconciles_legacy_collision_and_still_saves_mode() {
|
||||
// #904 复现:历史冲突存在时,用户切「自动」必须能保存成功,
|
||||
// 冲突的选区润色键被停用,而不是整份设置被拒。
|
||||
let collision = UserPreferences {
|
||||
hotkey: crate::types::HotkeyBinding {
|
||||
trigger: crate::types::HotkeyTrigger::RightAlt,
|
||||
mode: crate::types::HotkeyMode::Hold,
|
||||
keys: None,
|
||||
},
|
||||
dictation_hotkey: ShortcutBinding {
|
||||
primary: "RightAlt".into(),
|
||||
modifiers: vec![],
|
||||
},
|
||||
selection_polish_hotkey: Some(ShortcutBinding {
|
||||
primary: "RightAlt".into(),
|
||||
modifiers: vec![],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut next = collision.clone();
|
||||
next.hotkey.mode = crate::types::HotkeyMode::Auto;
|
||||
let writer = RaceSettingsWriter {
|
||||
reads: Mutex::new(vec![collision]),
|
||||
saved: Mutex::new(None),
|
||||
};
|
||||
|
||||
persist_settings_with_keyboard_apply(&writer, next, |_| Ok(())).unwrap();
|
||||
|
||||
let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
|
||||
assert_eq!(saved.hotkey.mode, crate::types::HotkeyMode::Auto);
|
||||
assert!(saved.selection_polish_hotkey.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_resolves_non_core_overlap_and_invalid_side_specific_hotkey() {
|
||||
// QA 与翻译键相同:较低优先级的 QA 恢复旧值,旧值仍冲突则停用。
|
||||
let previous = UserPreferences {
|
||||
qa_hotkey: Some(ShortcutBinding {
|
||||
primary: "E".into(),
|
||||
modifiers: vec!["ctrl".into(), "shift".into()],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut next = previous.clone();
|
||||
next.qa_hotkey = Some(ShortcutBinding {
|
||||
primary: "Shift".into(),
|
||||
modifiers: vec![],
|
||||
});
|
||||
// 侧特定修饰键对非 dictation 非法(SIDE_SPECIFIC_NON_DICTATION_MSG)。
|
||||
next.translation_hotkey = ShortcutBinding {
|
||||
primary: "D".into(),
|
||||
modifiers: vec!["cmd-left".into()],
|
||||
};
|
||||
|
||||
let adjusted = reconcile_hotkey_collisions(&mut next, &previous);
|
||||
|
||||
assert!(adjusted >= 2);
|
||||
assert_eq!(next.qa_hotkey, previous.qa_hotkey);
|
||||
assert_eq!(next.translation_hotkey, previous.translation_hotkey);
|
||||
assert!(reject_hotkey_collisions(&next).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
|
||||
|
||||
@@ -1324,15 +1324,23 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
let mut selection_polish_hotkey = wire
|
||||
.selection_polish_hotkey
|
||||
.unwrap_or_else(default_selection_polish_hotkey);
|
||||
if cfg!(target_os = "windows")
|
||||
&& selection_polish_hotkey_was_missing
|
||||
&& is_right_control_modifier_shortcut(&dictation_hotkey)
|
||||
{
|
||||
// Old settings cannot distinguish the historic default from a
|
||||
// deliberate Right Ctrl choice. Preserve the existing dictation
|
||||
// workflow and leave the new action disabled rather than silently
|
||||
// changing a user's shortcut.
|
||||
selection_polish_hotkey = None;
|
||||
if selection_polish_hotkey_was_missing {
|
||||
// 1.3.15 新增的选区润色默认键(Windows = 右 Alt)不能抢占/顶掉用户已有按键:
|
||||
// - 老用户从未自定义录音键(仍为历史默认 Right Control):默认关闭新功能,
|
||||
// 避免升级后右 Alt 被全局热键占用影响既有使用习惯;
|
||||
// - 默认键与录音键重叠(字符串可能不等但物理同键,如 legacy rightAlt
|
||||
// 派生出 RightOption 而默认是 RightAlt):同样关闭,否则升级后任何
|
||||
// 设置保存都会被热键冲突校验整体拒绝,改动全部丢失(#904)。
|
||||
let legacy_default_user = cfg!(target_os = "windows")
|
||||
&& is_right_control_modifier_shortcut(&dictation_hotkey);
|
||||
let default_taken_by_dictation = selection_polish_hotkey
|
||||
.as_ref()
|
||||
.is_some_and(|binding| {
|
||||
crate::shortcut_binding::bindings_overlap(binding, &dictation_hotkey)
|
||||
});
|
||||
if legacy_default_user || default_taken_by_dictation {
|
||||
selection_polish_hotkey = None;
|
||||
}
|
||||
}
|
||||
let streaming_insert_default_migrated = wire.streaming_insert_default_migrated;
|
||||
let streaming_insert = if streaming_insert_default_migrated {
|
||||
@@ -3061,6 +3069,38 @@ mod tests {
|
||||
assert_eq!(prefs.dictation_hotkey.primary, "RightControl");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn legacy_right_alt_dictation_upgrade_disables_selection_polish_instead_of_colliding() {
|
||||
// #904:录音键自定义为右 Alt 的旧配置升级时,默认注入的选区润色键(右 Alt)
|
||||
// 与录音键相同会形成持久冲突,把后续所有设置保存挡死。迁移必须改为停用新功能。
|
||||
let prefs: UserPreferences = serde_json::from_str(
|
||||
r#"{
|
||||
"hotkey": { "trigger": "rightAlt", "mode": "hold", "keys": null },
|
||||
"dictationHotkey": { "primary": "RightAlt", "modifiers": [] }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(prefs.selection_polish_hotkey.is_none());
|
||||
assert_eq!(prefs.dictation_hotkey.primary, "RightAlt");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn legacy_right_alt_trigger_upgrade_disables_selection_polish_by_overlap() {
|
||||
// #904 变体:旧文件没有 dictationHotkey,只带 legacy hotkey.trigger=rightAlt,
|
||||
// 派生出的录音键 primary 是 "RightOption",与默认注入的 "RightAlt" 字符串不相等
|
||||
// 但物理同键(bindings_overlap=true)。迁移必须按重叠判定,不能按 == 字符串比较。
|
||||
let prefs: UserPreferences = serde_json::from_str(
|
||||
r#"{
|
||||
"hotkey": { "trigger": "rightAlt", "mode": "hold", "keys": null }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(prefs.selection_polish_hotkey.is_none());
|
||||
assert_eq!(prefs.dictation_hotkey.primary, "RightOption");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn new_preferences_keep_the_existing_dictation_default_and_use_right_alt_for_selection_polish() {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
} from "../lib/types"
|
||||
import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n"
|
||||
import { applyThemeFromPreference } from "../lib/themeMode"
|
||||
import { emitSaved } from "../lib/savedEvent"
|
||||
|
||||
interface HotkeySettingsContextValue {
|
||||
prefs: UserPreferences | null
|
||||
@@ -48,6 +49,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const persistQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const latestPrefsRef = useRef<UserPreferences | null>(null)
|
||||
const persistedPrefsRef = useRef<UserPreferences | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -60,6 +62,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
let nextError: string | null = null
|
||||
if (prefsResult.status === "fulfilled") {
|
||||
latestPrefsRef.current = prefsResult.value
|
||||
persistedPrefsRef.current = prefsResult.value
|
||||
setPrefs(prefsResult.value)
|
||||
applyThemeFromPreference(prefsResult.value.themeMode ?? "system")
|
||||
} else {
|
||||
@@ -120,6 +123,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
const nextPrefs = event.payload
|
||||
if (!nextPrefs) return
|
||||
latestPrefsRef.current = nextPrefs
|
||||
persistedPrefsRef.current = nextPrefs
|
||||
setPrefs(nextPrefs)
|
||||
applyThemeFromPreference(nextPrefs.themeMode ?? "system")
|
||||
},
|
||||
@@ -196,7 +200,19 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
if (resolved === current) return
|
||||
setPrefs(resolved)
|
||||
latestPrefsRef.current = resolved
|
||||
await queueSetSettings(resolved)
|
||||
try {
|
||||
await queueSetSettings(resolved)
|
||||
persistedPrefsRef.current = resolved
|
||||
} catch (error) {
|
||||
// 兜底(#904):保存失败必须回滚乐观状态并可见,
|
||||
// 不能出现界面显示已切换、重启后回退的“假保存”。
|
||||
const fallback = persistedPrefsRef.current ?? current
|
||||
latestPrefsRef.current = fallback
|
||||
setPrefs(fallback)
|
||||
console.error("[hotkey-settings] save failed, rolled back", error)
|
||||
emitSaved("failed", errorMessage(error))
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[queueSetSettings],
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user