diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 238c124..9a773dd 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -20,6 +20,7 @@ import { useAppStore, logKey } from '@/store/useAppStore'; import { events, ipc } from '@/lib/ipc'; import { hasSeenTour, hasSeenTrayHint, markTrayHintSeen } from '@/lib/onboarding'; import { useContextMenu } from '@/lib/context-menu'; +import { useUiZoomShortcuts } from '@/lib/ui-zoom'; export default function App() { const selectedServiceId = useAppStore((s) => s.selectedServiceId); @@ -82,6 +83,7 @@ export default function App() { [openEditor, openStackEditor, startScan], ); const { menu: contextMenu } = useContextMenu(contextItems); + useUiZoomShortcuts(); useEffect(() => { let cancelled = false; diff --git a/apps/desktop/src/components/SidebarRail.tsx b/apps/desktop/src/components/SidebarRail.tsx index 5eed299..8d6dd64 100644 --- a/apps/desktop/src/components/SidebarRail.tsx +++ b/apps/desktop/src/components/SidebarRail.tsx @@ -98,9 +98,22 @@ export function SidebarRail() { resizing.current = false; }, []); + // PIDs of every service that's already surfaced through a stack row. + // Anything in this set is intentionally hidden from the flat sidebar list + // so users don't see the same service twice (once standalone, once under + // its stack) — the biggest single piece of "my sidebar feels cluttered" + // feedback. Stack detail view still renders them in full. + const serviceIdsInAnyStack = useMemo(() => { + const s = new Set(); + for (const stack of stacks) for (const sid of stack.service_ids) s.add(sid); + return s; + }, [stacks]); + const filteredServices = useMemo(() => { const q = search.trim().toLowerCase(); return services.filter((svc) => { + if (serviceIdsInAnyStack.has(svc.id)) return false; + const status: Status = statuses[svc.id]?.status ?? 'stopped'; const isRunning = status === 'running' || status === 'starting'; if (sidebarStatusFilter === 'running' && !isRunning) return false; @@ -122,7 +135,15 @@ export function SidebarRail() { return true; }); - }, [services, statuses, sidebarStatusFilter, categoryFilter, runtimeFilter, search]); + }, [ + services, + statuses, + sidebarStatusFilter, + categoryFilter, + runtimeFilter, + search, + serviceIdsInAnyStack, + ]); const servicesBySection = useMemo(() => { const map = new Map(); @@ -229,7 +250,10 @@ export function SidebarRail() { (svc) => (statuses[svc.id]?.status ?? 'stopped') === 'running', ).length; - const hiddenCount = services.length - filteredServices.length; + // "Hidden" banner means "hidden by the user's filters" — services living + // inside a stack are reachable through the stack row, so they shouldn't + // inflate the hidden-count and make the user feel something's been lost. + const hiddenCount = services.length - serviceIdsInAnyStack.size - filteredServices.length; const currentWidth = expanded ? width : COLLAPSED_W; const onHomeSelected = selectedServiceId === null && selectedStackId === null; const useSectionLayout = groupBy === 'none'; diff --git a/apps/desktop/src/lib/ui-zoom.ts b/apps/desktop/src/lib/ui-zoom.ts new file mode 100644 index 0000000..b292c52 --- /dev/null +++ b/apps/desktop/src/lib/ui-zoom.ts @@ -0,0 +1,74 @@ +import { useEffect } from 'react'; + +const STORAGE_KEY = 'runhq.ui-scale'; +const MIN = 0.85; +const MAX = 1.4; +const STEP = 0.05; +const DEFAULT = 1; + +function clamp(v: number): number { + if (!Number.isFinite(v)) return DEFAULT; + const stepped = Math.round(v / STEP) * STEP; + return Math.min(MAX, Math.max(MIN, stepped)); +} + +function readStored(): number { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw == null) return DEFAULT; + return clamp(Number(raw)); + } catch { + return DEFAULT; + } +} + +function apply(scale: number): void { + // `zoom` is non-standard but works in WKWebView (Tauri) and Chromium. + // Using `transform: scale` would break the window's bounding box; `zoom` + // reflows everything including Tailwind arbitrary px sizes. + document.documentElement.style.zoom = String(scale); +} + +export function useUiZoomShortcuts(): void { + useEffect(() => { + apply(readStored()); + + const onKey = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + + const current = readStored(); + let next: number | null = null; + + // `e.key` reflects the character the layout produces, but macOS bypasses + // layout processing while Cmd is held — so Cmd+Shift+0 on Turkish Q/F + // (where `=` sits on Shift+0) arrives as `e.key === '0'`, never `'='`. + // To stay usable on every layout we bind the zero row directly: + // Cmd+0 → zoom in, Cmd+Shift+0 → reset. + // The `=`/`+` keys still work for US layouts where Cmd+= produces `=`. + const isZeroKey = e.key === '0' || e.code === 'Digit0' || e.code === 'Numpad0'; + const isEqualish = e.key === '=' || e.key === '+' || e.code === 'NumpadAdd'; + const isMinusish = e.key === '-' || e.key === '_' || e.code === 'NumpadSubtract'; + + if (isZeroKey && e.shiftKey) next = DEFAULT; + else if (isZeroKey) next = clamp(current + STEP); + else if (isEqualish) next = clamp(current + STEP); + else if (isMinusish) next = clamp(current - STEP); + + if (next == null) return; + + e.preventDefault(); + e.stopPropagation(); + try { + localStorage.setItem(STORAGE_KEY, next.toFixed(2)); + } catch { + // Storage may be disabled (private mode); the zoom still applies + // for the current session. + } + apply(next); + }; + + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); +} diff --git a/crates/runhq-core/src/overview.rs b/crates/runhq-core/src/overview.rs index 0671272..6ba2e0f 100644 --- a/crates/runhq-core/src/overview.rs +++ b/crates/runhq-core/src/overview.rs @@ -1007,6 +1007,11 @@ struct ScanEntry { #[derive(Clone)] struct ScanCache { inner: Arc>>, + // TTL is a field rather than reading the global constant directly so tests + // can drive expiry with a tiny duration + real sleep. Backdating `Instant` + // values underflows on fresh Windows CI runners where `Instant::now()` is + // smaller than `SCAN_CACHE_TTL`, so we avoid that construction entirely. + ttl: Duration, } impl ScanCache { @@ -1016,6 +1021,7 @@ impl ScanCache { GLOBAL .get_or_init(|| ScanCache { inner: Arc::new(Mutex::new(HashMap::new())), + ttl: SCAN_CACHE_TTL, }) .clone() } @@ -1023,7 +1029,7 @@ impl ScanCache { fn get_fresh(&self, cwd: &Path) -> Option { let guard = self.inner.lock(); let entry = guard.get(cwd)?; - if entry.fetched_at.elapsed() < SCAN_CACHE_TTL { + if entry.fetched_at.elapsed() < self.ttl { Some(entry.clone()) } else { None @@ -1034,7 +1040,7 @@ impl ScanCache { let guard = self.inner.lock(); guard .iter() - .filter(|(_, e)| e.fetched_at.elapsed() < SCAN_CACHE_TTL) + .filter(|(_, e)| e.fetched_at.elapsed() < self.ttl) .map(|(k, v)| (k.clone(), v.clone())) .collect() } @@ -1220,13 +1226,12 @@ mod tests { fn scan_cache_ttl_returns_stale_as_miss() { let cache = ScanCache { inner: Arc::new(Mutex::new(HashMap::new())), + ttl: Duration::from_millis(20), }; let cwd = PathBuf::from("/tmp/runhq-overview-test"); cache.insert(&cwd, None, None); assert!(cache.get_fresh(&cwd).is_some()); - // Force expiry by rewriting the entry with a back-dated timestamp. - cache.inner.lock().get_mut(&cwd).unwrap().fetched_at = - Instant::now() - SCAN_CACHE_TTL - Duration::from_secs(1); + std::thread::sleep(Duration::from_millis(40)); assert!(cache.get_fresh(&cwd).is_none()); } }