mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-19 19:08:02 +02:00
feat: full ui refresh
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface CommonControlledStateProps<T> {
|
||||
value?: T;
|
||||
defaultValue?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either the caller-controlled `value` (read straight from props) or
|
||||
* an internal state when uncontrolled. The previous implementation kept the
|
||||
* controlled prop in a useEffect-synced state, which lagged one render
|
||||
* behind — when two sibling consumers flipped their `value` props in the
|
||||
* same React batch, both saw stale state for one render and the wrong tree
|
||||
* mounted briefly. Returning the prop directly when controlled makes the
|
||||
* component synchronous in the controlled case, matching React's controlled
|
||||
* input pattern.
|
||||
*/
|
||||
export function useControlledState<T, Rest extends unknown[] = []>(
|
||||
props: CommonControlledStateProps<T> & {
|
||||
onChange?: (value: T, ...args: Rest) => void;
|
||||
},
|
||||
): readonly [T, (next: T, ...args: Rest) => void] {
|
||||
const { value, defaultValue, onChange } = props;
|
||||
|
||||
const [internalState, setInternalState] = React.useState<T>(
|
||||
value ?? (defaultValue as T),
|
||||
);
|
||||
|
||||
const isControlled = value !== undefined;
|
||||
const currentState = isControlled ? value : internalState;
|
||||
|
||||
const setState = React.useCallback(
|
||||
(next: T, ...args: Rest) => {
|
||||
// Always notify caller via onChange so a controlled consumer can
|
||||
// update its own state. Internal state is only relevant in the
|
||||
// uncontrolled case but we keep it in sync so the hook reads the
|
||||
// right value if the consumer later removes its controlled prop.
|
||||
if (!isControlled) {
|
||||
setInternalState(next);
|
||||
}
|
||||
onChange?.(next, ...args);
|
||||
},
|
||||
[isControlled, onChange],
|
||||
);
|
||||
|
||||
return [currentState, setState] as const;
|
||||
}
|
||||
@@ -122,61 +122,25 @@ export function useProfileEvents(): UseProfileEventsReturn {
|
||||
};
|
||||
}, [loadProfiles, loadGroups]);
|
||||
|
||||
// Sync profile running states periodically to ensure consistency
|
||||
// Hydrate the initial runningProfiles set from the loaded list — every
|
||||
// profile that has a stored process_id is a candidate. The Rust status
|
||||
// checker emits profile-running-changed for any transitions; we then
|
||||
// mutate the Set incrementally instead of fan-out-polling all N profiles
|
||||
// every 30s (which was O(N) sysinfo scans and saturated the runtime for
|
||||
// users with hundreds of profiles).
|
||||
useEffect(() => {
|
||||
const syncRunningStates = async () => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const statusChecks = profiles.map(async (profile) => {
|
||||
try {
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
return { id: profile.id, isRunning };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to check status for profile ${profile.name}:`,
|
||||
error,
|
||||
);
|
||||
return { id: profile.id, isRunning: false };
|
||||
}
|
||||
});
|
||||
|
||||
const statuses = await Promise.all(statusChecks);
|
||||
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
let hasChanges = false;
|
||||
|
||||
statuses.forEach(({ id, isRunning }) => {
|
||||
if (isRunning && !prev.has(id)) {
|
||||
next.add(id);
|
||||
hasChanges = true;
|
||||
} else if (!isRunning && prev.has(id)) {
|
||||
next.delete(id);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasChanges ? next : prev;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to sync profile running states:", error);
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const p of profiles) {
|
||||
if (p.process_id != null) next.add(p.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial sync
|
||||
void syncRunningStates();
|
||||
|
||||
// Sync every 30 seconds to catch any missed events
|
||||
const interval = setInterval(() => {
|
||||
void syncRunningStates();
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
// Drop ids for profiles that no longer exist
|
||||
const valid = new Set(profiles.map((p) => p.id));
|
||||
for (const id of next) {
|
||||
if (!valid.has(id)) next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [profiles]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Track scroll position on a vertical scroll container and write the result
|
||||
* to `data-fade-top` / `data-fade-bottom` attributes on the element. The
|
||||
* `.scroll-fade` CSS utility in `globals.css` reads these attributes and
|
||||
* shows fade gradients only in directions that are actually scrollable.
|
||||
*
|
||||
* A ResizeObserver watches the container AND its direct children so internal
|
||||
* content height changes (e.g. virtualizer padding rows growing/shrinking
|
||||
* as the user scrolls) recompute the fade state automatically.
|
||||
*/
|
||||
export function useScrollFade<T extends HTMLElement>(
|
||||
ref: RefObject<T | null>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const update = () => {
|
||||
const fadeTop = el.scrollTop > 1;
|
||||
const fadeBottom = el.scrollHeight - el.clientHeight - el.scrollTop > 1;
|
||||
el.setAttribute("data-fade-top", fadeTop ? "true" : "false");
|
||||
el.setAttribute("data-fade-bottom", fadeBottom ? "true" : "false");
|
||||
};
|
||||
|
||||
update();
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
for (const child of Array.from(el.children)) {
|
||||
ro.observe(child);
|
||||
}
|
||||
|
||||
// MutationObserver picks up DOM additions (virtualizer mounts new rows)
|
||||
// and re-attaches the ResizeObserver to the new children. Without this,
|
||||
// newly inserted rows wouldn't trigger a fade recompute.
|
||||
const mo = new MutationObserver(() => {
|
||||
ro.disconnect();
|
||||
ro.observe(el);
|
||||
for (const child of Array.from(el.children)) {
|
||||
ro.observe(child);
|
||||
}
|
||||
update();
|
||||
});
|
||||
mo.observe(el, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
}
|
||||
Reference in New Issue
Block a user