Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src-tauri/src/commands/commits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use crate::error::AppError;
use crate::git::{repository::GitRepository, types::*};

#[tauri::command]
pub fn get_commit_log(
repo_path: String,
count: Option<usize>,
skip: Option<usize>,
) -> Result<Vec<CommitInfo>, AppError> {
let repo = GitRepository::open(&repo_path)?;
repo.get_commit_log(count.unwrap_or(25), skip.unwrap_or(0))
}

#[tauri::command]
pub fn get_branch_log(repo_path: String) -> Result<Vec<CommitInfo>, AppError> {
let repo = GitRepository::open(&repo_path)?;
repo.get_branch_log()
}

#[tauri::command]
pub fn get_commit_files(repo_path: String, oid: String) -> Result<Vec<FileEntry>, AppError> {
let repo = GitRepository::open(&repo_path)?;
repo.get_commit_files(&oid)
}

#[tauri::command]
pub fn get_commit_file_diff(
repo_path: String,
oid: String,
file_path: String,
context_lines: Option<u32>,
ignore_whitespace: Option<bool>,
) -> Result<FileDiff, AppError> {
let repo = GitRepository::open(&repo_path)?;
repo.get_commit_file_diff(
&oid,
&file_path,
context_lines.unwrap_or(3),
ignore_whitespace.unwrap_or(false),
)
}
2 changes: 2 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
pub mod commit;
pub mod commits;
pub mod diff;
pub mod discard;
pub mod review;
pub mod staging;
pub mod status;

pub use commit::commit;
pub use commits::{get_branch_log, get_commit_file_diff, get_commit_files, get_commit_log};
pub use diff::{get_combined_diff, get_file_diff};
pub use discard::{discard_all, discard_file};
pub use review::export_review;
Expand Down
173 changes: 172 additions & 1 deletion src-tauri/src/git/repository.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use git2::{
Delta, Diff, DiffOptions, IndexAddOption, Repository, ResetType, Signature, StatusOptions,
Delta, Diff, DiffOptions, IndexAddOption, Repository, ResetType, Signature, Sort, StatusOptions,
};
use std::path::Path;

Expand Down Expand Up @@ -399,6 +399,177 @@ impl GitRepository {
})
}

pub fn get_commit_log(&self, count: usize, skip: usize) -> Result<Vec<CommitInfo>, AppError> {
let mut revwalk = self.repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(Sort::TIME)?;

let mut commits = Vec::new();
for oid in revwalk.skip(skip).take(count) {
let oid = oid?;
let commit = self.repo.find_commit(oid)?;
let author = commit.author();
commits.push(CommitInfo {
oid: oid.to_string(),
message: commit.message().unwrap_or("").to_string(),
author_name: author.name().unwrap_or("").to_string(),
author_email: author.email().unwrap_or("").to_string(),
timestamp: commit.time().seconds(),
});
}

Ok(commits)
}

pub fn get_branch_log(&self) -> Result<Vec<CommitInfo>, AppError> {
let head_oid = match self.repo.head() {
Ok(h) => h.peel_to_commit()?.id(),
Err(_) => return Ok(vec![]),
};

// Try to find a merge base with common base branch names (local then remote)
let base_names = ["main", "master", "develop", "dev"];
let mut merge_base_oid = None;

'outer: for name in base_names {
let candidates = [
format!("refs/heads/{name}"),
format!("refs/remotes/origin/{name}"),
];
for refname in &candidates {
if let Ok(reference) = self.repo.find_reference(refname) {
if let Ok(base_commit) = reference.peel_to_commit() {
// Skip if the base branch IS the current HEAD
if base_commit.id() == head_oid {
continue;
}
if let Ok(base) = self.repo.merge_base(head_oid, base_commit.id()) {
merge_base_oid = Some(base);
break 'outer;
}
}
}
}
}

let stop_at = match merge_base_oid {
Some(oid) => oid,
// No base branch found — caller should fall back to paginated log
None => return Ok(vec![]),
};

let mut revwalk = self.repo.revwalk()?;
revwalk.push(head_oid)?;
revwalk.set_sorting(Sort::TIME)?;

let mut commits = Vec::new();
for oid in revwalk {
let oid = oid?;
if oid == stop_at {
break;
}
let commit = self.repo.find_commit(oid)?;
let author = commit.author();
commits.push(CommitInfo {
oid: oid.to_string(),
message: commit.message().unwrap_or("").to_string(),
author_name: author.name().unwrap_or("").to_string(),
author_email: author.email().unwrap_or("").to_string(),
timestamp: commit.time().seconds(),
});
}

Ok(commits)
}

pub fn get_commit_files(&self, oid: &str) -> Result<Vec<FileEntry>, AppError> {
let oid =
git2::Oid::from_str(oid).map_err(|e| AppError::Custom(format!("Invalid OID: {e}")))?;
let commit = self.repo.find_commit(oid)?;
let commit_tree = commit.tree()?;

let parent_tree = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};

let diff = self
.repo
.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;

