Repository: preactjs/preact-devtools
Pull request #474 (2023-04-01 03:34:01)

Feature: add commit filtering to the profiler
This surely needs some cleanup, but I wanted to put this out there as a feature suggestion! For our use cases, the profiler is very helpful, but is pretty hard to use when we have multiple hundreds of commits. This adds a filter to it, so you can specify which durations you're interested in looking at:

<img width="589" alt="Screenshot 2023-03-31 at 8 29 21 PM" src="https://user-images.githubusercontent.com/1518604/229263592-2080ccfd-eef0-4793-bc45-173292f3e378.png">

There's an empty state when you hide all of the commits, so it's clear what's going on:

<img width="347" alt="Screenshot 2023-03-31 at 8 29 39 PM" src="https://user-images.githubusercontent.com/1518604/229263602-bf061885-1430-431d-9edc-b7074b0eb592.png">

### Some edge cases handled:

* Navigating the commits via the "rendered at" panel should remove the filter. I think this is the simplest way, otherwise we'd need to filter all commits there too, and it gets a bit messy
* Changing the filter sets the first commit in the new, filtered list to the active one

### Implementation details

* I used a computed signal for filtered commits, which lets us share the logic for filtering easily
* I broke the FilterPopup into its own component now – I tried to keep it lightweight, but we could abstract the logic into it a bit more if we liked.

### Future work

It would be great to be able to filter additional things in or out of the commit timeline in the future so commits can be further searched. Maybe by the count of nodes in the commit?


Patch:
diff --git a/src/adapter/adapter/profiler.ts b/src/adapter/adapter/profiler.ts
index c89cd63..3d63485 100644
--- a/src/adapter/adapter/profiler.ts
+++ b/src/adapter/adapter/profiler.ts
@@ -1,13 +1,16 @@
 import { UpdateRects } from "./highlightUpdates";
 
