Keep hotkey settings load failures recoverable
Hotkey settings are a shared context for multiple pages, so one rejected IPC call must not leave the provider in a permanent loading state. The refresh path now records a localized recoverable error, keeps any successfully loaded half of the state, and always releases loading so consumers can render retry UI instead of staying blank. Constraint: Issue #316 requires local load failures to stay visible and non-fatal. Rejected: Keep Promise.all with one catch | it would still discard partial success from the other IPC call. Confidence: high Scope-risk: narrow Tested: git diff --check; npm run build; cargo check --manifest-path src-tauri/Cargo.toml Not-tested: Manual Tauri UI session with a deliberately corrupted preferences.json. Related: https://github.com/appergb/openless/issues/316
This commit is contained in:
1 parent
29c9160b9f
commit
98f25bd9bb
7 files changed
+113
-7
No files matched your search
@@ -10,6 +10,8 @@ export const en: typeof zhCN = {
|
||||
},
|
||||
common: {
|
||||
loading: 'Loading…',
|
||||
retry: 'Retry',
|
||||
settingsLoadFailed: 'Settings load failed',
|
||||
refresh: 'Refresh',
|
||||
clear: 'Clear',
|
||||
copy: 'Copy',
|
||||
|
||||
@@ -12,6 +12,8 @@ export const ja: typeof zhCN = {
|
||||
},
|
||||
common: {
|
||||
loading: '読み込み中…',
|
||||
retry: '再試行',
|
||||
settingsLoadFailed: '設定の読み込みに失敗しました',
|
||||
refresh: '更新',
|
||||
clear: 'クリア',
|
||||
copy: 'コピー',
|
||||
|
||||
@@ -12,6 +12,8 @@ export const ko: typeof zhCN = {
|
||||
},
|
||||
common: {
|
||||
loading: '로딩 중…',
|
||||
retry: '다시 시도',
|
||||
settingsLoadFailed: '설정 로드 실패',
|
||||
refresh: '새로고침',
|
||||
clear: '지우기',
|
||||
copy: '복사',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const zhCN = {
|
||||
},
|
||||
common: {
|
||||
loading: '加载中…',
|
||||
retry: '重试',
|
||||
settingsLoadFailed: '设置加载失败',
|
||||
refresh: '刷新',
|
||||
clear: '清空',
|
||||
copy: '复制',
|
||||
|
||||
@@ -10,6 +10,8 @@ export const zhTW: typeof zhCN = {
|
||||
},
|
||||
common: {
|
||||
loading: '加載中…',
|
||||
retry: '重試',
|
||||
settingsLoadFailed: '設置加載失敗',
|
||||
refresh: '刷新',
|
||||
clear: '清空',
|
||||
copy: '複製',
|
||||
|
||||
@@ -18,7 +18,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'failed';
|
||||
|
||||
export function Translation() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, refresh, updatePrefs: savePrefs } = useHotkeySettings();
|
||||
const { prefs, loading, error, refresh, updatePrefs: savePrefs } = useHotkeySettings();
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const statusTimer = useRef<number | null>(null);
|
||||
@@ -69,7 +69,34 @@ export function Translation() {
|
||||
desc={t('translation.desc')}
|
||||
/>
|
||||
<Card>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
|
||||
{error ? (
|
||||
<div role="alert" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-red, #ef4444)', lineHeight: 1.5 }}>
|
||||
{t('common.settingsLoadFailed')}:{error}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void refresh(); }}
|
||||
disabled={loading}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 999,
|
||||
border: 0,
|
||||
background: 'var(--ol-blue)',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: loading ? 'not-allowed' : 'default',
|
||||
opacity: loading ? 0.64 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? t('common.loading') : t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
@@ -121,6 +148,46 @@ export function Translation() {
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: '0.5px solid rgba(239,68,68,0.22)',
|
||||
background: 'rgba(239,68,68,0.07)',
|
||||
color: 'var(--ol-red, #ef4444)',
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<span>{t('common.settingsLoadFailed')}:{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void refresh(); }}
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
border: 0,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(239,68,68,0.12)',
|
||||
color: 'inherit',
|
||||
padding: '4px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: loading ? 'not-allowed' : 'default',
|
||||
opacity: loading ? 0.64 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? t('common.loading') : t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveState !== 'idle' && (
|
||||
<div
|
||||
role={saveState === 'failed' ? 'alert' : 'status'}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface HotkeySettingsContextValue {
|
||||
hotkey: HotkeyBinding | null;
|
||||
capability: HotkeyCapability | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
updatePrefs: (
|
||||
next: UserPreferences | ((current: UserPreferences) => UserPreferences),
|
||||
@@ -25,18 +26,45 @@ interface HotkeySettingsContextValue {
|
||||
|
||||
const HotkeySettingsContext = createContext<HotkeySettingsContextValue | null>(null);
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
String(error instanceof Error ? error.message : error);
|
||||
|
||||
export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [prefs, setPrefs] = useState<UserPreferences | null>(null);
|
||||
const [capability, setCapability] = useState<HotkeyCapability | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const persistQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const latestPrefsRef = useRef<UserPreferences | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextPrefs, nextCapability] = await Promise.all([getSettings(), getHotkeyCapability()]);
|
||||
setPrefs(nextPrefs);
|
||||
setCapability(nextCapability);
|
||||
setLoading(false);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [prefsResult, capabilityResult] = await Promise.allSettled([
|
||||
getSettings(),
|
||||
getHotkeyCapability(),
|
||||
]);
|
||||
let nextError: string | null = null;
|
||||
if (prefsResult.status === 'fulfilled') {
|
||||
setPrefs(prefsResult.value);
|
||||
} else {
|
||||
console.error('[hotkey-settings] failed to load preferences', prefsResult.reason);
|
||||
nextError = errorMessage(prefsResult.reason);
|
||||
}
|
||||
if (capabilityResult.status === 'fulfilled') {
|
||||
setCapability(capabilityResult.value);
|
||||
} else {
|
||||
console.error('[hotkey-settings] failed to load hotkey capability', capabilityResult.reason);
|
||||
nextError = errorMessage(capabilityResult.reason);
|
||||
}
|
||||
setError(nextError);
|
||||
} catch (error) {
|
||||
console.error('[hotkey-settings] failed to refresh hotkey settings', error);
|
||||
setError(errorMessage(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const queueSetSettings = useCallback((resolveNext: (current: UserPreferences) => UserPreferences) => {
|
||||
@@ -137,10 +165,11 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
hotkey: prefs?.hotkey ?? null,
|
||||
capability,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
updatePrefs,
|
||||
}),
|
||||
[capability, loading, prefs, refresh, updatePrefs],
|
||||
[capability, error, loading, prefs, refresh, updatePrefs],
|
||||
);
|
||||
|
||||
return <HotkeySettingsContext.Provider value={value}>{children}</HotkeySettingsContext.Provider>;
|
||||
|
||||
Reference in new issue
Block a user