let mut files = Vec::new();
for delta in diff.deltas() {
let status = match delta.status() {
git2::Delta::Added => FileStatus::Added,
git2::Delta::Deleted => FileStatus::Deleted,
git2::Delta::Renamed => FileStatus::Renamed,
git2::Delta::Copied => FileStatus::Copied,
git2::Delta::Conflicted => FileStatus::Conflicted,
_ => FileStatus::Modified,
};

let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();

let old_path = if delta.status() == git2::Delta::Renamed {
delta
.old_file()
.path()
.map(|p| p.to_string_lossy().to_string())
} else {
None
};

files.push(FileEntry {
path,
status,
staged: false,
old_path,
});
}

Ok(files)
}

pub fn get_commit_file_diff(
&self,
oid: &str,
file_path: &str,
context_lines: u32,
ignore_whitespace: bool,
) -> Result<FileDiff, AppError> {
let oid =
git2::Oid::from_str(oid).map_err(|e| AppError::Custom(format!("Invalid OID: {e}")))?;
let commit = self.repo.find_commit(oid)?;
let commit_tree = commit.tree()?;

let parent_tree = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};

let mut diff_opts = DiffOptions::new();
diff_opts.pathspec(file_path);
diff_opts.context_lines(context_lines);
if ignore_whitespace {
diff_opts.ignore_whitespace(true);
}

let diff = self.repo.diff_tree_to_tree(
parent_tree.as_ref(),
Some(&commit_tree),
Some(&mut diff_opts),
)?;

self.parse_diff(&diff, file_path)
}

pub fn stage_file(&self, file_path: &str) -> Result<(), AppError> {
let mut index = self.repo.index()?;
let workdir = self
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/git/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ pub struct FileDiff {
pub language: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitInfo {
pub oid: String,
pub message: String,
pub author_name: String,
pub author_email: String,
pub timestamp: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RepositoryStatus {
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ pub fn run() {
get_status,
get_file_diff,
get_combined_diff,
get_branch_log,
get_commit_log,
get_commit_files,
get_commit_file_diff,
stage_file,
unstage_file,
stage_all,
Expand Down
64 changes: 58 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,26 @@ import { FileList } from "@/features/files";
import { DiffViewer } from "@/features/diff";
import { CommentPopover, CommentList } from "@/features/comments";
import { CommitPanel } from "@/features/commit";
import { CommitList, CommitFileList } from "@/features/commits";
import { Button } from "@/components/ui";

export default function App() {
const { repoPath, status, setRepoPath, refreshStatus, initDemoMode, isDemo } =
useGitStore();
const {
repoPath,
status,
setRepoPath,
refreshStatus,
initDemoMode,
isDemo,
reviewMode,
setReviewMode,
selectedCommit,
} = useGitStore();
const {
draft,
exportToMarkdown,
setRepoPath: setCommentRepoPath,
setReviewContext,
getAllComments,
initDemoComments,
} = useCommentStore();
Expand Down Expand Up @@ -72,6 +83,15 @@ export default function App() {
}
}, [repoPath, setCommentRepoPath, isDemo]);

// Sync comment store's review context when mode or selected commit changes
useEffect(() => {
if (reviewMode === "commits" && selectedCommit) {
setReviewContext(selectedCommit.oid);
} else {
setReviewContext("working");
}
}, [reviewMode, selectedCommit, setReviewContext]);

// Listen for CLI-provided repo path from backend
useEffect(() => {
const unlisten = listen<string>("open-repo", (event) => {
Expand All @@ -88,7 +108,7 @@ export default function App() {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "c" && e.shiftKey) {
e.preventDefault();
const markdown = exportToMarkdown();
const markdown = exportToMarkdown(selectedCommit?.oid);
if (markdown) {
navigator.clipboard.writeText(markdown);
}
Expand All @@ -102,7 +122,7 @@ export default function App() {

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [exportToMarkdown, refreshStatus]);
}, [exportToMarkdown, refreshStatus, selectedCommit]);

// Auto-open comments panel when first comment is added
const comments = getAllComments();
Expand Down Expand Up @@ -229,10 +249,42 @@ export default function App() {
className="flex-shrink-0 flex flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900"
style={{ width: sidebarWidth }}
>
{/* Mode toggle */}
{repoPath && (
<div className="flex-shrink-0 flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setReviewMode("changes")}
className={`flex-1 py-1.5 text-xs font-medium cursor-default transition-colors ${
reviewMode === "changes"
? "bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
: "bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Changes
</button>
<button
onClick={() => setReviewMode("commits")}
className={`flex-1 py-1.5 text-xs font-medium cursor-default transition-colors border-l border-gray-200 dark:border-gray-700 ${
reviewMode === "commits"
? "bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
: "bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Commits
</button>
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<FileList />
{reviewMode === "commits" ? (
<>
<CommitList />
<CommitFileList />
</>
) : (
<FileList />
)}
</div>
<CommitPanel />
{reviewMode === "changes" && <CommitPanel />}
</aside>

{/* Diff viewer */}
Expand Down
Loading