diff --git a/src/main/processes/process-action-service.ts b/src/main/processes/process-action-service.ts index 33ec468..d6f35d7 100644 --- a/src/main/processes/process-action-service.ts +++ b/src/main/processes/process-action-service.ts @@ -82,6 +82,18 @@ function hasStableTargetIdentity(target: ProcessIdentity | undefined): boolean { return target?.startedAtStatus === FieldStatus.FIELD_STATUS_OK; } +/** + * True for MoStats itself: the main process or any of its direct child helpers + * (renderer, GPU, utility), so neither can be signaled into self-destabilizing + * the app. Helper PIDs differ from the main PID, but their parent is it. + */ +export function isSelfProcess(row: ProcessRow, selfPid: number): boolean { + if ((row.identity?.pid ?? 0) === selfPid) { + return true; + } + return row.statics?.parentStatus === FieldStatus.FIELD_STATUS_OK && row.statics.parentPid === selfPid; +} + /** * True for a session-critical process that must never be signaled: PID 0/1 * plus the {@link CRITICAL_PROCESS_NAMES} denylist, matched against both the @@ -123,7 +135,7 @@ export function disabledReasonFor( if (!hasStableTargetIdentity(target)) { return ActionDisabledReason.ACTION_DISABLED_REASON_UNSTABLE_IDENTITY; } - if ((row.identity?.pid ?? 0) === selfPid) { + if (isSelfProcess(row, selfPid)) { return ActionDisabledReason.ACTION_DISABLED_REASON_SELF; } if (isCriticalProcess(row)) { diff --git a/src/renderer/components/metrics/cpu-graph.tsx b/src/renderer/components/metrics/cpu-graph.tsx index 2713ddb..5b64f80 100644 --- a/src/renderer/components/metrics/cpu-graph.tsx +++ b/src/renderer/components/metrics/cpu-graph.tsx @@ -3,11 +3,11 @@ import { useRef, type PointerEvent as ReactPointerEvent } from "react"; import { cn } from "@/lib/utils"; import type { MetricState } from "@/domain/metric-view"; import { areaRuns } from "@/domain/area-path"; -import { HISTORY_CAPACITY, sampleIndexAtFraction } from "@/domain/sample-history"; +import { HISTORY_CAPACITY, sampleIndexAtFraction, type HistorySample } from "@/domain/sample-history"; import { AreaLayer, Baseline, ScrubBand } from "@/components/metrics/area-layer"; -/** A 0-100 percent reading, or `null` for a tick whose reading was not OK. */ -export type CpuSample = number | null; +/** A CPU percent reading, or `null` for a tick whose reading was not OK. */ +export type CpuSample = HistorySample; const FILL_BY_STATE: Record = { ok: "text-success", diff --git a/src/renderer/components/metrics/memory-graph.tsx b/src/renderer/components/metrics/memory-graph.tsx new file mode 100644 index 0000000..93dbb0d --- /dev/null +++ b/src/renderer/components/metrics/memory-graph.tsx @@ -0,0 +1,54 @@ +import { useRef, type PointerEvent as ReactPointerEvent } from "react"; + +import { areaRuns } from "@/domain/area-path"; +import { HISTORY_CAPACITY, sampleIndexAtFraction, type HistorySample } from "@/domain/sample-history"; +import { AreaLayer, Baseline, ScrubBand } from "@/components/metrics/area-layer"; + +const PEAK = 88; +const BASELINE_Y = 99.5; +// Keep page-sized memory wobble from turning into a dramatic spike. +const MIN_SPAN_BYTES = 16 * 1024 * 1024; + +/** Floating-axis memory trend for one process or group. */ +export function MemoryGraph({ + history, + scrubIndex, + onScrub, +}: { + history: HistorySample[]; + scrubIndex: number | null; + onScrub: (index: number | null) => void; +}) { + const ref = useRef(null); + const offset = HISTORY_CAPACITY - history.length; + + const values = history.filter((sample): sample is number => sample !== null); + const max = values.length > 0 ? Math.max(...values) : 0; + const min = values.length > 0 ? Math.min(...values) : 0; + const base = Math.max(0, min - MIN_SPAN_BYTES * 0.25); + const span = Math.max(MIN_SPAN_BYTES, max - base); + const runs = areaRuns(history, offset, (sample) => ((sample - base) / span) * PEAK, 100, -1); + + const handleMove = (event: ReactPointerEvent) => { + const rect = ref.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return; + onScrub(sampleIndexAtFraction((event.clientX - rect.left) / rect.width, history.length)); + }; + + return ( + onScrub(null)} + > + + + {scrubIndex !== null ? : null} + + ); +} diff --git a/src/renderer/components/processes/disclosure.tsx b/src/renderer/components/processes/disclosure.tsx index 52eba38..98f5826 100644 --- a/src/renderer/components/processes/disclosure.tsx +++ b/src/renderer/components/processes/disclosure.tsx @@ -5,14 +5,7 @@ import { appGateway } from "@/gateway/app-gateway"; import { cn } from "@/lib/utils"; /** - * Reusable disclosure and copy primitives for the detail view. Sensitive - * process text (paths, argv) is copied only on explicit user action and - * routes through main because the renderer is sandboxed. - */ - -/** - * Smooth height/opacity wrapper for disclosure bodies. Content stays mounted - * so close animations can run; `inert` keeps hidden controls unreachable. + * Smooth height/opacity wrapper for disclosure bodies. */ export function DisclosureContent({ open, @@ -26,8 +19,8 @@ export function DisclosureContent({ aria-hidden={!open} inert={open ? undefined : true} className={cn( - "grid transition-[grid-template-rows,opacity,margin-top] duration-150 ease-out motion-reduce:transition-none", - open ? "mt-1.5 grid-rows-[1fr] opacity-100" : "mt-0 grid-rows-[0fr] opacity-0", + "grid transition-[grid-template-rows,opacity] duration-150 ease-out motion-reduce:transition-none", + open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0", )} >
{children}
diff --git a/src/renderer/components/processes/member-row.tsx b/src/renderer/components/processes/member-row.tsx new file mode 100644 index 0000000..cbe198c --- /dev/null +++ b/src/renderer/components/processes/member-row.tsx @@ -0,0 +1,70 @@ +import { memo } from "react"; + +import { cn } from "@/lib/utils"; +import { ProcessIcon } from "@/components/processes/process-icon"; +import { metricValueText } from "@/domain/process-list"; +import type { DetailMember } from "@/domain/process-detail"; + +/** + * One member-process row - icon, name, active-metric value - drillable into its + * own detail. Shared by the detail view's Members section and the inline + * expanded list under a grouped row, where `indented` sits the row under the + * parent icon and mutes its name to read as a child. Memoized field-wise so an + * unchanged member skips re-rendering across snapshot ticks. + */ +export const MemberRow = memo( + function MemberRow({ + member, + indented = false, + onOpen, + }: { + member: DetailMember + indented?: boolean + onOpen: (pid: number, startedAtUnixMs?: number) => void + }) { + return ( + + ); + }, + (prev, next) => + prev.onOpen === next.onOpen && + prev.indented === next.indented && + prev.member.pid === next.member.pid && + prev.member.startedAtUnixMs === next.member.startedAtUnixMs && + prev.member.name === next.member.name && + prev.member.iconPngBase64 === next.member.iconPngBase64 && + prev.member.metricState === next.member.metricState && + prev.member.metricText === next.member.metricText && + prev.member.notResponding === next.member.notResponding, +); diff --git a/src/renderer/components/processes/process-actions.tsx b/src/renderer/components/processes/process-actions.tsx index bbf4e25..6e6030e 100644 --- a/src/renderer/components/processes/process-actions.tsx +++ b/src/renderer/components/processes/process-actions.tsx @@ -32,13 +32,12 @@ export function ProcessActions({ ); return ( -
-

- {message} -

+
+ {message ? ( +

+ {message} +

+ ) : null}
= { */ export function ProcessDetailView({ detail, + history, sort, actions, actionsBusy, @@ -46,6 +54,7 @@ export function ProcessDetailView({ onRunAction, }: { detail: ProcessDetail + history: HistorySample[] sort: SortMode actions: ActionState[] actionsBusy: boolean @@ -86,7 +95,7 @@ export function ProcessDetailView({
-
+
)} + + {grouped ? ( - ) : ( - - )} + ) : null}
{detail.system ? null : ( @@ -169,17 +178,44 @@ export function ProcessDetailView({ ); } -/** - * The metric value for a single-process detail (no members): one row in the - * slot the group's Members header occupies. Not collapsible or drillable. - */ -function SingleProcessMetric({ detail }: { detail: ProcessDetail }) { +/** Recent trend for the selected process or group under the active metric. */ +function ProcessMetricGraph({ detail, history }: { detail: ProcessDetail; history: HistorySample[] }) { + const isCpu = detail.totalSort === "cpu"; + const [scrubIndex, setScrubIndex] = useState(null); + + const format = isCpu ? formatCpuPercentPrecise : (value: number) => formatBytes(value, true); + + const scrubbed = scrubIndex !== null ? history[scrubIndex] ?? null : null; + const valueText = scrubIndex !== null + ? scrubbed !== null + ? format(scrubbed) + : "--" + : detail.totalValue !== null + ? format(detail.totalValue) + : metricValueText(detail.total.state, detail.total.text); + + const scrubPercent = + scrubIndex !== null + ? ((HISTORY_CAPACITY - history.length + scrubIndex + 0.5) / HISTORY_CAPACITY) * 100 + : null; + return ( -
- - {TOTAL_LABEL[detail.totalSort]} - - +
+ + + +
+ {isCpu ? ( + + ) : ( + + )} + {scrubbed !== null && scrubPercent !== null ? ( + + {format(scrubbed)} + + ) : null} +
); } @@ -260,21 +296,6 @@ function HeaderStat({ ); } -/** Renders a {@link DetailField} value with the ok/pending/unavailable rule. */ -function MetricValue({ metric, className }: { metric: DetailField; className?: string }) { - return ( - - {metricValueText(metric.state, metric.text)} - - ); -} - /** A labeled detail field: a quiet uppercase label, the value below. */ function Field({ label, children }: { label: string; children: ReactNode }) { return ( @@ -337,26 +358,28 @@ function ScrollableValue({ } /** - * The expandable Members section for a multi-process app. The disclosure - * header carries the group's selected-metric total on the right; toggling it - * reveals the member processes (representative first), each drillable into - * its own detail. Starts expanded; scrolls within a bounded box. + * The expandable Members section for a multi-process app. Rows are ranked by + * the active metric and drill into individual process details. */ function Members({ - members, + members: rankedMembers, memberCount, - total, + resetKey, onOpenMember, }: { members: DetailMember[] memberCount: number - total: DetailField + /** Changes when the drilled target or sort changes, dropping any stale pin. */ + resetKey: string onOpenMember: (pid: number, startedAtUnixMs?: number) => void }) { const [expanded, setExpanded] = useState(true); + const [pointerInside, setPointerInside] = useState(false); + const [focusInside, setFocusInside] = useState(false); + const members = useOrderPin(rankedMembers, memberKey, pointerInside || focusInside, resetKey); return ( -
+
-
    +
      setPointerInside(true)} + onPointerLeave={() => setPointerInside(false)} + onFocusCapture={() => setFocusInside(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setFocusInside(false); + } + }} + > {members.map((member) => ( -
    • +
    • ))} @@ -389,47 +421,3 @@ function Members({
); } - -/** One member row: icon, name, and the active-metric value. */ -const MemberRow = memo( - function MemberRow({ - member, - onOpen, - }: { - member: DetailMember - onOpen: (pid: number, startedAtUnixMs?: number) => void - }) { - return ( - - ); - }, - (prev, next) => - prev.onOpen === next.onOpen && - prev.member.pid === next.member.pid && - prev.member.startedAtUnixMs === next.member.startedAtUnixMs && - prev.member.name === next.member.name && - prev.member.iconPngBase64 === next.member.iconPngBase64 && - prev.member.metricState === next.member.metricState && - prev.member.metricText === next.member.metricText && - prev.member.notResponding === next.member.notResponding, -); diff --git a/src/renderer/components/processes/process-explorer-view.tsx b/src/renderer/components/processes/process-explorer-view.tsx index 9c495ba..ba2c167 100644 --- a/src/renderer/components/processes/process-explorer-view.tsx +++ b/src/renderer/components/processes/process-explorer-view.tsx @@ -7,6 +7,7 @@ import { ProcessList } from "@/components/processes/process-list"; import { ProcessSearchField } from "@/components/processes/process-search-field"; import { ProcessSortControl } from "@/components/processes/process-sort-control"; import { useProcessActions } from "@/components/processes/use-process-actions"; +import { useProcessHistories } from "@/components/processes/use-process-histories"; import { projectProcessList, resolveSelection, @@ -122,6 +123,7 @@ export function ProcessExplorerView({ active }: { active: boolean }) { return undefined; }, [snapshot, sort, selectionStack]); + const readHistory = useProcessHistories(snapshot); const { actions, actionsBusy, actionMessage, runAction } = useProcessActions( detail, pull, @@ -162,6 +164,7 @@ export function ProcessExplorerView({ active }: { active: boolean }) { { - listRef.current?.querySelector("button")?.focus(); + listRef.current?.querySelector("button[data-process-row]")?.focus(); }, []); const openTopMatch = useCallback(() => { @@ -247,6 +250,8 @@ function ProcessListPanel({ 0} onOpenSelection={onOpenSelection} diff --git a/src/renderer/components/processes/process-list.tsx b/src/renderer/components/processes/process-list.tsx index 8dcdd92..54f2a1a 100644 --- a/src/renderer/components/processes/process-list.tsx +++ b/src/renderer/components/processes/process-list.tsx @@ -1,22 +1,31 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type Ref } from "react"; +import { useCallback, useState, type KeyboardEvent, type Ref } from "react"; import { SnapshotStatus } from "@/gen/process_explorer"; import { ProcessRow } from "@/components/processes/process-row"; -import { pinGroupOrder, type DetailSelection, type ProcessGroup } from "@/domain/process-list"; +import { useOrderPin } from "@/components/processes/use-order-pin"; +import { + groupKey, + type DetailSelection, + type IconTable, + type ProcessGroup, + type SortMode, +} from "@/domain/process-list"; /** * The ranked, grouped process rows plus the loading/empty/unavailable states, * sharing one scroll area so the panel never resizes. * * While the pointer is inside the list - or a row has keyboard focus - row - * order is pinned via {@link pinGroupOrder}: a snapshot tick re-ranks rows, - * and a reorder landing between aiming and clicking (or between arrow - * presses) would open the wrong process. Values keep updating; only the order - * holds. Live ranking resumes when the pointer and focus leave (opening a - * detail unmounts the list, so a stale pin cannot outlive the interaction). + * order is pinned via {@link useOrderPin}: a snapshot tick re-ranks rows, and a + * reorder landing between aiming and clicking (or between arrow presses) would + * open the wrong process. Values keep updating; only the order holds. Live + * ranking resumes when the pointer and focus leave (opening a detail unmounts + * the list, so a stale pin cannot outlive the interaction). */ export function ProcessList({ groups: rankedGroups, + sort, + icons, status, hasQuery, onOpenSelection, @@ -24,6 +33,8 @@ export function ProcessList({ onExitTop, }: { groups: ProcessGroup[] + sort: SortMode + icons: IconTable status: SnapshotStatus hasQuery: boolean onOpenSelection: (selection: DetailSelection) => void @@ -32,30 +43,37 @@ export function ProcessList({ }) { const [pointerInside, setPointerInside] = useState(false); const [focusInside, setFocusInside] = useState(false); - // The key order last shown on screen; the baseline the next pinned tick replays. - const pinnedKeys = useRef([]); - + // Keys of groups expanded to show their member processes inline. Survives + // snapshot ticks; a key whose group has vanished is simply never read. + const [expandedKeys, setExpandedKeys] = useState>(() => new Set()); + const toggleExpanded = useCallback((key: string) => { + setExpandedKeys((current) => { + const next = new Set(current); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); const pinActive = pointerInside || focusInside; - const groups = useMemo( - () => (pinActive ? pinGroupOrder(rankedGroups, pinnedKeys.current) : rankedGroups), - [pinActive, rankedGroups], - ); - - // Track the order actually displayed: unpinned it follows the live ranking; - // pinned it evolves only by drop-outs and bottom appends, so a row that left - // the capped set and returned cannot reclaim a mid-list slot under the cursor. - useEffect(() => { - pinnedKeys.current = groups.map((group) => group.key); - }, [groups]); + const groups = useOrderPin(rankedGroups, groupKey, pinActive, sort); - // Moves focus between row buttons on ArrowDown/ArrowUp; rows are the only - // buttons inside the container. Focusing scrolls the row into view natively. + // Moves focus between row buttons on ArrowDown/ArrowUp. Focus may sit on a + // row's content button or on its expand chevron; both live in the same
  • , + // so resolve the active element to its row before stepping. Focusing scrolls + // the row into view natively. function handleKeyDown(event: KeyboardEvent) { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { return; } - const rows = Array.from(event.currentTarget.querySelectorAll("button")); - const current = rows.indexOf(document.activeElement as HTMLButtonElement); + const rows = Array.from( + event.currentTarget.querySelectorAll("button[data-process-row]"), + ); + const active = document.activeElement as Element | null; + const activeRow = active?.closest("li")?.querySelector("button[data-process-row]"); + const current = activeRow ? rows.indexOf(activeRow) : -1; if (current < 0) { return; } @@ -74,7 +92,11 @@ export function ProcessList({ className="scrollbar-hidden flex-1 overflow-y-auto bg-background" onPointerOver={() => setPointerInside(true)} onPointerLeave={() => setPointerInside(false)} - onFocusCapture={() => setFocusInside(true)} + onFocusCapture={(event) => { + if (event.target instanceof Element && event.target.matches(":focus-visible")) { + setFocusInside(true); + } + }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setFocusInside(false); @@ -86,7 +108,15 @@ export function ProcessList({
      {groups.map((group) => (
    • - +
    • ))}
    diff --git a/src/renderer/components/processes/process-row.tsx b/src/renderer/components/processes/process-row.tsx index abf16c1..50400d2 100644 --- a/src/renderer/components/processes/process-row.tsx +++ b/src/renderer/components/processes/process-row.tsx @@ -1,95 +1,193 @@ -import { memo } from "react"; +import { memo, useCallback, useMemo, useRef } from "react"; +import { ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; +import { DisclosureContent } from "@/components/processes/disclosure"; +import { MemberRow } from "@/components/processes/member-row"; import { ProcessIcon } from "@/components/processes/process-icon"; -import { metricValueText, type DetailSelection, type ProcessGroup } from "@/domain/process-list"; +import { useOrderPin } from "@/components/processes/use-order-pin"; +import { memberKey, rankMembers, type DetailMember } from "@/domain/process-detail"; +import { + metricValueText, + type DetailSelection, + type IconTable, + type ProcessGroup, + type SortMode, +} from "@/domain/process-list"; /** * One fixed-height process row: app icon, name, an optional "+N" grouped-child - * badge, and the right-aligned active metric. The whole row is a button - * opening the detail view. An app macOS marks Not Responding gets its name in - * the destructive color plus a matching badge (Activity Monitor's convention), - * since a hung app often shows nothing abnormal in CPU or memory. - * - * Memoized with a field-wise comparator: the projection rebuilds fresh group - * objects every tick, so comparing the rendered fields lets an unchanged row - * skip re-rendering. `onOpen` is a stable callback and is not compared. + * badge, and the right-aligned active metric. The row body is a button opening + * the detail view; a grouped row also carries a leading chevron that expands + * its member processes inline (ranked, and held in place while the list is + * pinned). An app macOS marks Not Responding gets its name in the destructive + * color plus a matching badge (Activity Monitor's convention), since a hung app + * often shows nothing abnormal in CPU or memory. */ export const ProcessRow = memo(function ProcessRow({ group, + sort, + icons, + expanded, + pinned, onOpen, + onToggle, }: { group: ProcessGroup + sort: SortMode + icons: IconTable + expanded: boolean + pinned: boolean onOpen: (selection: DetailSelection) => void + onToggle: (key: string) => void }) { - return ( - + ) : null} + + + {group.childCount > 0 ? ( + + +{group.childCount} + + ) : null} +
  • + + + {metricValueText(group.metricState, group.metricText)} + + +
    + + {expandable ? ( + +
      + {children.map((child) => ( +
    • + +
    • + ))} +
    +
    + ) : null} +
    ); }, areGroupsEqual); function areGroupsEqual( - previous: { group: ProcessGroup; onOpen: (selection: DetailSelection) => void }, - next: { group: ProcessGroup; onOpen: (selection: DetailSelection) => void }, + previous: { group: ProcessGroup; sort: SortMode; expanded: boolean; pinned: boolean }, + next: { group: ProcessGroup; sort: SortMode; expanded: boolean; pinned: boolean }, ): boolean { const a = previous.group; const b = next.group; - return ( - a.key === b.key && - a.name === b.name && - a.iconPngBase64 === b.iconPngBase64 && - a.system === b.system && - a.childCount === b.childCount && - a.memberCount === b.memberCount && - a.metricState === b.metricState && - a.metricText === b.metricText && - a.notResponding === b.notResponding && - areSelectionsEqual(a.openSelection, b.openSelection) - ); + if ( + previous.sort !== next.sort || + previous.expanded !== next.expanded || + a.key !== b.key || + a.name !== b.name || + a.iconPngBase64 !== b.iconPngBase64 || + a.system !== b.system || + a.childCount !== b.childCount || + a.memberCount !== b.memberCount || + a.metricState !== b.metricState || + a.metricText !== b.metricText || + a.notResponding !== b.notResponding || + !areSelectionsEqual(a.openSelection, b.openSelection) + ) { + return false; + } + if (!next.expanded) { + return true; + } + return previous.pinned === next.pinned && membersEqual(a.members, b.members); +} + +function membersEqual(a: ProcessGroup["members"], b: ProcessGroup["members"]): boolean { + if (a.length !== b.length) { + return false; + } + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) { + return false; + } + } + return true; } function areSelectionsEqual(left: DetailSelection, right: DetailSelection): boolean { diff --git a/src/renderer/components/processes/use-order-pin.ts b/src/renderer/components/processes/use-order-pin.ts new file mode 100644 index 0000000..bfa78c5 --- /dev/null +++ b/src/renderer/components/processes/use-order-pin.ts @@ -0,0 +1,35 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { pinOrder } from "@/domain/process-list"; + +/** + * Holds a ranked list's row order steady while `active` (pointer or keyboard + * focus inside it), so a snapshot re-rank can't move a row between aiming and + * clicking. Values keep updating; only the order is held. Used by every pinned + * list: the group list, the detail Members section, and the inline expanded + * children. + * + * `resetKey` drops the held order when it changes (e.g. the drilled target or + * sort switched), so the next pinned tick re-baselines from the live ranking. + */ +export function useOrderPin( + ranked: Item[], + getKey: (item: Item) => Key, + active: boolean, + resetKey?: unknown, +): Item[] { + const pinnedKeys = useRef([]); + const lastResetKey = useRef(resetKey); + const baselineChanged = resetKey !== lastResetKey.current; + + const ordered = useMemo(() => { + return active && !baselineChanged ? pinOrder(ranked, getKey, pinnedKeys.current) : ranked; + }, [active, baselineChanged, getKey, ranked]); + + useEffect(() => { + lastResetKey.current = resetKey; + pinnedKeys.current = ordered.map(getKey); + }, [getKey, ordered, resetKey]); + + return ordered; +} diff --git a/src/renderer/components/processes/use-process-histories.ts b/src/renderer/components/processes/use-process-histories.ts new file mode 100644 index 0000000..fc9d3bb --- /dev/null +++ b/src/renderer/components/processes/use-process-histories.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { ProcessSnapshot } from "@/gen/process_explorer"; +import { sampleMetricsByKey, type SortMode } from "@/domain/process-list"; +import { pushSample, type HistorySample } from "@/domain/sample-history"; + +/** A process's retained CPU and memory trails (oldest first). */ +interface Trails { + cpu: HistorySample[]; + memory: HistorySample[]; +} + +/** A read-only view of one target's trail under the active metric. */ +export interface ProcessHistory { + history: HistorySample[]; +} + +/** Keeps short CPU/memory trails for process details the user has opened. */ +export function useProcessHistories(snapshot: ProcessSnapshot): (key: string, sort: SortMode) => ProcessHistory { + const [trailsByKey, setTrailsByKey] = useState>(() => new Map()); + const trackedKeys = useRef(new Set()); + const lastRevision = useRef(null); + + useEffect(() => { + if (lastRevision.current === snapshot.revision) { + return; + } + lastRevision.current = snapshot.revision; + if (trackedKeys.current.size === 0) { + return; + } + const samples = sampleMetricsByKey(snapshot); + for (const key of trackedKeys.current) { + if (!samples.has(key)) { + trackedKeys.current.delete(key); + } + } + setTrailsByKey((previous) => { + const next = new Map(); + for (const key of trackedKeys.current) { + const sample = samples.get(key); + if (sample === undefined) { + continue; + } + const prior = previous.get(key); + next.set(key, { + cpu: pushSample(prior?.cpu ?? [], sample.cpu), + memory: pushSample(prior?.memory ?? [], sample.memory), + }); + } + return next; + }); + }, [snapshot]); + + return useCallback( + (key: string, sort: SortMode): ProcessHistory => { + trackedKeys.current.add(key); + const trails = trailsByKey.get(key); + return { history: (sort === "cpu" ? trails?.cpu : trails?.memory) ?? [] }; + }, + [trailsByKey], + ); +} diff --git a/src/renderer/domain/process-detail.ts b/src/renderer/domain/process-detail.ts index 0996993..187e507 100644 --- a/src/renderer/domain/process-detail.ts +++ b/src/renderer/domain/process-detail.ts @@ -55,6 +55,11 @@ export interface DetailMember { notResponding: boolean; } +/** Stable identity reader for a member, for order-pinning member lists. */ +export function memberKey(member: DetailMember): string { + return `${member.pid}:${member.startedAtUnixMs ?? "unknown"}`; +} + /** * Presentation model for the detail view of one selected group (or one * process - then a single-member group). @@ -87,6 +92,8 @@ export interface ProcessDetail { user: DetailField; /** The group's total for the selected metric, with detail precision. */ total: DetailField; + /** Raw active-metric total for the trend graph. */ + totalValue: number | null; /** Which metric {@link total} reflects, for the "Total CPU"/"Total RAM" label. */ totalSort: SortMode; memberCount: number; @@ -161,6 +168,20 @@ function sumGroup( return { state: anyPending ? "pending" : "unavailable" }; } +/** Raw counterpart to {@link sumGroup}; `null` means the graph should draw a gap. */ +function sumGroupValue(members: ProcessRow[], read: (row: ProcessRow) => MetricCell): number | null { + let sum = 0; + let hasValue = false; + for (const row of members) { + const value = read(row).value; + if (value !== undefined) { + sum += value; + hasValue = true; + } + } + return hasValue ? sum : null; +} + function rowThreadCount(row: ProcessRow): MetricCell { const threads = row.threadCount; if (threads && threads.status === FieldStatus.FIELD_STATUS_OK) { @@ -186,7 +207,8 @@ function detailUser(row: ProcessRow): DetailField { return { state: missingState(user?.status) }; } -function buildMember(row: ProcessRow, sort: SortMode, icons: IconTable): DetailMember { +/** Projects one process row into a member display item under the active sort. */ +export function buildMember(row: ProcessRow, sort: SortMode, icons: IconTable): DetailMember { const cell = rowMetric(row, sort); const metricState = cellState(cell); return { @@ -200,6 +222,23 @@ function buildMember(row: ProcessRow, sort: SortMode, icons: IconTable): DetailM }; } +/** + * A group's members as display rows ranked by the active metric (descending), + * with a PID tie-break so equal-value rows (e.g. idle 0.00% members) stay + * stable across ticks. Shared by the detail view and the inline expanded list + * so both order members identically. + */ +export function rankMembers(group: ProcessGroup, sort: SortMode, icons: IconTable): DetailMember[] { + const read = sort === "cpu" ? rowCpu : rowMemory; + return group.members + .slice() + .sort((left, right) => { + const delta = (read(right).value ?? 0) - (read(left).value ?? 0); + return delta !== 0 ? delta : rowPid(left) - rowPid(right); + }) + .map((row) => buildMember(row, sort, icons)); +} + /** * Projects a selected {@link ProcessGroup} into its display model. Identity, * path, argv, and started-at come from the representative (the row the @@ -215,20 +254,9 @@ export function buildProcessDetail(group: ProcessGroup, sort: SortMode, icons: I const parentAvailable = statics?.parentStatus === FieldStatus.FIELD_STATUS_OK && statics.parentPid > 0; - // Members ranked by the active metric like the main list, with a PID - // tie-break so equal-value rows (e.g. idle 0.00% members) stay stable across - // ticks. The representative stays group.members[0] for the header identity; - // only the displayed list is ranked. - const members = - group.memberCount > 1 - ? group.members - .slice() - .sort((left, right) => { - const delta = (read(right).value ?? 0) - (read(left).value ?? 0); - return delta !== 0 ? delta : rowPid(left) - rowPid(right); - }) - .map((row) => buildMember(row, sort, icons)) - : []; + // The representative stays group.members[0] for the header identity; only the + // displayed list is ranked. + const members = group.memberCount > 1 ? rankMembers(group, sort, icons) : []; return { key: group.key, @@ -248,6 +276,7 @@ export function buildProcessDetail(group: ProcessGroup, sort: SortMode, icons: I cpuTime: sumGroup(group.members, rowCpuTime, formatCpuTime), user: detailUser(representative), total: sumGroup(group.members, read, (value) => formatDetailMetric(value, sort)), + totalValue: sumGroupValue(group.members, read), totalSort: sort, memberCount: group.memberCount, members, diff --git a/src/renderer/domain/process-list.ts b/src/renderer/domain/process-list.ts index c44c71e..6c90325 100644 --- a/src/renderer/domain/process-list.ts +++ b/src/renderer/domain/process-list.ts @@ -493,30 +493,76 @@ export function projectProcessList( return groups.slice(0, DISPLAY_LIMIT); } +/** Raw CPU/memory readings for one graph tick; `null` draws as a gap. */ +export interface MetricSample { + cpu: number | null; + memory: number | null; +} + +function addSample(current: number | null, cell: MetricCell): number | null { + if (cell.value === undefined) { + return current; + } + return (current ?? 0) + cell.value; +} + +/** Samples graph values under the same keys {@link resolveSelection} uses. */ +export function sampleMetricsByKey(snapshot: ProcessSnapshot): Map { + const samples = new Map(); + + const fold = (key: string, row: ProcessRow) => { + const existing = samples.get(key); + const cpu = addSample(existing?.cpu ?? null, rowCpu(row)); + const memory = addSample(existing?.memory ?? null, rowMemory(row)); + samples.set(key, { cpu, memory }); + }; + + for (const row of snapshot.processes) { + const groupKeyValue = rowGroupKey(row); + fold(groupKeyValue, row); + const identityKey = rowIdentityKey(row); + if (identityKey !== groupKeyValue) { + fold(identityKey, row); + } + } + + return samples; +} + /** - * Reorders projected groups to match a previously rendered key order, so the - * list can pin row positions while the pointer is inside it (a live re-rank - * would move rows between aiming and clicking). Only the order is held; the - * group objects and their metric values are the fresh ones. New arrivals - * append after the pinned rows so they never displace a row mid-list; vanished - * keys drop out naturally. + * Reorders freshly ranked items to match a previously rendered identity order, + * so a list can pin row positions while the pointer or focus is inside it (a + * live re-rank would move rows between aiming and clicking). Only the order is + * held; the items and their values are the fresh ones. New arrivals append + * after the pinned rows so they never displace a row mid-list; vanished + * identities drop out naturally. Shared by every pinned list (groups, detail + * members, inline expanded children). */ -export function pinGroupOrder(groups: ProcessGroup[], pinnedKeys: string[]): ProcessGroup[] { +export function pinOrder( + items: Item[], + getKey: (item: Item) => Key, + pinnedKeys: Key[], +): Item[] { if (pinnedKeys.length === 0) { - return groups; + return items; } const rankByKey = new Map(pinnedKeys.map((key, index) => [key, index] as const)); - const pinned: ProcessGroup[] = []; - const fresh: ProcessGroup[] = []; - for (const group of groups) { - (rankByKey.has(group.key) ? pinned : fresh).push(group); + const pinned: Item[] = []; + const fresh: Item[] = []; + for (const item of items) { + (rankByKey.has(getKey(item)) ? pinned : fresh).push(item); } - pinned.sort((left, right) => (rankByKey.get(left.key) ?? 0) - (rankByKey.get(right.key) ?? 0)); + pinned.sort((left, right) => (rankByKey.get(getKey(left)) ?? 0) - (rankByKey.get(getKey(right)) ?? 0)); return [...pinned, ...fresh]; } +/** Stable identity reader for a group, for order-pinning the list. */ +export function groupKey(group: ProcessGroup): string { + return group.key; +} + /** * Finds one group by key for the detail view, folding only the matching rows * through the same {@link buildGroupRow} path the list uses, so the diff --git a/src/renderer/domain/sample-history.ts b/src/renderer/domain/sample-history.ts index ab6c7eb..a386317 100644 --- a/src/renderer/domain/sample-history.ts +++ b/src/renderer/domain/sample-history.ts @@ -7,6 +7,9 @@ export const HISTORY_CAPACITY = 60; +/** Scalar graph sample; `null` draws as a gap rather than a fake 0. */ +export type HistorySample = number | null; + /** Appends `sample`, keeping at most `capacity` newest entries (oldest dropped). */ export function pushSample(history: T[], sample: T, capacity = HISTORY_CAPACITY): T[] { const next = [...history, sample]; diff --git a/src/renderer/lib/format.ts b/src/renderer/lib/format.ts index 8ac647e..87d50a4 100644 --- a/src/renderer/lib/format.ts +++ b/src/renderer/lib/format.ts @@ -168,7 +168,9 @@ export function formatCelsius(celsius: number): string { */ export function formatStartTime(epochMs: number): string { if (!Number.isFinite(epochMs) || epochMs <= 0) return UNAVAILABLE_TEXT; - return new Date(epochMs).toLocaleString(undefined, { + const date = new Date(epochMs); + if (Number.isNaN(date.getTime())) return UNAVAILABLE_TEXT; + return date.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", diff --git a/tests/unit/format.test.ts b/tests/unit/format.test.ts index 3e35521..7f4e639 100644 --- a/tests/unit/format.test.ts +++ b/tests/unit/format.test.ts @@ -182,6 +182,10 @@ describe("formatStartTime", () => { expect(formatStartTime(Number.NaN)).toBe(UNAVAILABLE_TEXT); }); + it("is unavailable for an epoch beyond the JS Date range", () => { + expect(formatStartTime(8.7e15)).toBe(UNAVAILABLE_TEXT); + }); + it("renders a real epoch as a non-empty local string", () => { // The exact text is locale/timezone dependent, so assert it produced a // concrete (non-unavailable) string rather than a specific format. diff --git a/tests/unit/process-action-service.test.ts b/tests/unit/process-action-service.test.ts index c87ce1c..c9fcfb2 100644 --- a/tests/unit/process-action-service.test.ts +++ b/tests/unit/process-action-service.test.ts @@ -116,6 +116,14 @@ describe("disabledReasonFor", () => { .toBe(ActionDisabledReason.ACTION_DISABLED_REASON_SELF); }); + it("quit blocks a direct child helper of MoStats (SELF)", () => { + const helper = makeRow({ pid: SOME_PID, startedAtUnixMs: 1, commandName: "MoStats Helper", parentPid: selfPid }); + expect(disabledReasonFor(QUIT, helper, selfPid, makeTarget(SOME_PID, 1))) + .toBe(ActionDisabledReason.ACTION_DISABLED_REASON_SELF); + expect(disabledReasonFor(FORCE_QUIT, helper, selfPid, makeTarget(SOME_PID, 1))) + .toBe(ActionDisabledReason.ACTION_DISABLED_REASON_SELF); + }); + it("quit blocks a session-critical process (PROTECTED)", () => { const row = makeRow({ pid: 80, startedAtUnixMs: 1, commandName: "WindowServer" }); expect(disabledReasonFor(QUIT, row, selfPid, makeTarget(80, 1))) diff --git a/tests/unit/process-detail.test.ts b/tests/unit/process-detail.test.ts index 1ce83cf..a8b17b8 100644 --- a/tests/unit/process-detail.test.ts +++ b/tests/unit/process-detail.test.ts @@ -109,6 +109,7 @@ describe("buildProcessDetail - totals", () => { expect(detail.total.state).toBe("ok"); // 4% + 8% = 12%, detail precision (two decimals). expect(detail.total.text).toBe("12.00%"); + expect(detail.totalValue).toBe(12); }); it("sums the selected metric across members (memory)", () => { @@ -116,6 +117,7 @@ describe("buildProcessDetail - totals", () => { expect(detail.totalSort).toBe("memory"); // 300 MB + 150 MB = 450 MB, detail precision (one extra decimal). expect(detail.total.text).toBe("450.0 MB"); + expect(detail.totalValue).toBe(450 * MB); }); it("sums thread count and CPU time across members", () => { @@ -196,6 +198,7 @@ describe("buildProcessDetail - group total mixed states", () => { const detail = buildProcessDetail(groupOf(rows, "app:/Applications/App.app"), "cpu", NO_ICONS); expect(detail.total.state).toBe("ok"); expect(detail.total.text).toBe("6.00%"); + expect(detail.totalValue).toBe(6); }); it("reports the total pending when no member is OK but one is still pending", () => { @@ -215,5 +218,6 @@ describe("buildProcessDetail - group total mixed states", () => { ]; const detail = buildProcessDetail(groupOf(rows, "app:/Applications/App.app"), "cpu", NO_ICONS); expect(detail.total.state).toBe("unavailable"); + expect(detail.totalValue).toBeNull(); }); }); diff --git a/tests/unit/process-list.test.ts b/tests/unit/process-list.test.ts index 767cec5..55fce14 100644 --- a/tests/unit/process-list.test.ts +++ b/tests/unit/process-list.test.ts @@ -6,7 +6,7 @@ import { findGroupByKey, isPending, okString, - pinGroupOrder, + pinOrder, projectProcessList, resolveSelection, rowCpu, @@ -15,6 +15,7 @@ import { rowMetric, rowNotResponding, rowPid, + sampleMetricsByKey, singleProcessGroup, SYSTEM_GROUP_KEY, } from "@/domain/process-list"; @@ -187,44 +188,57 @@ describe("projectProcessList - sorting", () => { }); }); -describe("pinGroupOrder", () => { - function tick(cpuByName: Record) { - const rows = Object.entries(cpuByName).map(([name, cpu], index) => - makeRow({ pid: index + 1, commandName: name, startedAtUnixMs: index + 1, cpuPercent: cpu }), - ); - return projectProcessList(makeSnapshot(rows), "cpu", ""); +describe("pinOrder", () => { + // Simple keyed items stand in for the ranked rows/groups the list pins: each + // tick rebuilds them with fresh `value`s, while the pinned key order holds. + interface Item { + key: string; + value: number; } + const item = (key: string, value: number): Item => ({ key, value }); + const keyOf = (it: Item) => it.key; - it("replays the pinned order over a re-ranked projection, keeping fresh values", () => { - const before = tick({ alpha: 50, beta: 40, gamma: 30 }); - const pinned = before.map((group) => group.key); + it("replays the pinned identity order over a re-ranked list, keeping fresh values", () => { + const pinned = ["a", "b", "c"]; + // Fresh tick ranked c-first, but the pinned order must win. + const ranked = [item("c", 90), item("a", 10), item("b", 20)]; - // Next tick: gamma spikes to the top; the pinned order must not move. - const next = tick({ alpha: 10, beta: 20, gamma: 90 }); - const replayed = pinGroupOrder(next, pinned); + const result = pinOrder(ranked, keyOf, pinned); - expect(replayed.map((group) => group.name)).toEqual(["alpha", "beta", "gamma"]); - // The group objects are the fresh ones - values keep ticking while pinned. - expect(replayed.map((group) => group.sortValue)).toEqual([10, 20, 90]); + expect(result.map(keyOf)).toEqual(["a", "b", "c"]); + // The items are the fresh ones - only the order is held, not the values. + expect(result.map((it) => it.value)).toEqual([10, 20, 90]); }); - it("appends new groups after the pinned rows and drops vanished ones", () => { - const pinned = tick({ alpha: 50, beta: 40, gamma: 30 }).map((group) => group.key); + it("appends new keys after the pinned ones, in their ranked order", () => { + const pinned = ["a", "b"]; + // d and c are new arrivals; d outranks c but both go below the pinned rows. + const ranked = [item("d", 95), item("b", 20), item("c", 50), item("a", 10)]; - // beta exited; delta arrived at the top of the ranking. - const nextRows = [ - makeRow({ pid: 1, commandName: "alpha", startedAtUnixMs: 1, cpuPercent: 10 }), - makeRow({ pid: 3, commandName: "gamma", startedAtUnixMs: 3, cpuPercent: 30 }), - makeRow({ pid: 4, commandName: "delta", startedAtUnixMs: 4, cpuPercent: 95 }), - ]; - const next = projectProcessList(makeSnapshot(nextRows), "cpu", ""); + const result = pinOrder(ranked, keyOf, pinned); + + expect(result.map(keyOf)).toEqual(["a", "b", "d", "c"]); + }); + + it("drops vanished pinned keys without leaving a gap", () => { + const pinned = ["a", "b", "c"]; + // b vanished this tick; a and c keep their relative pinned order. + const ranked = [item("c", 30), item("a", 10)]; - expect(pinGroupOrder(next, pinned).map((group) => group.name)).toEqual(["alpha", "gamma", "delta"]); + expect(pinOrder(ranked, keyOf, pinned).map(keyOf)).toEqual(["a", "c"]); }); - it("passes groups through unchanged when nothing is pinned", () => { - const groups = tick({ alpha: 50, beta: 40 }); - expect(pinGroupOrder(groups, [])).toBe(groups); + it("returns the input unchanged when nothing is pinned", () => { + const ranked = [item("a", 50), item("b", 40)]; + expect(pinOrder(ranked, keyOf, [])).toBe(ranked); + }); + + it("appends and drops together: vanished key gone, new key after the pinned ones", () => { + const pinned = ["a", "b", "c"]; + // b exited; d arrived at the top of the ranking. + const ranked = [item("d", 95), item("a", 10), item("c", 30)]; + + expect(pinOrder(ranked, keyOf, pinned).map(keyOf)).toEqual(["a", "c", "d"]); }); }); @@ -460,3 +474,35 @@ describe("projectProcessList - System group", () => { expect(resolved?.name).toBe("System"); }); }); + +describe("sampleMetricsByKey", () => { + it("sums a group's members under the group key, with per-member identity keys", () => { + const rows = [ + makeRow({ pid: 100, bundlePath: "/Applications/Chrome.app", startedAtUnixMs: 1, cpuPercent: 4, footprintBytes: 300 * MB }), + makeRow({ pid: 200, bundlePath: "/Applications/Chrome.app", startedAtUnixMs: 2, cpuPercent: 8, footprintBytes: 150 * MB }), + ]; + const samples = sampleMetricsByKey(makeSnapshot(rows)); + + expect(samples.get("app:/Applications/Chrome.app")).toEqual({ cpu: 12, memory: 450 * MB }); + expect(samples.get("pid:100:1")).toEqual({ cpu: 4, memory: 300 * MB }); + expect(samples.get("pid:200:2")).toEqual({ cpu: 8, memory: 150 * MB }); + }); + + it("keys an ungrouped process once (group key == identity)", () => { + const rows = [makeRow({ pid: 321, commandName: "tool", startedAtUnixMs: 5, cpuPercent: 7, footprintBytes: 20 * MB })]; + const samples = sampleMetricsByKey(makeSnapshot(rows)); + + expect(samples.get("pid:321:5")).toEqual({ cpu: 7, memory: 20 * MB }); + expect(samples.size).toBe(1); + }); + + it("records an unreadable metric as null, not a fabricated 0", () => { + const rows = [ + makeRow({ pid: 10, commandName: "x", startedAtUnixMs: 1, cpuStatus: FieldStatus.FIELD_STATUS_UNAVAILABLE, footprintBytes: 5 * MB }), + ]; + const sample = sampleMetricsByKey(makeSnapshot(rows)).get("pid:10:1"); + + expect(sample?.cpu).toBeNull(); + expect(sample?.memory).toBe(5 * MB); + }); +});