Skip to content

Commit c0385e8

Browse files
var-ggclaude
andcommitted
feat(chips): virtualize the Repo/Author/Branch filter dropdowns
Opening a filter chip mounted every row at once — a 10k-repo RepoChip built tens of thousands of DOM nodes synchronously and froze the panel. Add a shared fixed-row-height virtual list (VirtualChipList): only the rows intersecting the viewport plus overscan are mounted, so opening a dropdown is O(viewport) regardless of how many repos/authors/branches were scanned. Section headers and the "All ~" buttons are known-height special rows in the flattened row array; the search query drives a scroll-to-top reset. Drop the now-dead .chip-section-header:first-child rule (every header is its own virtual row now). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 04d82ec commit c0385e8

5 files changed

Lines changed: 412 additions & 193 deletions

File tree

src/components/AuthorsChip.tsx

Lines changed: 72 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22

33
import type { AuthorTally } from "../types";
44
import { ChipDropdown } from "./ChipDropdown";
5+
import { VirtualChipList, type VirtualChipRow } from "./VirtualChipList";
6+
7+
// Virtualised-row heights (px) — mirror the box heights in styles.css.
8+
const ITEM_H = 26; // .chip-item — one-line author entry
9+
const EMPTY_H = 34; // .chip-empty — "No authors match."
510

