Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -82,6 +83,7 @@ export default function App() {
[openEditor, openStackEditor, startScan],
);
const { menu: contextMenu } = useContextMenu(contextItems);
useUiZoomShortcuts();

useEffect(() => {
let cancelled = false;
Expand Down
28 changes: 26 additions & 2 deletions apps/desktop/src/components/SidebarRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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;
Expand All @@ -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<SectionId, typeof services>();
Expand Down Expand Up @@ -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';
Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/lib/ui-zoom.ts
Original file line number Diff line number Diff line change
@@ -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);
}, []);
}
15 changes: 10 additions & 5 deletions crates/runhq-core/src/overview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,11 @@ struct ScanEntry {
#[derive(Clone)]
struct ScanCache {
inner: Arc<Mutex<HashMap<PathBuf, ScanEntry>>>,
// 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 {
Expand All @@ -1016,14 +1021,15 @@ impl ScanCache {
GLOBAL
.get_or_init(|| ScanCache {
inner: Arc::new(Mutex::new(HashMap::new())),
ttl: SCAN_CACHE_TTL,
})
.clone()
}

fn get_fresh(&self, cwd: &Path) -> Option<ScanEntry> {
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
Expand All @@ -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()
}
Expand Down Expand Up @@ -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());
}
}
Loading