feat: enhance useSettings hook with pending changes tracking and improved save status management
This commit is contained in:
parent
1331033b81
commit
5ac6b6ce95
|
|
@ -22,8 +22,18 @@ import {
|
||||||
export const SETTINGS_PATH = '/.sys/config.json';
|
export const SETTINGS_PATH = '/.sys/config.json';
|
||||||
const SETTINGS_DIR = '/.sys';
|
const SETTINGS_DIR = '/.sys';
|
||||||
|
|
||||||
/** How long to wait after the last change before writing to disk. */
|
/**
|
||||||
const SAVE_DEBOUNCE_MS = 400;
|
* How long to wait after the last change before writing to disk.
|
||||||
|
*
|
||||||
|
* The debounce timer is reset on every change, so this is effectively
|
||||||
|
* "the number of milliseconds the user must be idle before the change
|
||||||
|
* is committed". Bump this up to give the user more time to make
|
||||||
|
* multiple related edits; lower it to write changes more eagerly.
|
||||||
|
*/
|
||||||
|
export const SAVE_IDLE_MS = 3000;
|
||||||
|
|
||||||
|
/** How long the "Saved" indicator lingers before fading out. */
|
||||||
|
const SAVED_INDICATOR_MS = 1500;
|
||||||
|
|
||||||
export type SettingsConfig = Record<string, any>;
|
export type SettingsConfig = Record<string, any>;
|
||||||
|
|
||||||
|
|
@ -56,17 +66,27 @@ export async function writeSettings(config: SettingsConfig): Promise<void> {
|
||||||
await putFileContents(SETTINGS_PATH, json);
|
await putFileContents(SETTINGS_PATH, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error';
|
export type SaveStatus =
|
||||||
|
| 'idle' // nothing to do
|
||||||
|
| 'loading' // initial fetch from server
|
||||||
|
| 'unsaved' // one or more changes pending; waiting for the idle timer
|
||||||
|
| 'saving' // a write is currently in flight
|
||||||
|
| 'saved' // last write succeeded (fades back to 'idle' shortly)
|
||||||
|
| 'error'; // last write failed; will retry on the next change
|
||||||
|
|
||||||
export interface UseSettingsResult {
|
export interface UseSettingsResult {
|
||||||
config: SettingsConfig;
|
config: SettingsConfig;
|
||||||
setConfig: (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => void;
|
setConfig: (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => void;
|
||||||
/** Whether the initial load from the server has completed. */
|
/** Whether the initial load from the server has completed. */
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
/** Current save status. `'saving'` means a write is in flight. */
|
/** Current save status (see {@link SaveStatus}). */
|
||||||
saveStatus: SaveStatus;
|
saveStatus: SaveStatus;
|
||||||
|
/** Number of pending (uncommitted) changes since the last successful save. */
|
||||||
|
pendingCount: number;
|
||||||
/** Force a re-fetch from the server and replace local state. */
|
/** Force a re-fetch from the server and replace local state. */
|
||||||
reload: () => Promise<void>;
|
reload: () => Promise<void>;
|
||||||
|
/** Flush any pending changes immediately, ignoring the idle timer. */
|
||||||
|
flushNow: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -88,6 +108,7 @@ export function useSettings(): UseSettingsResult {
|
||||||
const [config, setConfigState] = useState<SettingsConfig>(bundledConfig as SettingsConfig);
|
const [config, setConfigState] = useState<SettingsConfig>(bundledConfig as SettingsConfig);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('loading');
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('loading');
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
|
||||||
// Refs so the debounced writer always sees the latest values without
|
// Refs so the debounced writer always sees the latest values without
|
||||||
// needing to re-subscribe on every render.
|
// needing to re-subscribe on every render.
|
||||||
|
|
@ -95,6 +116,8 @@ export function useSettings(): UseSettingsResult {
|
||||||
const loadedRef = useRef<boolean>(false);
|
const loadedRef = useRef<boolean>(false);
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const dirtyRef = useRef<boolean>(false);
|
const dirtyRef = useRef<boolean>(false);
|
||||||
|
const pendingRef = useRef<number>(0);
|
||||||
|
const savedTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setSaveStatus('loading');
|
setSaveStatus('loading');
|
||||||
|
|
@ -117,17 +140,28 @@ export function useSettings(): UseSettingsResult {
|
||||||
window.clearTimeout(timerRef.current);
|
window.clearTimeout(timerRef.current);
|
||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (savedTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(savedTimerRef.current);
|
||||||
|
savedTimerRef.current = null;
|
||||||
|
}
|
||||||
if (!dirtyRef.current) return;
|
if (!dirtyRef.current) return;
|
||||||
dirtyRef.current = false;
|
dirtyRef.current = false;
|
||||||
|
const pending = pendingRef.current;
|
||||||
|
pendingRef.current = 0;
|
||||||
|
setPendingCount(0);
|
||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
try {
|
try {
|
||||||
await writeSettings(configRef.current);
|
await writeSettings(configRef.current);
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
// Drop the "saved" badge after a short delay so it doesn't linger.
|
// Drop the "saved" badge after a short delay so it doesn't linger.
|
||||||
window.setTimeout(() => {
|
savedTimerRef.current = window.setTimeout(() => {
|
||||||
setSaveStatus((s) => (s === 'saved' ? 'idle' : s));
|
setSaveStatus((s) => (s === 'saved' ? 'idle' : s));
|
||||||
}, 1500);
|
}, SAVED_INDICATOR_MS);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Roll back: the change is still pending.
|
||||||
|
dirtyRef.current = true;
|
||||||
|
pendingRef.current = pending;
|
||||||
|
setPendingCount(pending);
|
||||||
setSaveStatus('error');
|
setSaveStatus('error');
|
||||||
// Re-throw so the caller can handle it if they want.
|
// Re-throw so the caller can handle it if they want.
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -166,18 +200,30 @@ export function useSettings(): UseSettingsResult {
|
||||||
configRef.current = value;
|
configRef.current = value;
|
||||||
setConfigState(value);
|
setConfigState(value);
|
||||||
dirtyRef.current = true;
|
dirtyRef.current = true;
|
||||||
setSaveStatus('saving');
|
pendingRef.current += 1;
|
||||||
|
setPendingCount(pendingRef.current);
|
||||||
|
setSaveStatus('unsaved');
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
window.clearTimeout(timerRef.current);
|
window.clearTimeout(timerRef.current);
|
||||||
}
|
}
|
||||||
|
// Reset the idle timer: save `SAVE_IDLE_MS` after the most recent
|
||||||
|
// change. Every additional change resets the timer again.
|
||||||
timerRef.current = window.setTimeout(() => {
|
timerRef.current = window.setTimeout(() => {
|
||||||
void flushNow();
|
void flushNow();
|
||||||
}, SAVE_DEBOUNCE_MS);
|
}, SAVE_IDLE_MS);
|
||||||
},
|
},
|
||||||
[flushNow],
|
[flushNow],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { config, setConfig, loaded, saveStatus, reload: load };
|
return {
|
||||||
|
config,
|
||||||
|
setConfig,
|
||||||
|
loaded,
|
||||||
|
saveStatus,
|
||||||
|
pendingCount,
|
||||||
|
reload: load,
|
||||||
|
flushNow,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function absoluteSettingsUrl(): string {
|
function absoluteSettingsUrl(): string {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user