fix(vcs): fit the Git log toolbar and rows to narrow panes

- Filters that do not fit the toolbar move behind a chevron that lists
  them, so the action buttons on the right stay visible.
- Commits on the current branch are tinted unless the log is filtered to
  that branch, instead of dimming the others only when other branches
  happen to be loaded.
- The HEAD commit is drawn as a ring around a dot.
- A wide graph no longer paints the subject and references over the
  author column; references shrink down to their icon.
- Scrollbar thumbs keep a minimum length on long lists.
This commit is contained in:
l0ng-ai committed 2026-10-31 09:20:36 +08:00
1 parent 89475f4fed
commit f2b8db548f
4 files changed
+152 -78

No files matched your search

+26 -9
View File
@@ -75,6 +75,18 @@
flex: 0 1 260px;
min-width: 100px;
}
.filterGroup {
position: relative;
flex: 0 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.filterHidden {
visibility: hidden;
}
.filterButton {
flex: none;
height: 24px;
@@ -153,20 +165,21 @@
cursor: default;
font-size: var(--fs-ui);
}
/* View Options › Highlight › Current Branch: commits outside HEAD are dimmed. */
.offBranch .subject,
.offBranch .muted {
color: var(--fg-subtle);
/* View Options › Highlight › Current Branch: commits reachable from HEAD are tinted. */
.currentBranch {
--log-row-bg: var(--bg-log-current-branch);
background: var(--log-row-bg);
}
.row:hover {
background: var(--bg-tree-hover);
}
.selected,
.selected:hover {
background: var(--bg-tree-selected-inactive);
--log-row-bg: var(--bg-tree-selected-inactive);
background: var(--log-row-bg);
}
.list:focus-within .selected {
background: var(--bg-tree-selected);
--log-row-bg: var(--bg-tree-selected);
}
.colSubject {
flex: 1;
@@ -174,8 +187,9 @@
display: flex;
align-items: center;
gap: 6px;
/* Let graph endpoints overlap adjacent rows; text is clipped by .subject. */
overflow: visible;
/* Let graph endpoints overlap adjacent rows, but never paint over the next column. */
overflow-x: clip;
overflow-y: visible;
}
.colAuthor {
flex: none;
@@ -221,7 +235,9 @@
overflow: visible;
}
.refs {
flex: none;
flex: 0 1 auto;
/* Shrinks to the first label's icon when the graph leaves no room. */
min-width: 11px;
display: inline-flex;
align-items: center;
gap: 8px;
@@ -242,6 +258,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
max-width: 180px;
}
/* Outlined tag glyph in the ref's colour. */
+123 -69
View File
@@ -508,25 +508,25 @@ const DATE_OPTIONS: { label: string; days: number }[] = [
{ label: "Last Year", days: 365 },
];
function menuBelow(e: ReactMouseEvent<HTMLElement>, items: MenuItem[]) {
const r = e.currentTarget.getBoundingClientRect();
function menuBelow(anchor: HTMLElement | ReactMouseEvent<HTMLElement>, items: MenuItem[]) {
const r = ("currentTarget" in anchor ? anchor.currentTarget : anchor).getBoundingClientRect();
showContextMenu({ x: r.left, y: r.bottom + 2 }, items);
}
function FilterButton({
label,
value,
onOpen,
onClear,
}: {
interface LogFilter {
label: string;
value: string | null;
onOpen: (e: ReactMouseEvent<HTMLElement>) => void;
onOpen: (anchor: HTMLElement) => void;
onClear: () => void;
}) {
}
function FilterButton({ label, value, onOpen, onClear, hidden }: LogFilter & { hidden?: boolean }) {
return (
<span className={clsx(styles.filterButton, value && styles.filterActive)}>
<button type="button" className={styles.filterMain} onClick={onOpen} aria-haspopup="menu">
<span
className={clsx(styles.filterButton, value && styles.filterActive, hidden && styles.filterHidden)}
inert={hidden}
>
<button type="button" className={styles.filterMain} onClick={(e) => onOpen(e.currentTarget)} aria-haspopup="menu">
{value ? (
<>
{tx(label)}: <span className={styles.filterValue}>{value}</span>
@@ -580,6 +580,23 @@ function FilterBar() {
}, [repositories, filters.repositories, version]);
const [text, setText] = useState(filters.text);
const [branchAnchor, setBranchAnchor] = useState<HTMLElement | null>(null);
// Filters that do not fit move behind a chevron, like a toolbar's overflow.
const groupRef = useRef<HTMLSpanElement>(null);
const [hiddenFrom, setHiddenFrom] = useState(Infinity);
useLayoutEffect(() => {
const group = groupRef.current;
if (!group) return;
const measure = () => {
const items = [...group.children] as HTMLElement[];
const i = items.findIndex((el) => el.offsetLeft + el.offsetWidth > group.clientWidth + 1);
setHiddenFrom(i < 0 ? Infinity : i);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(group);
for (const el of group.children) ro.observe(el);
return () => ro.disconnect();
}, [filters, repositories.length]);
// Keep the field in sync when filters change elsewhere (Show History, reveal).
const [seen, setSeen] = useState(filters);
@@ -607,9 +624,9 @@ function FilterBar() {
if (r) setLogBranches(r);
};
const openUser = (e: ReactMouseEvent<HTMLElement>) => {
const openUser = (anchor: HTMLElement) => {
const authors = [...new Set(commits.map((c) => c.author))].sort((a, b) => a.localeCompare(b)).slice(0, 30);
menuBelow(e, [
menuBelow(anchor, [
{ label: ME, checked: filters.authors.includes(ME), run: () => setLogFilters({ authors: [ME] }) },
...(authors.length ? [{ kind: "separator" as const }] : []),
...authors.map((a) => ({
@@ -633,8 +650,8 @@ function FilterBar() {
]);
};
const openDate = (e: ReactMouseEvent<HTMLElement>) =>
menuBelow(e, [
const openDate = (anchor: HTMLElement) =>
menuBelow(anchor, [
...DATE_OPTIONS.map((o) => ({
label: o.label,
checked: filters.date?.label === o.label,
@@ -650,8 +667,8 @@ function FilterBar() {
},
]);
const openPaths = (e: ReactMouseEvent<HTMLElement>) =>
menuBelow(e, [
const openPaths = (anchor: HTMLElement) =>
menuBelow(anchor, [
{
label: "Select Folders…",
run: async () => {
@@ -664,6 +681,51 @@ function FilterBar() {
},
]);
const logFilters: LogFilter[] = [
...(repositories.length > 1
? [
{
label: "Repository",
value: filters.repositories?.map(repoName).join(", ") || null,
onOpen: (anchor: HTMLElement) =>
menuBelow(
anchor,
repositories.map((repo) => ({
label: repoName(repo),
checked: filters.repositories?.includes(repo) ?? false,
run: () => setLogFilters({ repositories: [repo] }),
})),
),
onClear: () => setLogFilters({ repositories: [] }),
},
]
: []),
{
label: "Branch",
value: filters.branches.join(", ") || null,
onOpen: setBranchAnchor,
onClear: () => setLogBranches([]),
},
{
label: "User",
value: filters.authors.join(", ") || null,
onOpen: openUser,
onClear: () => setLogFilters({ authors: [] }),
},
{
label: "Date",
value: filters.date?.label ?? null,
onOpen: openDate,
onClear: () => setLogFilters({ date: null }),
},
{
label: "Paths",
value: filters.path ? (root ? relTo(root, filters.path) : filters.path) || "." : null,
onOpen: openPaths,
onClear: () => setLogFilters({ path: null }),
},
];
return (
<div className={styles.filters} role="toolbar" aria-label={tx("Log filters")}>
{!showBranches && (
@@ -692,29 +754,29 @@ function FilterBar() {
},
]}
/>
{repositories.length > 1 && (
<FilterButton
label="Repository"
value={filters.repositories?.map(repoName).join(", ") || null}
onOpen={(e) =>
<span ref={groupRef} className={styles.filterGroup}>
{logFilters.map((f, i) => (
<FilterButton key={f.label} {...f} hidden={i >= hiddenFrom} />
))}
</span>
{hiddenFrom < logFilters.length && (
<IconButton
icon="chevron-right"
title="More Filters"
size="small"
aria-haspopup="menu"
onClick={(e) => {
const anchor = e.currentTarget;
menuBelow(
e,
repositories.map((repo) => ({
label: repoName(repo),
checked: filters.repositories?.includes(repo) ?? false,
run: () => setLogFilters({ repositories: [repo] }),
anchor,
logFilters.slice(hiddenFrom).map((f) => ({
label: f.value ? `${tx(f.label)}: ${f.value}` : f.label,
run: () => f.onOpen(anchor),
})),
)
}
onClear={() => setLogFilters({ repositories: [] })}
);
}}
/>
)}
<FilterButton
label="Branch"
value={filters.branches.join(", ") || null}
onOpen={(e) => setBranchAnchor(e.currentTarget)}
onClear={() => setLogBranches([])}
/>
{branchAnchor && (
<BranchFilterPopup
anchor={branchAnchor}
@@ -728,24 +790,6 @@ function FilterBar() {
onSelectMany={() => void selectBranches()}
/>
)}
<FilterButton
label="User"
value={filters.authors.join(", ") || null}
onOpen={openUser}
onClear={() => setLogFilters({ authors: [] })}
/>
<FilterButton
label="Date"
value={filters.date?.label ?? null}
onOpen={openDate}
onClear={() => setLogFilters({ date: null })}
/>
<FilterButton
label="Paths"
value={filters.path ? (root ? relTo(root, filters.path) : filters.path) || "." : null}
onOpen={openPaths}
onClear={() => setLogFilters({ path: null })}
/>
<span className={styles.grow} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void reloadLog({ preserve: true })} />
<IconButton
@@ -909,13 +953,16 @@ function CommitList() {
const laid = collapsed ? computeGraph(collapsed.graph).rows : allRows;
return opts.longEdges ? laid : hideLongEdges(laid);
}, [collapsed, allRows, opts.longEdges]);
const filterBranches = useGitLog((s) => s.filters.branches);
const currentBranch = useVcs((s) => s.branch?.head ?? null);
// Nothing to tell apart when the log is filtered to the current branch.
const onlyCurrent = filterBranches.length > 0 && filterBranches.every((b) => b === "HEAD" || b === currentBranch);
const onBranch = useMemo(() => {
if (!opts.highlightCurrentBranch) return null;
if (!opts.highlightCurrentBranch || onlyCurrent) return null;
const reach = new Set<string>();
for (const head of headKeys) for (const id of reachableFrom(all.map(graphCommit), head)) reach.add(id);
// Nothing to tell apart when only the current branch is shown.
return all.every((c) => reach.has(commitKey(c))) ? null : reach;
}, [all, headKeys, opts.highlightCurrentBranch]);
return reach;
}, [all, headKeys, opts.highlightCurrentBranch, onlyCurrent]);
const loading = useGitLog((s) => s.loading);
const done = useGitLog((s) => s.done);
const error = useGitLog((s) => s.error);
@@ -1025,7 +1072,7 @@ function CommitList() {
selected={selectedSet.has(commitKey(c))}
head={headKeys.has(commitKey(c))}
mine={opts.highlightMine && isMine(c, user)}
onBranch={!onBranch || onBranch.has(commitKey(c))}
onBranch={!!onBranch?.has(commitKey(c))}
opts={opts}
remoteNames={new Set((c as RepoCommit).remoteNames ?? remoteNames)}
onPick={pick}
@@ -1079,7 +1126,7 @@ const CommitRow = memo(function CommitRow({
id={id}
role="option"
aria-selected={selected}
className={clsx(styles.row, !onBranch && styles.offBranch, selected && styles.selected)}
className={clsx(styles.row, onBranch && styles.currentBranch, selected && styles.selected)}
style={{ top }}
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault(); // no text selection on range clicks
@@ -1150,14 +1197,21 @@ export function GraphCell({ row, head }: { row: GraphRow; head?: boolean }) {
strokeDasharray={s.dashed ? "2 2" : undefined}
/>
))}
<circle
cx={x(row.lane)}
cy={cy}
r={head ? 4.5 : 3.8}
fill={head ? "var(--bg-app)" : laneColor(row.color)}
stroke={laneColor(row.color)}
strokeWidth={head ? 2 : 0}
/>
{head ? (
<>
<circle
cx={x(row.lane)}
cy={cy}
r={5}
fill="var(--log-row-bg, var(--bg-app))"
stroke={laneColor(row.color)}
strokeWidth={1.5}
/>
<circle cx={x(row.lane)} cy={cy} r={2.5} fill={laneColor(row.color)} />
</>
) : (
<circle cx={x(row.lane)} cy={cy} r={3.8} fill={laneColor(row.color)} />
)}
</svg>
);
}
+2
View File
@@ -171,6 +171,8 @@ a:hover {
border: 2px solid transparent;
background-clip: content-box;
border-radius: 6px;
min-height: 32px;
min-width: 32px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
+1
View File
@@ -264,6 +264,7 @@ html {
--bg-scrim: rgba(0, 0, 0, 0.45);
--fg-on-accent: #ffffff;
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
--bg-log-current-branch: color-mix(in srgb, var(--accent) 8%, var(--bg-app));
--accent-hover: color-mix(in srgb, var(--accent) 86%, var(--fg-strong));
--match: var(--accent);
--shadow-tooltip: 0 4px 12px rgba(0, 0, 0, 0.4);