611
interface Props {
712
open: boolean;
@@ -41,19 +46,72 @@ export function AuthorsChip({
4146
? selected[0]
4247
: `${selected.length} authors`;
4348

44-
function toggle(name: string) {
45-
if (selected === "all") {
46-
// Switching from "all" to specific selection.
47-
const others = authors.map((a) => a.name).filter((n) => n !== name);
48-
onChange(others);
49-
return;
49+
const toggle = useCallback(
50+
(name: string) => {
51+
if (selected === "all") {
52+
// Switching from "all" to specific selection.
53+
const others = authors.map((a) => a.name).filter((n) => n !== name);
54+
onChange(others);
55+
return;
56+
}
57+
const set = new Set(selected);
58+
if (set.has(name)) set.delete(name);
59+
else set.add(name);
60+
const next = Array.from(set);
61+
onChange(next.length === authors.length ? "all" : next);
62+
},
63+
[selected, authors, onChange],
64+
);
65+
66+
// Flatten into virtual rows: the "All authors" reset button, then one
67+
// row per author, then the empty-state line when nothing matches.
68+
const rows = useMemo<VirtualChipRow[]>(() => {
69+
const out: VirtualChipRow[] = [];
70+
out.push({
71+
key: "__all",
72+
height: ITEM_H,
73+
render: () => (
74+
<button
75+
type="button"
76+
className={"chip-item" + (selected === "all" ? " active" : "")}
77+
onClick={() => {
78+
onChange("all");
79+
onClose();
80+
}}
81+
>
82+
<span className="chip-item-name">All authors</span>
83+
</button>
84+
),
85+
});
86+
for (const a of filtered) {
87+
const { name, count } = a;
88+
const isSelected =
89+
selected === "all" || (selected as string[]).includes(name);
90+
out.push({
91+
key: "author:" + name,
92+
height: ITEM_H,
93+
render: () => (
94+
<button
95+
type="button"
96+
className={"chip-item" + (isSelected ? " checked" : "")}
97+
onClick={() => toggle(name)}
98+
>
99+
<span className="chip-check">{isSelected ? "✓" : ""}</span>
100+
<span className="chip-item-name">{name}</span>
101+
<span className="chip-item-count">{count}</span>
102+
</button>
103+
),
104+
});
50105
}
51-
const set = new Set(selected);
52-
if (set.has(name)) set.delete(name);
53-
else set.add(name);
54-
const next = Array.from(set);
55-
onChange(next.length === authors.length ? "all" : next);
56-
}
106+
if (filtered.length === 0) {
107+
out.push({
108+
key: "__empty",
109+
height: EMPTY_H,
110+
render: () => <div className="chip-empty">No authors match.</div>,
111+
});
112+
}
113+
return out;
114+
}, [filtered, selected, onChange, onClose, toggle]);
57115

58116
return (
59117
<ChipDropdown
@@ -72,37 +130,7 @@ export function AuthorsChip({
72130
placeholder="Search authors…"
73131
/>
74132
</div>
75-
<div className="chip-list">
76-
<button
77-
type="button"
78-
className={"chip-item" + (selected === "all" ? " active" : "")}
79-
onClick={() => {
80-
onChange("all");
81-
onClose();
82-
}}
83-
>
84-
<span className="chip-item-name">All authors</span>
85-
</button>
86-
{filtered.map((a) => {
87-
const isSelected =
88-
selected === "all" || (selected as string[]).includes(a.name);
89-
return (
90-
<button
91-
key={a.name}
92-
type="button"
93-
className={"chip-item" + (isSelected ? " checked" : "")}
94-
onClick={() => toggle(a.name)}
95-
>
96-
<span className="chip-check">{isSelected ? "✓" : ""}</span>
97-
<span className="chip-item-name">{a.name}</span>
98-
<span className="chip-item-count">{a.count}</span>
99-
</button>
100-
);
101-
})}
102-
{filtered.length === 0 && (
103-
<div className="chip-empty">No authors match.</div>
104-
)}
105-
</div>
133+
<VirtualChipList rows={rows} resetKey={query} />
106134
</ChipDropdown>
107135
);
108136
}

src/components/BranchChip.tsx

Lines changed: 107 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22

33
import type { BranchInfo } from "../types";
44
import { ChipDropdown } from "./ChipDropdown";
5+
import { VirtualChipList, type VirtualChipRow } from "./VirtualChipList";
6+
7+
// Virtualised-row heights (px) — mirror the box heights in styles.css.
8+
const ITEM_H = 26; // .chip-item — one-line branch entry
9+
const HEADER_H = 25; // .chip-section-header — "Local" / "Remote tracking"
10+
const EMPTY_H = 34; // .chip-empty — "No branches match."
511

612
interface Props {
713
open: boolean;
@@ -72,46 +78,106 @@ export function BranchChip({
7278
return `${selected.length} branches`;
7379
}, [selected, branches]);
7480

75-
function toggle(refName: string) {
76-
if (selected === "all") {
77-
// GitLens / IDE sidebar pattern: clicking a branch from the "all"
78-
// state means "focus on THIS one". The previous "everything except
79-
// this one" behaviour was a multi-select-with-checkboxes mental
80-
// model that doesn't match what users expect from a branch filter.
81-
onChange([refName]);
82-
return;
83-
}
84-
const set = new Set(selected);
85-
if (set.has(refName)) set.delete(refName);
86-
else set.add(refName);
87-
const next = Array.from(set);
88-
onChange(next.length === branches.length ? "all" : next);
89-
}
81+
const toggle = useCallback(
82+
(refName: string) => {
83+
if (selected === "all") {
84+
// GitLens / IDE sidebar pattern: clicking a branch from the "all"
85+
// state means "focus on THIS one". The previous "everything except
86+
// this one" behaviour was a multi-select-with-checkboxes mental
87+
// model that doesn't match what users expect from a branch filter.
88+
onChange([refName]);
89+
return;
90+
}
91+
const set = new Set(selected);
92+
if (set.has(refName)) set.delete(refName);
93+
else set.add(refName);
94+
const next = Array.from(set);
95+
onChange(next.length === branches.length ? "all" : next);
96+
},
97+
[selected, branches, onChange],
98+
);
99+
100+
// Flatten the two sections into one virtual-row list. The Local / Remote
101+
// headers and the empty-state line are known-height special rows
102+
// interleaved with the branch rows.
103+
const rows = useMemo<VirtualChipRow[]>(() => {
104+
const branchRow = (b: BranchInfo): VirtualChipRow => {
105+
// In the "all" meta-state the All branches row at the top carries the
106+
// highlight — individual items shouldn't ALSO look "checked", or a
107+
// user clicking a row to "uncheck it" is met with the GitLens focus
108+
// behaviour and it feels like a different row got deselected. So: no
109+
// per-item ✓ until the user makes an explicit selection.
110+
const isSelected =
111+
selected !== "all" && (selected as string[]).includes(b.refName);
112+
return {
113+
key: "branch:" + b.refName,
114+
height: ITEM_H,
115+
render: () => (
116+
<button
117+
type="button"
118+
className={"chip-item" + (isSelected ? " checked" : "")}
119+
onClick={() => toggle(b.refName)}
120+
>
121+
<span className="chip-check">{isSelected ? "✓" : ""}</span>
122+
<span className="chip-item-name">
123+
{b.name}
124+
{b.isHead && <span className="chip-item-head"> · HEAD</span>}
125+
</span>
126+
<span className="chip-item-count">{b.commitCount}</span>
127+
</button>
128+
),
129+
};
130+
};
90131

91-
function renderItem(b: BranchInfo) {
92-
// In the "all" meta-state the All branches row at the top carries the
93-
// highlight — individual items shouldn't ALSO look "checked", or a
94-
// user clicking a row to "uncheck it" is met with the GitLens focus
95-
// behaviour and it feels like a different row got deselected. So: no
96-
// per-item ✓ until the user makes an explicit selection.
97-
const isSelected =
98-
selected !== "all" && (selected as string[]).includes(b.refName);
99-
return (
100-
<button
101-
key={b.refName}
102-
type="button"
103-
className={"chip-item" + (isSelected ? " checked" : "")}
104-
onClick={() => toggle(b.refName)}
105-
>
106-
<span className="chip-check">{isSelected ? "✓" : ""}</span>
107-
<span className="chip-item-name">
108-
{b.name}
109-
{b.isHead && <span className="chip-item-head"> · HEAD</span>}
110-
</span>
111-
<span className="chip-item-count">{b.commitCount}</span>
112-
</button>
113-
);
114-
}
132+
const out: VirtualChipRow[] = [];
133+
out.push({
134+
key: "__all",
135+
height: ITEM_H,
136+
render: () => (
137+
<button
138+
type="button"
139+
className={"chip-item" + (selected === "all" ? " active" : "")}
140+
onClick={() => {
141+
onChange("all");
142+
onClose();
143+
}}
144+
>
145+
<span className="chip-item-name">All branches</span>
146+
</button>
147+
),
148+
});
149+
if (localBranches.length > 0) {
150+
out.push({
151+
key: "__local",
152+
height: HEADER_H,
153+
render: () => <div className="chip-section-header">Local</div>,
154+
});
155+
for (const b of localBranches) out.push(branchRow(b));
156+
}
157+
if (remoteBranches.length > 0) {
158+
out.push({
159+
key: "__remote",
160+
height: HEADER_H,
161+
render: () => (
162+
<div
163+
className="chip-section-header"
164+
title="Remote-tracking refs are local — gitwink never calls git fetch. Updated by your IDE / CLI."
165+
>
166+
Remote tracking
167+
</div>
168+
),
169+
});
170+
for (const b of remoteBranches) out.push(branchRow(b));
171+
}
172+
if (localBranches.length === 0 && remoteBranches.length === 0) {
173+
out.push({
174+
key: "__empty",
175+
height: EMPTY_H,
176+
render: () => <div className="chip-empty">No branches match.</div>,
177+
});
178+
}
179+
return out;
180+
}, [localBranches, remoteBranches, selected, onChange, onClose, toggle]);
115181

116182
return (
117183
<ChipDropdown
@@ -129,41 +195,7 @@ export function BranchChip({
129195
placeholder="Search branches…"
130196
/>
131197
</div>
132-
<div className="chip-list">
133-
<button
134-
type="button"
135-
className={"chip-item" + (selected === "all" ? " active" : "")}
136-
onClick={() => {
137-
onChange("all");
138-
onClose();
139-
}}
140-
>
141-
<span className="chip-item-name">All branches</span>
142-
</button>
143-
144-
{localBranches.length > 0 && (
145-
<>
146-
<div className="chip-section-header">Local</div>
147-
{localBranches.map(renderItem)}
148-
</>
149-
)}
150-
151-
{remoteBranches.length > 0 && (
152-
<>
153-
<div
154-
className="chip-section-header"
155-
title="Remote-tracking refs are local — gitwink never calls git fetch. Updated by your IDE / CLI."
156-
>
157-
Remote tracking
158-
</div>
159-
{remoteBranches.map(renderItem)}
160-
</>
161-
)}
162-
163-
{localBranches.length === 0 && remoteBranches.length === 0 && (
164-
<div className="chip-empty">No branches match.</div>
165-
)}
166-
</div>
198+
<VirtualChipList rows={rows} resetKey={query} />
167199
</ChipDropdown>
168200
);
169201
}

0 commit comments

Comments
 (0)