-export interface ProfilerState {
+export interface RawTimelineFilterState {
+	filterCommitsUnder: number | false;
+}
+export type ProfilerState = {
 	isProfiling: boolean;
 	highlightUpdates: boolean;
 	pendingHighlightUpdates: Set<HTMLElement>;
 	updateRects: UpdateRects;
 	captureRenderReasons: boolean;
 	recordStats: boolean;
-}
+} & RawTimelineFilterState;
 
 export function newProfiler(): ProfilerState {
 	return {
@@ -17,5 +20,6 @@ export function newProfiler(): ProfilerState {
 		captureRenderReasons: false,
 		isProfiling: false,
 		recordStats: false,
+		filterCommitsUnder: false,
 	};
 }
diff --git a/src/adapter/hook.ts b/src/adapter/hook.ts
index f0255ae..126da10 100644
--- a/src/adapter/hook.ts
+++ b/src/adapter/hook.ts
@@ -2,6 +2,7 @@ import { Renderer } from "./renderer";
 import { ID } from "../view/store/types";
 import { createAdapter, InspectData, UpdateType } from "./adapter/adapter";
 import { DEFAULT_FIlTERS, FilterState, RawFilterState } from "./adapter/filter";
+import { RawTimelineFilterState } from "./adapter/profiler";
 import { Options } from "preact";
 import { createRenderer, RendererConfig } from "./shared/renderer";
 import { setupOptionsV10 } from "./10/options";
@@ -43,6 +44,7 @@ export interface DevtoolEvents {
 	"start-highlight-updates": null;
 	"stop-highlight-updates": null;
 	"update-filter": RawFilterState;
+	"update-timeline-filter": RawTimelineFilterState;
 	"load-host-selection": null;
 	"inspect-host-node": null;
 	"view-source": ID;
diff --git a/src/view/components/FilterPopup/FilterPopup.module.css b/src/view/components/FilterPopup/FilterPopup.module.css
new file mode 100644
index 0000000..b3be93b
--- /dev/null
+++ b/src/view/components/FilterPopup/FilterPopup.module.css
@@ -0,0 +1,105 @@
+.filterBtnWrapper {
+	position: relative;
+	display: flex;
+	height: 100%;
+	align-items: center;
+}
+
+.filter {
+	position: absolute;
+	top: 90%;
+	right: -0.5rem;
+	border-top: none;
+	border: 0.0625rem solid var(--color-border);
+	background: var(--color-bg);
+	width: 12rem;
+	z-index: 10;
+	padding: 0.5rem 0.5rem 0.5rem 0.75rem;
+}
+
+.filter form {
+	margin: 0;
+}
+
+.filter label {
+	cursor: pointer;
+}
+
+.filterRow {
+	display: flex;
+	height: 1.375rem;
+	align-items: center;
+}
+
+.filterCheck {
+	width: 1.25rem;
+	display: flex;
+	flex: 0 0 auto;
+	justify-content: flex-start;
+	align-items: center;
+}
+
+.filterCheck svg {
+	width: 1.25rem;
+}
+
+.filterCheck input {
+	opacity: 0;
+	position: absolute;
+	left: 0;
+}
+
+.filterCheck input:checked + svg {
+	color: var(--color-selected-bg);
+}
+
+.filterValue {
+	text-align: left;
+}
+
+.filterValueText {
+	padding-left: 0.25rem;
+	font-size: 0.875rem;
+}
+
+.filterAdd {
+	display: flex;
+	align-items: center;
+	font-size: 0.875rem;
+}
+
+.filterName {
+	display: flex;
+	align-items: center;
+	width: 100%;
+	background: var(--color-props-input-bg);
+	border: none;
+	color: var(--color-text-input);
+	padding: 0.15rem 0.3rem;
+	font-size: 0.75rem;
+}
+
+.filterActions {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+}
+
+.filterSubmitBtn {
+	display: block;
+	background: var(--color-selected-bg);
+	color: var(--color-selected-text);
+	border: none;
+	border-radius: 0.1875rem;
+	font-size: 0.875rem;
+	padding: 0.25rem 0.5rem;
+}
+
+.filterSubmitBtn svg {
+	margin-right: 0.25rem;
+}
+
+.filterInputNumber {
+	width: 3rem;
+	padding: 0.15rem;
+}
diff --git a/src/view/components/FilterPopup/FilterPopup.tsx b/src/view/components/FilterPopup/FilterPopup.tsx
new file mode 100644
index 0000000..113bc20
--- /dev/null
+++ b/src/view/components/FilterPopup/FilterPopup.tsx
@@ -0,0 +1,106 @@
+import { h } from "preact";
+import { ComponentChildren } from "preact";
+import { Icon } from "../icons";
+import s from "./FilterPopup.module.css";
+
+export function FilterCheck({
+	checked,
+	label,
+	onInput,
+}: {
+	checked: boolean;
+	onInput: (checked: boolean) => void;
+	label: string;
+}) {
+	return (
+		<label class={s.filterRow}>
+			<span class={s.filterCheck}>
+				<input
+					type="checkbox"
+					checked={checked}
+					onInput={e => onInput((e.target as any).checked)}
+				/>
+				<Icon icon={checked ? "checkbox-checked" : "checkbox-unchecked"} />
+			</span>
+			<span class={`${s.filterValue} ${s.filterValueText}`}>{label}</span>
+		</label>
+	);
+}
+
+export function FilterNumber({
+	value,
+	label,
+	onInput,
+	units,
+	defaultValue,
+}: {
+	value: number | false;
+	onInput: (value: number | false) => void;
+	label: string;
+	units?: string;
+	defaultValue: number;
+}) {
+	const checked = value !== false;
+	return (
+		<label class={s.filterRow}>
+			<span class={s.filterCheck}>
+				<input
+					type="checkbox"
+					checked={checked}
+					onInput={e =>
+						onInput((e.target as any).checked ? defaultValue : (false as const))
+					}
+				/>
+				<Icon icon={checked ? "checkbox-checked" : "checkbox-unchecked"} />
+			</span>
+			<span class={`${s.filterValue} ${s.filterValueText}`}>
+				{label}{" "}
+				<input
+					type="number"
+					class={s.filterInputNumber}
+					value={checked ? value : defaultValue}
+					onInput={e => {
+						onInput((e.target as any).value);
+					}}
+				></input>{" "}
+				{units}
+			</span>
+		</label>
+	);
+}
+
+export function FilterPopup({
+	children,
+	onFiltersSubmit,
+	filterActions,
+	className,
+}: {
+	children: ComponentChildren;
+	filterActions?: ComponentChildren;
+	onFiltersSubmit: () => void;
+	className?: string;
+}) {
+	return (
+		<div class={`${s.filter} ${className}`} data-testid="filter-popup">
+			<form
+				onSubmit={e => {
+					e.preventDefault();
+					onFiltersSubmit();
+				}}
+			>
+				{children}
+				<div class={s.vSep} />
+				<div class={s.filterActions}>
+					{filterActions}
+					<button
+						type="submit"
+						class={s.filterSubmitBtn}
+						data-testid="filter-update"
+					>
+						Update
+					</button>
+				</div>
+			</form>
+		</div>
+	);
+}
diff --git a/src/view/components/elements/TreeBar.module.css b/src/view/components/elements/TreeBar.module.css
index 54a2318..f1623d0 100644
--- a/src/view/components/elements/TreeBar.module.css
+++ b/src/view/components/elements/TreeBar.module.css
@@ -56,107 +56,6 @@
 	color: var(--color-text-empty);
 }
 
-.filterBtnWrapper {
-	position: relative;
-	display: flex;
-	height: 100%;
-	align-items: center;
-}
-
-.filter {
-	position: absolute;
-	top: 90%;
-	right: -0.5rem;
-	border-top: none;
-	border: .0625rem solid var(--color-border);
-	background: var(--color-bg);
-	width: 12rem;
-	z-index: 10;
-	padding: 0.5rem 0.5rem 0.5rem 0.75rem;
-}
-
-.filter form {
-	margin: 0;
-}
-
-.filter label {
-	cursor: pointer;
-}
-
-.filterRow {
-	display: flex;
-	height: 1.375rem;
-	align-items: center;
-}
-
-.filterCheck {
-	width: 1.25rem;
-	display: flex;
-	flex: 0 0 auto;
-	justify-content: flex-start;
-	align-items: center;
-}
-
-.filterCheck svg {
-	width: 1.25rem;
-}
-
-.filterCheck input {
-	opacity: 0;
-	position: absolute;
-	left: 0;
-}
-
-.filterCheck input:checked + svg {
-	color: var(--color-selected-bg);
-}
-
-.filterValue {
-	text-align: left;
-}
-
-.filterValueText {
-	padding-left: .25rem;
-	font-size: .875rem;
-}
-
-.filterAdd {
-	display: flex;
-	align-items: center;
-	font-size: .875rem;
-}
-
-.filterName {
-	display: flex;
-	align-items: center;
-	width: 100%;
-	background: var(--color-props-input-bg);
-	border: none;
-	color: var(--color-text-input);
-	padding: 0.15rem 0.3rem;
-	font-size: .75rem;
-}
-
-.filterActions {
-	display: flex;
-	justify-content: space-between;
-	align-items: center;
-}
-
-.filterSubmitBtn {
-	display: block;
-	background: var(--color-selected-bg);
-	color: var(--color-selected-text);
-	border: none;
-	border-radius: 0.1875rem;
-	font-size: .875rem;
-	padding: 0.25rem 0.5rem;
-}
-
-.filterSubmitBtn svg {
-	margin-right: 0.25rem;
-}
-
 .removeWrapper {
 	width: 1.5rem;
 	display: flex;
diff --git a/src/view/components/elements/TreeBar.tsx b/src/view/components/elements/TreeBar.tsx
index 3a0bfa4..338c510 100644
--- a/src/view/components/elements/TreeBar.tsx
+++ b/src/view/components/elements/TreeBar.tsx
@@ -7,6 +7,8 @@ import { useStore } from "../../store/react-bindings";
 import s from "./TreeBar.module.css";
 import { useSearch } from "../../store/search";
 import { OutsideClick } from "../OutsideClick";
+import { FilterCheck, FilterPopup } from "../FilterPopup/FilterPopup";
+import filterBarStyles from "../FilterPopup/FilterPopup.module.css";
 
 export function TreeBar() {
 	const store = useStore();
@@ -92,7 +94,7 @@ export function TreeBar() {
 			<div class={s.btnWrapper}>
 				<OutsideClick
 					onClick={() => setFilterVisible(false)}
-					class={s.filterBtnWrapper}
+					class={filterBarStyles.filterBtnWrapper}
 				>
 					<IconBtn
 						title="Filter Components"
@@ -102,38 +104,14 @@ export function TreeBar() {
 					>
 						<Icon icon="filter-list" />
 					</IconBtn>
-					{filterVisible && <FilterPopup />}
+					{filterVisible && <TreeFilterPopup />}
 				</OutsideClick>
 			</div>
 		</Actions>
 	);
 }
 
-function FilterCheck({
-	checked,
-	label,
-	onInput,
-}: {
-	checked: boolean;
-	onInput: (checked: boolean) => void;
-	label: string;
-}) {
-	return (
-		<label class={s.filterRow}>
-			<span class={s.filterCheck}>
-				<input
-					type="checkbox"
-					checked={checked}
-					onInput={e => onInput((e.target as any).checked)}
-				/>
-				<Icon icon={checked ? "checkbox-checked" : "checkbox-unchecked"} />
-			</span>
-			<span class={`${s.filterValue} ${s.filterValueText}`}>{label}</span>
-		</label>
-	);
-}
-
-export function FilterPopup() {
+export function TreeFilterPopup() {
 	const store = useStore();
 	const [filterDom, setFilterDom] = useState(store.filter.filterDom.value);
 	const [filterFragment, setFilterFragment] = useState(
@@ -147,124 +125,112 @@ export function FilterPopup() {
 	const [filters, setFilters] = useState(store.filter.filters.value);
 
 	return (
-		<div class={s.filter} data-testid="filter-popup">
-			<form
-				onSubmit={e => {
-					e.preventDefault();
-
-					store.filter.filterDom.value = filterDom;
-					store.filter.filterFragment.value = filterFragment;
-					store.filter.filterRoot.value = filterRoot;
-					store.filter.filterHoc.value = filterHoc;
-					store.filter.filterTextSignal.value = filterTextSignal;
+		<FilterPopup
+			onFiltersSubmit={() => {
+				store.filter.filterDom.value = filterDom;
+				store.filter.filterFragment.value = filterFragment;
+				store.filter.filterRoot.value = filterRoot;
+				store.filter.filterHoc.value = filterHoc;
+				store.filter.filterTextSignal.value = filterTextSignal;
 
-					store.filter.filters.value = filters;
+				store.filter.filters.value = filters;
 
-					store.filter.submit();
-				}}
-			>
-				{/* Native filters */}
-				<FilterCheck
-					label="Roots"
-					onInput={checked => setFilterRoot(checked)}
-					checked={filterRoot}
-				/>
-				<FilterCheck
-					label="Fragments"
-					onInput={checked => setFilterFragment(checked)}
-					checked={filterFragment}
-				/>
-				<FilterCheck
-					label="HOC-Components"
-					onInput={checked => setFilterHoc(checked)}
-					checked={filterHoc}
-				/>
-				<FilterCheck
-					label="DOM nodes"
-					onInput={checked => setFilterDom(checked)}
-					checked={filterDom}
-				/>
-				<FilterCheck
-					label="Text Signal nodes"
-					onInput={checked => setFilterTextSignal(checked)}
-					checked={filterTextSignal}
-				/>
-				{/* Custom user filters */}
-				{filters.map((x, i) => {
-					return (
-						<div key={i} class={s.filterRow}>
-							<label class={s.filterCheck}>
-								<input
-									type="checkbox"
-									checked={x.enabled}
-									onInput={e => {
-										const copy = [...filters];
-										copy[i].enabled = (e.target as any).checked;
-										setFilters(copy);
-									}}
-								/>
-								<Icon
-									icon={x.enabled ? "checkbox-checked" : "checkbox-unchecked"}
-								/>
-							</label>
-							<span class={s.filterValue}>
-								<input
-									className={s.filterName}
-									type="text"
-									placeholder="MyComponent"
-									value={x.value}
-									onInput={e => {
+				store.filter.submit();
+			}}
+			filterActions={
+				<IconBtn
+					styling="secondary"
+					title="Add new filter"
+					testId="add-filter"
+					onClick={() =>
+						setFilters([...filters, { enabled: false, value: "" }])
+					}
+				>
+					<span class={filterBarStyles.filterAdd}>
+						<span class={filterBarStyles.filterCheck}>
+							<Icon icon="add-circle" />
+						</span>
+						Add filter
+					</span>
+				</IconBtn>
+			}
+		>
+			{/* Native filters */}
+			<FilterCheck
+				label="Roots"
+				onInput={checked => setFilterRoot(checked)}
+				checked={filterRoot}
+			/>
+			<FilterCheck
+				label="Fragments"
+				onInput={checked => setFilterFragment(checked)}
+				checked={filterFragment}
+			/>
+			<FilterCheck
+				label="HOC-Components"
+				onInput={checked => setFilterHoc(checked)}
+				checked={filterHoc}
+			/>
+			<FilterCheck
+				label="DOM nodes"
+				onInput={checked => setFilterDom(checked)}
+				checked={filterDom}
+			/>
+			<FilterCheck
+				label="Text Signal nodes"
+				onInput={checked => setFilterTextSignal(checked)}
+				checked={filterTextSignal}
+			/>
+			{/* Custom user filters */}
+			{filters.map((x, i) => {
+				return (
+					<div key={i} class={filterBarStyles.filterRow}>
+						<label class={filterBarStyles.filterCheck}>
+							<input
+								type="checkbox"
+								checked={x.enabled}
+								onInput={e => {
+									const copy = [...filters];
+									copy[i].enabled = (e.target as any).checked;
+									setFilters(copy);
+								}}
+							/>
+							<Icon
+								icon={x.enabled ? "checkbox-checked" : "checkbox-unchecked"}
+							/>
+						</label>
+						<span class={filterBarStyles.filterValue}>
+							<input
+								className={filterBarStyles.filterName}
+								type="text"
+								placeholder="MyComponent"
+								value={x.value}
+								onInput={e => {
+									const copy = [...filters];
+									copy[i].value = (e.target as any).value;
+									setFilters(copy);
+								}}
+							/>
+						</span>
+						<span class={filterBarStyles.removeWrapper}>
+							<IconBtn
+								title="Remove filter"
+								styling="secondary"
+								onClick={() => {
+									const idx = filters.indexOf(x);
+									if (idx > -1) {
 										const copy = [...filters];
-										copy[i].value = (e.target as any).value;
+										copy.splice(idx, 1);
 										setFilters(copy);
-									}}
-								/>
-							</span>
-							<span class={s.removeWrapper}>
-								<IconBtn
-									title="Remove filter"
-									styling="secondary"
-									onClick={() => {
-										const idx = filters.indexOf(x);
-										if (idx > -1) {
-											const copy = [...filters];
-											copy.splice(idx, 1);
-											setFilters(copy);
-										}
-									}}
-								>
-									<Icon icon="remove" />
-								</IconBtn>
-							</span>
-						</div>
-					);
-				})}
-				<div class={s.vSep} />
-				<div class={s.filterActions}>
-					<IconBtn
-						styling="secondary"
-						title="Add new filter"
-						testId="add-filter"
-						onClick={() =>
-							setFilters([...filters, { enabled: false, value: "" }])
-						}
-					>
-						<span class={s.filterAdd}>
-							<span class={s.filterCheck}>
-								<Icon icon="add-circle" />
-							</span>
-							Add filter
+									}
+								}}
+							>
+								<Icon icon="remove" />
+							</IconBtn>
 						</span>
-					</IconBtn>
-					<button
-						type="submit"
-						class={s.filterSubmitBtn}
-						data-testid="filter-update"
-					>
-						Update
-					</button>
-				</div>
-			</form>
-		</div>
+					</div>
+				);
+			})}
+		</FilterPopup>
 	);
 }
diff --git a/src/view/components/profiler/components/CommitTimeline/CommitTimeline.tsx b/src/view/components/profiler/components/CommitTimeline/CommitTimeline.tsx
index 92af6af..f455eea 100644
--- a/src/view/components/profiler/components/CommitTimeline/CommitTimeline.tsx
+++ b/src/view/components/profiler/components/CommitTimeline/CommitTimeline.tsx
@@ -73,7 +73,7 @@ function calcSize(
 
 export interface CommitTimelineProps {
 	selected: number;
-	items: number[];
+	items: { percent: number; index: number }[];
 	onChange: (i: number) => void;
 }
 
@@ -89,13 +89,15 @@ export function CommitTimeline(props: CommitTimelineProps) {
 	const pane = useRef<HTMLDivElement>(null);
 	const paneContainerRef = useRef<HTMLDivElement>(null);
 
+	const selectedItemIndex = items.findIndex(item => item.index === selected);
+
 	useEffect(() => {
 		if (pane.current) {
 			const active = pane.current.querySelector("[data-selected]");
 			// JSDOM doesn't support scrollIntoView
 			if (active && active.scrollIntoView) active.scrollIntoView();
 		}
-	}, [selected]);
+	}, [selectedItemIndex]);
 
 	useResize(
 		() => {
@@ -105,31 +107,35 @@ export function CommitTimeline(props: CommitTimelineProps) {
 				pane,
 				paneContainerRef,
 				items.length,
-				selected,
+				selectedItemIndex,
 			);
 			setPaneWidth(sizes.pane);
 			setViewportWidth(sizes.viewport);
 			setOffset(sizes.offset);
 		},
-		[pane, container, paneContainerRef, inner, items.length, selected],
+		[pane, container, paneContainerRef, inner, items.length, selectedItemIndex],
 		true,
 	);
 
 	const onPrev = useCallback(() => {
-		const next = selected - 1;
-		onChange(next < 0 ? items.length - 1 : next);
-	}, [selected]);
+		const next = selectedItemIndex - 1;
+		const nextWrapped = next < 0 ? items.length - 1 : next;
+
+		onChange(items[nextWrapped].index);
+	}, [selectedItemIndex]);
 
 	const onNext = useCallback(() => {
-		const next = selected + 1;
-		onChange(next > items.length - 1 ? 0 : next);
-	}, [selected]);
+		const next = selectedItemIndex + 1;
+		const nextWrapped = next > items.length - 1 ? 0 : next;
+		onChange(items[nextWrapped].index);
+	}, [selectedItemIndex]);
 
 	if (items.length === 0) {
-		return null;
+		// Useful just for layout purposes
+		return <div class="commit-timeline"></div>;
 	}
 
-	const selectedCount = "" + (selected + 1);
+	const selectedCount = "" + (selectedItemIndex + 1);
 	const itemsCountLen = ("" + items.length).length;
 	const leading = selectedCount.padStart(itemsCountLen, "0");
 
@@ -141,7 +147,7 @@ export function CommitTimeline(props: CommitTimelineProps) {
 						{leading.slice(0, itemsCountLen - selectedCount.length)}
 					</span>
 					<span data-testid="commit-page-info">
-						{selected + 1} / {items.length}
+						{selectedItemIndex + 1} / {items.length}
 					</span>
 				</div>
 				<button
@@ -162,13 +168,13 @@ export function CommitTimeline(props: CommitTimelineProps) {
 						ref={pane}
 						style={`width: ${paneWidth}px; transform: translate3d(${offset}px, 0, 0);`}
 					>
-						{items.map((x, i) => {
+						{items.map(x => {
 							return (
 								<CommitItem
-									key={x}
-									onClick={() => onChange(i)}
-									selected={i === selected}
-									percent={x}
+									key={x.index}
+									onClick={() => onChange(x.index)}
+									selected={x.index === selected}
+									percent={x.percent}
 								/>
 							);
 						})}
diff --git a/src/view/components/profiler/components/RenderedAt/RenderedAt.tsx b/src/view/components/profiler/components/RenderedAt/RenderedAt.tsx
index c154d5b..084dc3d 100644
--- a/src/view/components/profiler/components/RenderedAt/RenderedAt.tsx
+++ b/src/view/components/profiler/components/RenderedAt/RenderedAt.tsx
@@ -59,9 +59,10 @@ export function RenderedAt() {
 											key={node.index}
 											class="rendered-at-item"
 											data-active={commitIdx === node.index}
-											onClick={() =>
-												(store.profiler.activeCommitIdx.value = node.index)
-											}
+											onClick={() => {
+												store.profiler.activeCommitIdx.value = node.index;
+												store.profiler.filterCommitsUnder.value = false;
+											}}
 										>
 											<span>
 												Commit #{node.index} for {formatTime(node.selfDuration)}
diff --git a/src/view/components/profiler/components/TimelineBar/TimelineBar.module.css b/src/view/components/profiler/components/TimelineBar/TimelineBar.module.css
new file mode 100644
index 0000000..bb55051
--- /dev/null
+++ b/src/view/components/profiler/components/TimelineBar/TimelineBar.module.css
@@ -0,0 +1,14 @@
+.filterPopup {
+	width: 18rem;
+}
+
+.timelineActions {
+	display: flex;
+	justify-content: space-between;
+	flex: 1 1 auto;
+}
+
+.timelineCommitsEmpty {
+	font-size: 0.8rem;
+	padding: 0 0.3rem;
+}
diff --git a/src/view/components/profiler/components/TimelineBar/TimelineBar.tsx b/src/view/components/profiler/components/TimelineBar/TimelineBar.tsx
index a7335b0..b5c0900 100644
--- a/src/view/components/profiler/components/TimelineBar/TimelineBar.tsx
+++ b/src/view/components/profiler/components/TimelineBar/TimelineBar.tsx
@@ -3,8 +3,8 @@ import { Actions, ActionSeparator } from "../../../Actions";
 import { CommitTimeline } from "../CommitTimeline/CommitTimeline";
 import { IconBtn } from "../../../IconBtn";
 import { useStore } from "../../../../store/react-bindings";
-import s from "../../../elements/TreeBar.module.css";
-import { useCallback } from "preact/hooks";
+import treeBarStyles from "../../../elements/TreeBar.module.css";
+import { useCallback, useState } from "preact/hooks";
 import { FlameGraphMode } from "../../flamegraph/FlameGraphMode";
 import {
 	getCommitInitalSelectNodeId,
@@ -14,13 +14,21 @@ import {
 } from "../../data/commits";
 import { Icon } from "../../../icons";
 import { useComputed } from "@preact/signals";
+import { OutsideClick } from "../../../OutsideClick";
+import { FilterNumber, FilterPopup } from "../../../FilterPopup/FilterPopup";
+import filterBarStyles from "../../../FilterPopup/FilterPopup.module.css";
+import s from "./TimelineBar.module.css";
 
 export function TimelineBar() {
 	const store = useStore();
 	const commits = store.profiler.commits.value;
+	const filteredCommits = store.profiler.filteredCommits.value;
 	const isRecording = store.profiler.isRecording.value;
 	const isSupported = store.profiler.isSupported.value;
 	const selectedCommit = store.profiler.activeCommitIdx.value;
+
+	const [filterVisible, setFilterVisible] = useState(false);
+
 	const stats = useComputed(() => {
 		return {
 			max: Math.max(16, ...store.profiler.commits.value.map(x => x.duration)),
@@ -66,10 +74,10 @@ export function TimelineBar() {
 
 	return (
 		<Actions>
-			<div class={s.btnWrapper}>
+			<div class={treeBarStyles.btnWrapper}>
 				<RecordBtn />
 			</div>
-			<div class={s.btnWrapper}>
+			<div class={treeBarStyles.btnWrapper}>
 				<IconBtn
 					title="Reload and profile"
 					disabled={!isSupported || isRecording}
@@ -79,7 +87,7 @@ export function TimelineBar() {
 					<Icon icon="refresh" />
 				</IconBtn>
 			</div>
-			<div class={s.btnWrapper}>
+			<div class={treeBarStyles.btnWrapper}>
 				<IconBtn
 					title="Clear profiling data"
 					disabled={!isSupported || commits.length === 0 || isRecording}
@@ -93,20 +101,71 @@ export function TimelineBar() {
 			<ActionSeparator />
 			{isSupported && !isRecording && (
 				<CommitTimeline
-					items={commits.map(commit => {
+					items={filteredCommits.map(commit => {
 						const percent =
 							((commit.duration - stats.min) * 100) /
 							(stats.max - stats.min || 0.1);
-						return percent;
+						return { percent, index: commit.index };
 					})}
 					selected={selectedCommit}
 					onChange={onCommitChange}
 				/>
 			)}
+			{filteredCommits.length === 0 && commits.length > 0 && (
+				<span class={s.timelineCommitsEmpty}>
+					{commits.length} commits hidden
+				</span>
+			)}
+			{isSupported && !isRecording && commits.length !== 0 && (
+				<OutsideClick
+					onClick={() => setFilterVisible(false)}
+					class={filterBarStyles.filterBtnWrapper}
+				>
+					<IconBtn
+						title="Filter Commits"
+						active={filterVisible}
+						testId="filter-menu-button"
+						onClick={() => setFilterVisible(!filterVisible)}
+					>
+						<Icon icon="filter-list" />
+					</IconBtn>
+					{filterVisible && <TimelineFilterPopup />}
+				</OutsideClick>
+			)}
 		</Actions>
 	);
 }
 
+export function TimelineFilterPopup() {
+	const store = useStore();
+	const [filterCommitsUnder, setFilterCommitsUnder] = useState(
+		store.profiler.filterCommitsUnder.value,
+	);
+
+	return (
+		<FilterPopup
+			className={s.filterPopup}
+			onFiltersSubmit={() => {
+				store.profiler.filterCommitsUnder.value = filterCommitsUnder
+					? filterCommitsUnder
+					: false;
+				store.profiler.activeCommitIdx.value =
+					(store.profiler.filteredCommits.value ?? [])[0]?.index ?? 0;
+			}}
+		>
+			<FilterNumber
+				value={filterCommitsUnder}
+				label="Hide commits under"
+				units="ms"
+				onInput={value => {
+					setFilterCommitsUnder(value);
+				}}
+				defaultValue={0}
+			/>
+		</FilterPopup>
+	);
+}
+
 export function RecordBtn() {
 	const store = useStore();
 	const isRecording = store.profiler.isRecording.value;
diff --git a/src/view/components/profiler/data/commits.ts b/src/view/components/profiler/data/commits.ts
index 1ec78dc..2306714 100644
--- a/src/view/components/profiler/data/commits.ts
+++ b/src/view/components/profiler/data/commits.ts
@@ -69,6 +69,10 @@ export interface ProfilerState {
 	// Flamegraph mode
 	flamegraphNodes: Signal<Map<ID, NodeTransform>>;
 	rankedNodes: Signal<NodeTransform[]>;
+
+	// Filtering
+	filterCommitsUnder: Signal<number | false>;
+	filteredCommits: Signal<(CommitData & { index: number })[]>;
 }
 
 function getMaxSelfDurationNode(commit: CommitData) {
@@ -128,6 +132,18 @@ export function createProfiler(): ProfilerState {
 			: null;
 	});
 
+	// Filtering
+	const filterCommitsUnder = signal<false | number>(false);
+	const filteredCommits = computed(() => {
+		return commits.value
+			.map((commit, index) => ({ ...commit, index }))
+			.filter(commit =>
+				filterCommitsUnder.value === false
+					? true
+					: commit.duration >= filterCommitsUnder.value,
+			);
+	});
+
 	// Flamegraph
 	const flamegraphType = signal(FlamegraphType.FLAMEGRAPH);
 
@@ -184,6 +200,10 @@ export function createProfiler(): ProfilerState {
 		selectedNodeId,
 		selectedNode,
 
+		// Filtering
+		filterCommitsUnder,
+		filteredCommits,
+
 		// Rendering
 		flamegraphType,
 		flamegraphNodes,


Test patch:
diff --git a/src/view/components/profiler/components/CommitTimeline/CommitTimeline.test.tsx b/src/view/components/profiler/components/CommitTimeline/CommitTimeline.test.tsx
index f9f053c..28b654f 100644
--- a/src/view/components/profiler/components/CommitTimeline/CommitTimeline.test.tsx
+++ b/src/view/components/profiler/components/CommitTimeline/CommitTimeline.test.tsx
@@ -17,10 +17,18 @@ export function renderTest(ui: ComponentChild) {
 
 const noop = () => null;
 
+function makeCommits(arr: number[]) {
+	return arr.map((a, index) => ({ percent: a, index }));
+}
+
 describe("CommitTimeline", () => {
 	it("should render 4 items", () => {
 		const { $$ } = renderTest(
-			<CommitTimeline onChange={noop} selected={0} items={[20, 80, 10, 10]} />,
+			<CommitTimeline
+				onChange={noop}
+				selected={0}
+				items={makeCommits([20, 80, 10, 10])}
+			/>,
 		);
 		expect($$('[data-testid="commit-item"]').length).to.equal(4);
 	});
@@ -28,7 +36,11 @@ describe("CommitTimeline", () => {
 	it("should wrap around if selection === 0", () => {
 		const spy = sinon.spy();
 		const { $ } = renderTest(
-			<CommitTimeline onChange={spy} selected={0} items={[20, 80, 10, 10]} />,
+			<CommitTimeline
+				onChange={spy}
+				selected={0}
+				items={makeCommits([20, 80, 10, 10])}
+			/>,
 		);
 
 		const btn = $("[data-testid='prev-commit']")!;
@@ -39,7 +51,11 @@ describe("CommitTimeline", () => {
 	it("should wrap around if selection === items.length", () => {
 		const spy = sinon.spy();
 		const { $ } = renderTest(
-			<CommitTimeline onChange={spy} selected={3} items={[20, 80, 10, 10]} />,
+			<CommitTimeline
+				onChange={spy}
+				selected={3}
+				items={makeCommits([20, 80, 10, 10])}
+			/>,
 		);
 
 		const btn = $("[data-testid='next-commit']")!;