Skip to content

Commit 5887e07

Browse files
committed
Improve commit graph filtering
1 parent b1b4e2e commit 5887e07

5 files changed

Lines changed: 109 additions & 93 deletions

File tree

src-tauri/src/commands/history.rs

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,65 @@ use crate::{
33
services::git_service,
44
};
55
#[tauri::command]
6-
pub fn get_history(repo_path: String, limit: u32) -> Result<Vec<CommitInfo>, GitError> {
6+
pub fn get_history(
7+
repo_path: String,
8+
limit: u32,
9+
branch: Option<String>,
10+
author: Option<String>,
11+
since: Option<String>,
12+
until: Option<String>,
13+
keyword: Option<String>,
14+
file_path: Option<String>,
15+
) -> Result<Vec<CommitInfo>, GitError> {
716
let fmt = "%H%x1f%h%x1f%P%x1f%an%x1f%ad%x1f%d%x1f%s";
8-
let out = git_service::git_text(
9-
&repo_path,
10-
&[
11-
"log",
12-
"--graph",
13-
"--date=short",
14-
&format!("--max-count={limit}"),
15-
&format!("--pretty=format:{fmt}"),
16-
],
17-
)?;
17+
let max_count = format!("--max-count={limit}");
18+
let pretty = format!("--pretty=format:{fmt}");
19+
let author_arg = author
20+
.filter(|v| !v.trim().is_empty() && v != "all")
21+
.map(|v| format!("--author={v}"));
22+
let since_arg = since
23+
.filter(|v| !v.trim().is_empty())
24+
.map(|v| format!("--since={v}"));
25+
let until_arg = until
26+
.filter(|v| !v.trim().is_empty())
27+
.map(|v| format!("--until={v}"));
28+
let grep_arg = keyword
29+
.filter(|v| !v.trim().is_empty())
30+
.map(|v| format!("--grep={v}"));
31+
let branch_arg = branch.filter(|v| !v.trim().is_empty() && v != "all");
32+
let file_arg = file_path.filter(|v| !v.trim().is_empty());
33+
34+
let mut args = vec![
35+
"log".to_string(),
36+
"--graph".to_string(),
37+
"--decorate".to_string(),
38+
"--date=short".to_string(),
39+
max_count,
40+
pretty,
41+
];
42+
if let Some(author) = author_arg {
43+
args.push(author);
44+
}
45+
if let Some(since) = since_arg {
46+
args.push(since);
47+
}
48+
if let Some(until) = until_arg {
49+
args.push(until);
50+
}
51+
if let Some(grep) = grep_arg {
52+
args.push(grep);
53+
}
54+
if let Some(branch) = branch_arg {
55+
args.push(branch);
56+
} else {
57+
args.push("--all".to_string());
58+
}
59+
if let Some(path) = file_arg {
60+
args.push("--".to_string());
61+
args.push(path);
62+
}
63+
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
64+
let out = git_service::git_text(&repo_path, &refs)?;
1865
Ok(out
1966
.lines()
2067
.filter_map(|l| {
@@ -72,7 +119,10 @@ pub fn compare_commits(repo_path: String, from: String, to: String) -> Result<St
72119
}
73120

74121
#[tauri::command]
75-
pub fn checkout_commit(repo_path: String, commit: String) -> Result<crate::models::git::GitCommandOutput, GitError> {
122+
pub fn checkout_commit(
123+
repo_path: String,
124+
commit: String,
125+
) -> Result<crate::models::git::GitCommandOutput, GitError> {
76126
git_service::git_checked(&repo_path, &["checkout", &commit])
77127
}
78128
#[tauri::command]
@@ -97,11 +147,17 @@ pub fn create_tag_from_commit(
97147
git_service::git_checked(&repo_path, &["tag", &name, &commit])
98148
}
99149
#[tauri::command]
100-
pub fn cherry_pick_commit(repo_path: String, commit: String) -> Result<crate::models::git::GitCommandOutput, GitError> {
150+
pub fn cherry_pick_commit(
151+
repo_path: String,
152+
commit: String,
153+
) -> Result<crate::models::git::GitCommandOutput, GitError> {
101154
git_service::git_checked(&repo_path, &["cherry-pick", &commit])
102155
}
103156
#[tauri::command]
104-
pub fn revert_commit(repo_path: String, commit: String) -> Result<crate::models::git::GitCommandOutput, GitError> {
157+
pub fn revert_commit(
158+
repo_path: String,
159+
commit: String,
160+
) -> Result<crate::models::git::GitCommandOutput, GitError> {
105161
git_service::git_checked(&repo_path, &["revert", "--no-edit", &commit])
106162
}
107163
#[tauri::command]

src/components/graph/GitGraph.tsx

Lines changed: 33 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { useMemo, useState } from 'react';
1+
import { useEffect, useMemo, useRef, useState } from 'react';
22
import { Filter, GitCommitHorizontal, Search, X } from 'lucide-react';
33
import { useGitStore } from '../../store/gitStore';
4-
import type { CommitInfo } from '../../types/git';
4+
import type { CommitInfo, HistoryFilters } from '../../types/git';
55

66
const LANE_COLORS = ['#38bdf8','#a78bfa','#34d399','#fb923c','#f472b6','#facc15','#60a5fa','#4ade80','#e879f9','#f87171'];
77
const ROW_H = 34;
@@ -18,114 +18,73 @@ function buildRows(commits: CommitInfo[]): RowData[] {
1818
const findLane = (hash: string) => lanes.findIndex(l => l?.hash === hash);
1919
const freeLane = () => { const idx = lanes.findIndex(l => l === null); return idx === -1 ? lanes.length : idx; };
2020
const rows: RowData[] = [];
21-
2221
for (const commit of commits) {
2322
let myLane = findLane(commit.hash);
2423
let myColorIdx: number;
25-
if (myLane === -1) {
26-
myLane = freeLane();
27-
myColorIdx = nextColor();
28-
if (myLane === lanes.length) lanes.push(null);
29-
lanes[myLane] = { hash: commit.hash, colorIdx: myColorIdx };
30-
} else {
31-
myColorIdx = lanes[myLane]!.colorIdx;
32-
}
33-
24+
if (myLane === -1) { myLane = freeLane(); myColorIdx = nextColor(); if (myLane === lanes.length) lanes.push(null); lanes[myLane] = { hash: commit.hash, colorIdx: myColorIdx }; }
25+
else myColorIdx = lanes[myLane]!.colorIdx;
3426
const myColor = LANE_COLORS[myColorIdx];
3527
const lines: RowData['lines'] = [];
3628
for (let i = 0; i < lanes.length; i++) if (i !== myLane && lanes[i]) lines.push({ fromLane: i, toLane: i, color: LANE_COLORS[lanes[i]!.colorIdx], pos: 'top' });
37-
3829
const parents = commit.parents ?? [];
3930
lanes[myLane] = null;
40-
if (parents[0]) {
41-
const existingLane = findLane(parents[0]);
42-
if (existingLane === -1) lanes[myLane] = { hash: parents[0], colorIdx: myColorIdx };
43-
else lines.push({ fromLane: myLane, toLane: existingLane, color: myColor, pos: 'bottom' });
44-
}
45-
for (let i = 1; i < parents.length; i++) {
46-
const parent = parents[i];
47-
const existingLane = findLane(parent);
48-
if (existingLane === -1) {
49-
const newLane = freeLane();
50-
const newColorIdx = nextColor();
51-
if (newLane === lanes.length) lanes.push(null);
52-
lanes[newLane] = { hash: parent, colorIdx: newColorIdx };
53-
lines.push({ fromLane: myLane, toLane: newLane, color: LANE_COLORS[newColorIdx], pos: 'bottom' });
54-
} else lines.push({ fromLane: myLane, toLane: existingLane, color: LANE_COLORS[lanes[existingLane]!.colorIdx], pos: 'bottom' });
55-
}
31+
if (parents[0]) { const existingLane = findLane(parents[0]); if (existingLane === -1) lanes[myLane] = { hash: parents[0], colorIdx: myColorIdx }; else lines.push({ fromLane: myLane, toLane: existingLane, color: myColor, pos: 'bottom' }); }
32+
for (let i = 1; i < parents.length; i++) { const parent = parents[i]; const existingLane = findLane(parent); if (existingLane === -1) { const newLane = freeLane(); const newColorIdx = nextColor(); if (newLane === lanes.length) lanes.push(null); lanes[newLane] = { hash: parent, colorIdx: newColorIdx }; lines.push({ fromLane: myLane, toLane: newLane, color: LANE_COLORS[newColorIdx], pos: 'bottom' }); } else lines.push({ fromLane: myLane, toLane: existingLane, color: LANE_COLORS[lanes[existingLane]!.colorIdx], pos: 'bottom' }); }
5633
for (let i = 0; i < lanes.length; i++) if (i !== myLane && lanes[i]) lines.push({ fromLane: i, toLane: i, color: LANE_COLORS[lanes[i]!.colorIdx], pos: 'bottom' });
5734
rows.push({ commit, lane: myLane, color: myColor, lines, maxLane: Math.max(myLane, ...lanes.map((l, i) => l ? i : 0)) });
5835
}
5936
return rows;
6037
}
6138

6239
const normalized = (value: string) => value.toLowerCase().trim();
63-
64-
function matchesCommit(commit: CommitInfo, query: string, author: string, ref: string) {
65-
const q = normalized(query);
40+
function matchesCommit(commit: CommitInfo, query: string) {
41+
const q = normalized(query); if (!q) return true;
6642
const refNames = commit.refs.join(' ');
67-
const textMatch = !q || [commit.message, commit.hash, commit.shortHash, commit.author, refNames].some(v => normalized(v).includes(q));
68-
const authorMatch = author === 'all' || commit.author === author;
69-
const refMatch = ref === 'all' || commit.refs.some(r => r.includes(ref));
70-
return textMatch && authorMatch && refMatch;
43+
return [commit.message, commit.hash, commit.shortHash, commit.author, refNames].some(v => normalized(v).includes(q));
7144
}
7245

73-
function GraphRow({ row, selected, dimmed, onClick }: { row: RowData; selected: boolean; dimmed: boolean; onClick: () => void }) {
46+
function GraphRow({ row, selected, dimmed, onClick, rowRef }: { row: RowData; selected: boolean; dimmed: boolean; onClick: () => void; rowRef?: (node: HTMLButtonElement | null) => void }) {
7447
const svgWidth = (row.maxLane + 1) * LANE_W + PAD_LEFT * 2;
75-
const cy = ROW_H / 2;
76-
const cx = PAD_LEFT + row.lane * LANE_W;
77-
return <button onClick={onClick} className={`flex w-full items-center border-t border-pilot-line text-left transition-colors hover:bg-slate-800/60 ${selected ? 'bg-slate-800 ring-1 ring-inset ring-sky-500/40' : ''} ${dimmed ? 'opacity-35' : ''}`} style={{ height: ROW_H }}>
48+
const cy = ROW_H / 2; const cx = PAD_LEFT + row.lane * LANE_W;
49+
return <button ref={rowRef} onClick={onClick} className={`flex w-full items-center border-t border-pilot-line text-left transition-colors hover:bg-slate-800/60 ${selected ? 'bg-slate-800 ring-1 ring-inset ring-sky-500/40' : ''} ${dimmed ? 'opacity-35' : ''}`} style={{ height: ROW_H }}>
7850
<svg width={svgWidth} height={ROW_H} className="shrink-0 overflow-visible">
7951
{row.lines.map((ln, i) => { const x1 = PAD_LEFT + ln.fromLane * LANE_W; const x2 = PAD_LEFT + ln.toLane * LANE_W; const y1 = ln.pos === 'bottom' ? cy : 0; const y2 = ln.pos === 'top' ? cy : ROW_H; return x1 === x2 ? <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke={ln.color} strokeWidth={1.6} /> : <path key={i} d={`M ${x1} ${y1} C ${x1} ${(y1 + y2) / 2}, ${x2} ${(y1 + y2) / 2}, ${x2} ${y2}`} fill="none" stroke={ln.color} strokeWidth={1.6} />; })}
80-
<circle cx={cx} cy={cy} r={CIRCLE_R + 3} fill={row.color} opacity={0.18} />
81-
<circle cx={cx} cy={cy} r={CIRCLE_R} fill={row.color} />
82-
{row.commit.head && <circle cx={cx} cy={cy} r={CIRCLE_R + 3} fill="none" stroke={row.color} strokeWidth={1.5} />}
52+
<circle cx={cx} cy={cy} r={CIRCLE_R + 3} fill={row.color} opacity={0.18} /><circle cx={cx} cy={cy} r={CIRCLE_R} fill={row.color} />{row.commit.head && <circle cx={cx} cy={cy} r={CIRCLE_R + 3} fill="none" stroke={row.color} strokeWidth={1.5} />}
8353
</svg>
84-
<div className="min-w-0 flex flex-1 items-center gap-3 pr-3">
85-
<div className="min-w-0 flex-1">
86-
<div className="flex min-w-0 items-center gap-1.5">
87-
{row.commit.refs.slice(0, 4).map(ref => <span key={ref} className="shrink-0 rounded border border-sky-400/30 bg-sky-400/10 px-1 py-0 text-[9px] font-semibold text-sky-300">{ref.replace('HEAD -> ', '').replace('tag: ', '')}</span>)}
88-
<span className="truncate text-xs text-slate-200">{row.commit.message}</span>
89-
</div>
90-
<div className="mt-0.5 text-[10px] text-slate-500">{row.commit.shortHash} · {row.commit.author} · {row.commit.date}</div>
91-
</div>
92-
{row.commit.parents.length > 1 && <span className="rounded bg-violet-400/10 px-1.5 py-0.5 text-[10px] text-violet-300">merge</span>}
93-
</div>
54+
<div className="min-w-0 flex flex-1 items-center gap-3 pr-3"><div className="min-w-0 flex-1"><div className="flex min-w-0 items-center gap-1.5">{row.commit.refs.slice(0, 5).map(ref => <span key={ref} className="shrink-0 rounded border border-sky-400/30 bg-sky-400/10 px-1 py-0 text-[9px] font-semibold text-sky-300">{ref.replace('HEAD -> ', '').replace('tag: ', '')}</span>)}<span className="truncate text-xs text-slate-200">{row.commit.message}</span></div><div className="mt-0.5 text-[10px] text-slate-500">{row.commit.shortHash} · {row.commit.author} · {row.commit.date}</div></div>{row.commit.parents.length > 1 && <span className="rounded bg-violet-400/10 px-1.5 py-0.5 text-[10px] text-violet-300">merge</span>}</div>
9455
</button>;
9556
}
9657

9758
export function GitGraph() {
98-
const { history, selectedCommit, selectCommit } = useGitStore(s => ({ history: s.history, selectedCommit: s.selectedCommit, selectCommit: s.selectCommit }));
99-
const [query, setQuery] = useState('');
100-
const [author, setAuthor] = useState('all');
101-
const [ref, setRef] = useState('all');
59+
const { history, selectedCommit, selectCommit, historyLimit, historyFilters, loadHistory } = useGitStore(s => ({ history: s.history, selectedCommit: s.selectedCommit, selectCommit: s.selectCommit, historyLimit: s.historyLimit, historyFilters: s.historyFilters, loadHistory: s.loadHistory }));
60+
const [search, setSearch] = useState('');
61+
const [filters, setFilters] = useState<HistoryFilters>(historyFilters);
62+
const selectedRef = useRef<HTMLButtonElement | null>(null);
63+
useEffect(() => { const id = window.setTimeout(() => { void loadHistory(filters, historyLimit); }, 350); return () => window.clearTimeout(id); }, [filters, historyLimit, loadHistory]);
64+
useEffect(() => { selectedRef.current?.scrollIntoView({ block: 'nearest' }); }, [selectedCommit?.hash]);
10265
const rows = useMemo(() => buildRows(history), [history]);
10366
const authors = useMemo(() => Array.from(new Set(history.map(c => c.author))).sort(), [history]);
10467
const refs = useMemo(() => Array.from(new Set(history.flatMap(c => c.refs.map(r => r.replace('HEAD -> ', '').replace('tag: ', ''))))).sort(), [history]);
105-
const visibleRows = rows.filter(row => matchesCommit(row.commit, query, author, ref));
106-
const hasFilter = Boolean(query || author !== 'all' || ref !== 'all');
107-
68+
const visibleRows = rows.filter(row => matchesCommit(row.commit, search));
69+
const hasFilter = Boolean(search || filters.branch || filters.author || filters.since || filters.until || filters.keyword || filters.filePath);
70+
const clear = () => { setSearch(''); setFilters({}); };
10871
if (history.length === 0) return <div className="flex h-full items-center justify-center text-sm text-slate-500">Open a repository to see commit history</div>;
109-
11072
return <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#090e1b]">
11173
<div className="sticky top-0 z-10 border-b border-pilot-line bg-[#0d1324] p-2">
112-
<div className="mb-2 flex items-center justify-between gap-3">
113-
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-slate-500"><GitCommitHorizontal size={13} /> Commit Graph · {visibleRows.length}/{history.length}</div>
114-
{hasFilter && <button className="icon-btn h-6" onClick={() => { setQuery(''); setAuthor('all'); setRef('all'); }}><X size={12} /> Clear</button>}
115-
</div>
116-
<div className="grid grid-cols-[1fr_150px_150px] gap-2">
117-
<label className="relative"><Search size={13} className="pointer-events-none absolute left-2 top-1.5 text-slate-500" /><input className="input h-7 w-full pl-7 text-xs" value={query} onChange={e => setQuery(e.target.value)} placeholder="Search message, hash, author, branch…" /></label>
118-
<label className="relative"><Filter size={12} className="pointer-events-none absolute left-2 top-2 text-slate-500" /><select className="input h-7 w-full pl-7 text-xs" value={author} onChange={e => setAuthor(e.target.value)}><option value="all">All authors</option>{authors.map(a => <option key={a} value={a}>{a}</option>)}</select></label>
119-
<select className="input h-7 w-full text-xs" value={ref} onChange={e => setRef(e.target.value)}><option value="all">All branches/tags</option>{refs.map(r => <option key={r} value={r}>{r}</option>)}</select>
74+
<div className="mb-2 flex items-center justify-between gap-3"><div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-slate-500"><GitCommitHorizontal size={13} /> Commit Graph · {visibleRows.length}/{history.length}</div>{hasFilter && <button className="icon-btn h-6" onClick={clear}><X size={12} /> Clear</button>}</div>
75+
<div className="grid grid-cols-[1.2fr_140px_140px_110px_110px] gap-2">
76+
<label className="relative"><Search size={13} className="pointer-events-none absolute left-2 top-1.5 text-slate-500" /><input className="input h-7 w-full pl-7 text-xs" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search hash, message, author…" /></label>
77+
<label className="relative"><Filter size={12} className="pointer-events-none absolute left-2 top-2 text-slate-500" /><select className="input h-7 w-full pl-7 text-xs" value={filters.author ?? ''} onChange={e => setFilters(f => ({ ...f, author: e.target.value || undefined }))}><option value="">All authors</option>{authors.map(a => <option key={a} value={a}>{a}</option>)}</select></label>
78+
<select className="input h-7 w-full text-xs" value={filters.branch ?? ''} onChange={e => setFilters(f => ({ ...f, branch: e.target.value || undefined }))}><option value="">All refs</option>{refs.map(r => <option key={r} value={r}>{r}</option>)}</select>
79+
<input className="input h-7 text-xs" type="date" value={filters.since ?? ''} onChange={e => setFilters(f => ({ ...f, since: e.target.value || undefined }))} title="Since" />
80+
<input className="input h-7 text-xs" type="date" value={filters.until ?? ''} onChange={e => setFilters(f => ({ ...f, until: e.target.value || undefined }))} title="Until" />
12081
</div>
82+
<div className="mt-2 grid grid-cols-2 gap-2"><input className="input h-7 text-xs" value={filters.keyword ?? ''} onChange={e => setFilters(f => ({ ...f, keyword: e.target.value || undefined }))} placeholder="Commit message keyword (git --grep)" /><input className="input h-7 text-xs" value={filters.filePath ?? ''} onChange={e => setFilters(f => ({ ...f, filePath: e.target.value || undefined }))} placeholder="File path filter (e.g. src/App.tsx)" /></div>
12183
</div>
12284
<div className="min-h-0 flex-1 overflow-auto">
123-
{rows.map(row => {
124-
const matched = matchesCommit(row.commit, query, author, ref);
125-
if (hasFilter && !matched) return null;
126-
return <GraphRow key={row.commit.hash} row={row} selected={selectedCommit?.hash === row.commit.hash} dimmed={hasFilter && !matched} onClick={() => void selectCommit(row.commit)} />;
127-
})}
85+
{visibleRows.map(row => <GraphRow key={row.commit.hash} row={row} selected={selectedCommit?.hash === row.commit.hash} dimmed={false} rowRef={selectedCommit?.hash === row.commit.hash ? node => { selectedRef.current = node; } : undefined} onClick={() => void selectCommit(row.commit)} />)}
12886
{visibleRows.length === 0 && <div className="p-6 text-center text-sm text-slate-500">No commits match the current GitKraken-style filters.</div>}
87+
<div className="border-t border-pilot-line p-3 text-center"><button className="btn" onClick={() => void loadHistory(filters, historyLimit + 500)}>Load 500 more commits</button></div>
12988
</div>
13089
</div>;
13190
}

0 commit comments

Comments
 (0)