feat: full ui refresh

This commit is contained in:
zhom
2026-05-11 23:12:16 +04:00
parent 739b5e2449
commit ed3c209f35
46 changed files with 5956 additions and 1553 deletions
+47
View File
@@ -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;
}
+17 -53
View File
@@ -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 {
+55
View File
@@ -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]);
}