From dc892b8359fa426efdc992202126412bc1375cc9 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Wed, 15 Apr 2026 21:19:14 +0200 Subject: [PATCH] feat(commits): add commit review mode When agents commit changes directly, the existing unstaged-changes workflow loses its value. A commit review mode lets the same inline commenting workflow apply to already-committed work, keeping the tool useful regardless of how changes were landed. The sidebar gains a Changes/Commits toggle. In commit mode, the branch log is shown first (commits unique to the current branch vs main/master/develop/dev, checked both locally and on origin); if no base branch is found, a paginated log with "Load more" is shown instead. Selecting a commit lists its changed files and opens diffs in the existing viewer. Comments are scoped per review context so working-directory comments and per-commit comments are kept separate. When exporting a review made in commit mode, each comment includes a tag with the full commit hash, allowing agents to create fixup commits targeting the reviewed commit when addressing issues. Assisted-by: Claude Code (claude-sonnet-4-6) --- src-tauri/src/commands/commits.rs | 41 ++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/git/repository.rs | 173 +++++++++++++++++++++++- src-tauri/src/git/types.rs | 10 ++ src-tauri/src/lib.rs | 4 + src/App.tsx | 64 ++++++++- src/features/comments/CommentList.tsx | 6 +- src/features/commits/CommitFileList.tsx | 89 ++++++++++++ src/features/commits/CommitList.tsx | 94 +++++++++++++ src/features/commits/index.ts | 2 + src/features/files/FileList.tsx | 15 +- src/lib/useCommentCountByFile.ts | 18 +++ src/stores/commentStore.ts | 74 ++++++---- src/stores/gitStore.ts | 153 +++++++++++++++++---- src/types/git.ts | 10 ++ 15 files changed, 676 insertions(+), 79 deletions(-) create mode 100644 src-tauri/src/commands/commits.rs create mode 100644 src/features/commits/CommitFileList.tsx create mode 100644 src/features/commits/CommitList.tsx create mode 100644 src/features/commits/index.ts create mode 100644 src/lib/useCommentCountByFile.ts diff --git a/src-tauri/src/commands/commits.rs b/src-tauri/src/commands/commits.rs new file mode 100644 index 0000000..4b39bfe --- /dev/null +++ b/src-tauri/src/commands/commits.rs @@ -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, + skip: Option, +) -> Result, 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, AppError> { + let repo = GitRepository::open(&repo_path)?; + repo.get_branch_log() +} + +#[tauri::command] +pub fn get_commit_files(repo_path: String, oid: String) -> Result, 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, + ignore_whitespace: Option, +) -> Result { + let repo = GitRepository::open(&repo_path)?; + repo.get_commit_file_diff( + &oid, + &file_path, + context_lines.unwrap_or(3), + ignore_whitespace.unwrap_or(false), + ) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e76de71..02765c4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod commit; +pub mod commits; pub mod diff; pub mod discard; pub mod review; @@ -6,6 +7,7 @@ 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; diff --git a/src-tauri/src/git/repository.rs b/src-tauri/src/git/repository.rs index fed05cf..dc2dac1 100644 --- a/src-tauri/src/git/repository.rs +++ b/src-tauri/src/git/repository.rs @@ -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; @@ -399,6 +399,177 @@ impl GitRepository { }) } + pub fn get_commit_log(&self, count: usize, skip: usize) -> Result, 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, 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, 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 { + 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 diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 5c91fa9..3108c1e 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -62,6 +62,16 @@ pub struct FileDiff { pub language: Option, } +#[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 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4eac4f2..c0a85e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index 7841f82..a4c788d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(); @@ -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("open-repo", (event) => { @@ -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); } @@ -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(); @@ -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 && ( +
+ + +
+ )}
- + {reviewMode === "commits" ? ( + <> + + + + ) : ( + + )}
- + {reviewMode === "changes" && } {/* Diff viewer */} diff --git a/src/features/comments/CommentList.tsx b/src/features/comments/CommentList.tsx index b210b7f..97adfce 100644 --- a/src/features/comments/CommentList.tsx +++ b/src/features/comments/CommentList.tsx @@ -45,7 +45,7 @@ export function CommentList() { clearAllComments, setDraft, } = useCommentStore(); - const { repoPath, status, selectFile } = useGitStore(); + const { repoPath, status, selectFile, selectedCommit } = useGitStore(); const { setScrollToLine } = useUiStore(); const comments = getAllComments(); const [exportStatus, setExportStatus] = useState< @@ -63,14 +63,14 @@ export function CommentList() { }; const handleCopyMarkdown = async () => { - const markdown = exportToMarkdown(); + const markdown = exportToMarkdown(selectedCommit?.oid); if (markdown) { await navigator.clipboard.writeText(markdown); } }; const handleExportForAgent = async () => { - const markdown = exportToMarkdown(); + const markdown = exportToMarkdown(selectedCommit?.oid); if (!markdown || !repoPath) return; setExportStatus("exporting"); diff --git a/src/features/commits/CommitFileList.tsx b/src/features/commits/CommitFileList.tsx new file mode 100644 index 0000000..7096253 --- /dev/null +++ b/src/features/commits/CommitFileList.tsx @@ -0,0 +1,89 @@ +import { clsx } from "clsx"; +import { useGitStore } from "@/stores/gitStore"; +import { useCommentCountByFile } from "@/lib/useCommentCountByFile"; +import type { FileEntry, FileStatus } from "@/types/git"; + +const statusConfig: Record = { + modified: { label: "M", color: "text-yellow-600 dark:text-yellow-400" }, + added: { label: "A", color: "text-green-600 dark:text-green-400" }, + deleted: { label: "D", color: "text-red-600 dark:text-red-400" }, + renamed: { label: "R", color: "text-blue-600 dark:text-blue-400" }, + copied: { label: "C", color: "text-purple-600 dark:text-purple-400" }, + untracked: { label: "U", color: "text-gray-500 dark:text-gray-400" }, + ignored: { label: "I", color: "text-gray-400 dark:text-gray-500" }, + conflicted: { label: "!", color: "text-red-600 dark:text-red-400" }, +}; + +function CommitFileItem({ + file, + isSelected, + commentCount, + onSelect, +}: { + file: FileEntry; + isSelected: boolean; + commentCount: number; + onSelect: () => void; +}) { + const { label, color } = statusConfig[file.status]; + const parts = file.path.split("/"); + const filename = parts.pop() || file.path; + const dir = parts.join("/"); + + return ( + + ); +} + +export function CommitFileList() { + const { commitFiles, selectedFile, selectFile, selectedCommit } = useGitStore(); + const commentCountByFile = useCommentCountByFile(); + + if (!selectedCommit) return null; + + if (commitFiles.length === 0) { + return ( +
+ No files changed +
+ ); + } + + return ( +
+
+ {commitFiles.length} file{commitFiles.length !== 1 ? "s" : ""} changed +
+ {commitFiles.map((file) => ( + selectFile(file)} + /> + ))} +
+ ); +} diff --git a/src/features/commits/CommitList.tsx b/src/features/commits/CommitList.tsx new file mode 100644 index 0000000..135be5c --- /dev/null +++ b/src/features/commits/CommitList.tsx @@ -0,0 +1,94 @@ +import { clsx } from "clsx"; +import { useGitStore } from "@/stores/gitStore"; +import type { CommitInfo } from "@/types/git"; + +function formatRelativeTime(timestamp: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - timestamp; + + if (diff < 60) return "just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`; + return new Date(timestamp * 1000).toLocaleDateString(); +} + +function CommitItem({ + commit, + isSelected, + onSelect, +}: { + commit: CommitInfo; + isSelected: boolean; + onSelect: () => void; +}) { + const firstLine = commit.message.split("\n")[0].trim(); + const shortOid = commit.oid.slice(0, 7); + + return ( + + ); +} + +export function CommitList() { + const { commits, selectedCommit, selectCommit, isLoading, commitsPaginated, loadMoreCommits } = + useGitStore(); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (commits.length === 0) { + return ( +
+ No commits found +
+ ); + } + + return ( +
+ {commits.map((commit) => ( + selectCommit(commit)} + /> + ))} + {commitsPaginated && ( + + )} +
+ ); +} diff --git a/src/features/commits/index.ts b/src/features/commits/index.ts new file mode 100644 index 0000000..7c6974c --- /dev/null +++ b/src/features/commits/index.ts @@ -0,0 +1,2 @@ +export { CommitList } from "./CommitList"; +export { CommitFileList } from "./CommitFileList"; diff --git a/src/features/files/FileList.tsx b/src/features/files/FileList.tsx index e0c338b..ede1f65 100644 --- a/src/features/files/FileList.tsx +++ b/src/features/files/FileList.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useGitStore } from "@/stores/gitStore"; -import { useCommentStore } from "@/stores/commentStore"; +import { useCommentCountByFile } from "@/lib/useCommentCountByFile"; import { Button } from "@/components/ui"; import { FileItem } from "./FileItem"; @@ -14,8 +14,7 @@ export function FileList() { stageAll, unstageAll, } = useGitStore(); - const comments = useCommentStore((state) => state.comments); - const currentRepoPath = useCommentStore((state) => state.currentRepoPath); + const commentCountByFile = useCommentCountByFile(); const { stagedFiles, unstagedFiles } = useMemo(() => { if (!status) return { stagedFiles: [], unstagedFiles: [] }; @@ -26,16 +25,6 @@ export function FileList() { return { stagedFiles: staged, unstagedFiles: unstaged }; }, [status]); - const commentCountByFile = useMemo(() => { - const counts: Record = {}; - if (!currentRepoPath) return counts; - const repoComments = comments[currentRepoPath] || {}; - for (const [filePath, fileComments] of Object.entries(repoComments)) { - counts[filePath] = fileComments.length; - } - return counts; - }, [comments, currentRepoPath]); - const handleStageToggle = (file: (typeof stagedFiles)[0]) => { if (file.staged) { unstageFile(file.path); diff --git a/src/lib/useCommentCountByFile.ts b/src/lib/useCommentCountByFile.ts new file mode 100644 index 0000000..34269a0 --- /dev/null +++ b/src/lib/useCommentCountByFile.ts @@ -0,0 +1,18 @@ +import { useMemo } from "react"; +import { useCommentStore } from "@/stores/commentStore"; + +export function useCommentCountByFile(): Record { + const comments = useCommentStore((state) => state.comments); + const currentRepoPath = useCommentStore((state) => state.currentRepoPath); + const reviewContext = useCommentStore((state) => state.reviewContext); + + return useMemo(() => { + const counts: Record = {}; + if (!currentRepoPath) return counts; + const contextComments = comments[currentRepoPath]?.[reviewContext] || {}; + for (const [filePath, fileComments] of Object.entries(contextComments)) { + counts[filePath] = fileComments.length; + } + return counts; + }, [comments, currentRepoPath, reviewContext]); +} diff --git a/src/stores/commentStore.ts b/src/stores/commentStore.ts index 7a3a153..3bccae1 100644 --- a/src/stores/commentStore.ts +++ b/src/stores/commentStore.ts @@ -5,10 +5,12 @@ import { getLanguageFromPath } from "@/lib/syntax"; interface CommentState { currentRepoPath: string | null; - comments: Record>; // repoPath -> filePath -> comments + reviewContext: string; // "working" for changes mode, commit oid for commit mode + comments: Record>>; // repoPath -> reviewContext -> filePath -> comments draft: CommentDraft | null; setRepoPath: (repoPath: string) => void; + setReviewContext: (context: string) => void; addComment: ( filePath: string, startLine: number, @@ -29,7 +31,7 @@ interface CommentState { getAllComments: () => Comment[]; setDraft: (draft: CommentDraft | null) => void; clearAllComments: () => void; - exportToMarkdown: () => string; + exportToMarkdown: (commitOid?: string) => string; // Demo mode - accepts pre-built comments initDemoComments: (repoPath: string, comments: Record) => void; } @@ -38,11 +40,14 @@ export const useCommentStore = create()( persist( (set, get) => ({ currentRepoPath: null, + reviewContext: "working", comments: {}, draft: null, setRepoPath: (repoPath) => set({ currentRepoPath: repoPath }), + setReviewContext: (context) => set({ reviewContext: context }), + addComment: ( filePath, startLine, @@ -52,7 +57,7 @@ export const useCommentStore = create()( codeSnippet, isOld, ) => { - const { currentRepoPath } = get(); + const { currentRepoPath, reviewContext } = get(); if (!currentRepoPath) return; const comment: Comment = { @@ -72,10 +77,13 @@ export const useCommentStore = create()( ...state.comments, [currentRepoPath]: { ...(state.comments[currentRepoPath] || {}), - [filePath]: [ - ...(state.comments[currentRepoPath]?.[filePath] || []), - comment, - ], + [reviewContext]: { + ...(state.comments[currentRepoPath]?.[reviewContext] || {}), + [filePath]: [ + ...(state.comments[currentRepoPath]?.[reviewContext]?.[filePath] || []), + comment, + ], + }, }, }, draft: null, @@ -83,7 +91,7 @@ export const useCommentStore = create()( }, removeComment: (filePath, commentId) => { - const { currentRepoPath } = get(); + const { currentRepoPath, reviewContext } = get(); if (!currentRepoPath) return; set((state) => ({ @@ -91,16 +99,19 @@ export const useCommentStore = create()( ...state.comments, [currentRepoPath]: { ...(state.comments[currentRepoPath] || {}), - [filePath]: ( - state.comments[currentRepoPath]?.[filePath] || [] - ).filter((c) => c.id !== commentId), + [reviewContext]: { + ...(state.comments[currentRepoPath]?.[reviewContext] || {}), + [filePath]: ( + state.comments[currentRepoPath]?.[reviewContext]?.[filePath] || [] + ).filter((c) => c.id !== commentId), + }, }, }, })); }, updateComment: (filePath, commentId, content, category) => { - const { currentRepoPath } = get(); + const { currentRepoPath, reviewContext } = get(); if (!currentRepoPath) return; set((state) => ({ @@ -108,27 +119,30 @@ export const useCommentStore = create()( ...state.comments, [currentRepoPath]: { ...(state.comments[currentRepoPath] || {}), - [filePath]: ( - state.comments[currentRepoPath]?.[filePath] || [] - ).map((c) => - c.id === commentId ? { ...c, content, category } : c, - ), + [reviewContext]: { + ...(state.comments[currentRepoPath]?.[reviewContext] || {}), + [filePath]: ( + state.comments[currentRepoPath]?.[reviewContext]?.[filePath] || [] + ).map((c) => + c.id === commentId ? { ...c, content, category } : c, + ), + }, }, }, })); }, getFileComments: (filePath) => { - const { currentRepoPath, comments } = get(); + const { currentRepoPath, reviewContext, comments } = get(); if (!currentRepoPath) return []; - return comments[currentRepoPath]?.[filePath] || []; + return comments[currentRepoPath]?.[reviewContext]?.[filePath] || []; }, getAllComments: () => { - const { currentRepoPath, comments } = get(); + const { currentRepoPath, reviewContext, comments } = get(); if (!currentRepoPath) return []; - const repoComments = comments[currentRepoPath] || {}; - return Object.values(repoComments) + const contextComments = comments[currentRepoPath]?.[reviewContext] || {}; + return Object.values(contextComments) .flat() .sort((a, b) => { if (a.filePath !== b.filePath) @@ -140,13 +154,16 @@ export const useCommentStore = create()( setDraft: (draft) => set({ draft }), clearAllComments: () => { - const { currentRepoPath } = get(); + const { currentRepoPath, reviewContext } = get(); if (!currentRepoPath) return; set((state) => ({ comments: { ...state.comments, - [currentRepoPath]: {}, + [currentRepoPath]: { + ...(state.comments[currentRepoPath] || {}), + [reviewContext]: {}, + }, }, })); }, @@ -154,13 +171,14 @@ export const useCommentStore = create()( initDemoComments: (repoPath: string, comments: Record) => { set({ currentRepoPath: repoPath, + reviewContext: "working", comments: { - [repoPath]: comments, + [repoPath]: { working: comments }, }, }); }, - exportToMarkdown: () => { + exportToMarkdown: (commitOid?: string) => { const allComments = get().getAllComments(); if (allComments.length === 0) return ""; @@ -215,7 +233,7 @@ Questions: ${byCategory.question.length} ${lineRef} ${side} ${comment.category} -${ +${commitOid ? `${commitOid}\n` : ""}${ comment.codeSnippet ? ` ${comment.codeSnippet} @@ -232,7 +250,7 @@ ${comment.codeSnippet} }, }), { - name: "revu-comments", + name: "revu-comments-v2", partialize: (state) => ({ comments: state.comments }), }, ), diff --git a/src/stores/gitStore.ts b/src/stores/gitStore.ts index 76966d3..d8e2273 100644 --- a/src/stores/gitStore.ts +++ b/src/stores/gitStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { invoke } from "@tauri-apps/api/core"; -import type { FileEntry, FileDiff, RepositoryStatus } from "@/types/git"; +import type { CommitInfo, FileEntry, FileDiff, RepositoryStatus, ReviewMode } from "@/types/git"; interface DemoState { status: RepositoryStatus; @@ -16,6 +16,12 @@ interface GitState { error: string | null; isDemo: boolean; _demoState: DemoState | null; + reviewMode: ReviewMode; + commits: CommitInfo[]; + commitsPaginated: boolean; // true when showing paginated log (not branch-scoped) + commitsPage: number; // current page in paginated mode + selectedCommit: CommitInfo | null; + commitFiles: FileEntry[]; setRepoPath: (path: string) => Promise; refreshStatus: () => Promise; @@ -33,6 +39,11 @@ interface GitState { discardFile: (filePath: string) => Promise; discardAll: () => Promise; clearError: () => void; + setReviewMode: (mode: ReviewMode) => Promise; + fetchCommitLog: () => Promise; + loadMoreCommits: () => Promise; + selectCommit: (commit: CommitInfo | null) => Promise; + _fetchDiff: (file: FileEntry, fullContext: boolean, ignoreWhitespace: boolean) => Promise; // Demo mode - accepts pre-built demo state initDemoMode: (demoState: DemoState) => void; } @@ -46,6 +57,12 @@ export const useGitStore = create()((set, get) => ({ error: null, isDemo: false, _demoState: null, + reviewMode: "changes", + commits: [], + commitsPaginated: false, + commitsPage: 0, + selectedCommit: null, + commitFiles: [], initDemoMode: (demoState: DemoState) => { const { status, diffs } = demoState; @@ -103,25 +120,11 @@ export const useGitStore = create()((set, get) => ({ set({ selectedFile: file, currentDiff: null }); if (file) { - // In demo mode, use pre-built diff data if (isDemo && _demoState) { - const diff = _demoState.diffs[file.path] || null; - set({ currentDiff: diff }); + set({ currentDiff: _demoState.diffs[file.path] || null }); return; } - - try { - const diff = await invoke("get_file_diff", { - repoPath, - filePath: file.path, - staged: file.staged, - contextLines: fullContext ? 999999 : null, - ignoreWhitespace: ignoreWhitespace || null, - }); - set({ currentDiff: diff }); - } catch (e) { - set({ error: String(e) }); - } + await get()._fetchDiff(file, fullContext, ignoreWhitespace); } }, @@ -129,22 +132,38 @@ export const useGitStore = create()((set, get) => ({ const { repoPath, selectedFile, isDemo, _demoState } = get(); if (!repoPath || !selectedFile) return; - // In demo mode, just return the existing diff if (isDemo && _demoState) { - const diff = _demoState.diffs[selectedFile.path] || null; - set({ currentDiff: diff }); + set({ currentDiff: _demoState.diffs[selectedFile.path] || null }); return; } + await get()._fetchDiff(selectedFile, fullContext, ignoreWhitespace); + }, + + _fetchDiff: async (file: FileEntry, fullContext: boolean, ignoreWhitespace: boolean) => { + const { repoPath, reviewMode, selectedCommit } = get(); + if (!repoPath) return; + try { - const diff = await invoke("get_file_diff", { - repoPath, - filePath: selectedFile.path, - staged: selectedFile.staged, - contextLines: fullContext ? 999999 : null, - ignoreWhitespace: ignoreWhitespace || null, - }); - set({ currentDiff: diff }); + if (reviewMode === "commits" && selectedCommit) { + const diff = await invoke("get_commit_file_diff", { + repoPath, + oid: selectedCommit.oid, + filePath: file.path, + contextLines: fullContext ? 999999 : null, + ignoreWhitespace: ignoreWhitespace || null, + }); + set({ currentDiff: diff }); + } else { + const diff = await invoke("get_file_diff", { + repoPath, + filePath: file.path, + staged: file.staged, + contextLines: fullContext ? 999999 : null, + ignoreWhitespace: ignoreWhitespace || null, + }); + set({ currentDiff: diff }); + } } catch (e) { set({ error: String(e) }); } @@ -252,4 +271,82 @@ export const useGitStore = create()((set, get) => ({ }, clearError: () => set({ error: null }), + + setReviewMode: async (mode: ReviewMode) => { + const { repoPath, refreshStatus, fetchCommitLog } = get(); + if (!repoPath) return; + + set({ + reviewMode: mode, + selectedFile: null, + currentDiff: null, + commits: [], + selectedCommit: null, + commitFiles: [], + commitsPage: 0, + commitsPaginated: false, + }); + + if (mode === "commits") { + await fetchCommitLog(); + } else { + await refreshStatus(); + } + }, + + fetchCommitLog: async () => { + const { repoPath } = get(); + if (!repoPath) return; + + set({ isLoading: true, error: null }); + try { + // Try branch-scoped log first (commits not in main/master/develop/dev) + const branchCommits = await invoke("get_branch_log", { repoPath }); + if (branchCommits.length > 0) { + set({ commits: branchCommits, commitsPaginated: false, commitsPage: 0, isLoading: false }); + return; + } + // Fall back to paginated log + const commits = await invoke("get_commit_log", { repoPath, count: 25, skip: 0 }); + set({ commits, commitsPaginated: true, commitsPage: 0, isLoading: false }); + } catch (e) { + set({ error: String(e), isLoading: false }); + } + }, + + loadMoreCommits: async () => { + const { repoPath, commits, commitsPage, commitsPaginated } = get(); + if (!repoPath || !commitsPaginated) return; + + const nextPage = commitsPage + 1; + try { + const more = await invoke("get_commit_log", { + repoPath, + count: 25, + skip: nextPage * 25, + }); + set({ commits: [...commits, ...more], commitsPage: nextPage }); + } catch (e) { + set({ error: String(e) }); + } + }, + + selectCommit: async (commit: CommitInfo | null) => { + const { repoPath } = get(); + if (!repoPath) return; + + set({ selectedCommit: commit, selectedFile: null, currentDiff: null, commitFiles: [] }); + + if (commit) { + try { + const commitFiles = await invoke("get_commit_files", { + repoPath, + oid: commit.oid, + }); + set({ commitFiles }); + } catch (e) { + set({ error: String(e) }); + } + } + }, })); diff --git a/src/types/git.ts b/src/types/git.ts index 56b37d5..7294fab 100644 --- a/src/types/git.ts +++ b/src/types/git.ts @@ -1,3 +1,13 @@ +export type ReviewMode = "changes" | "commits"; + +export interface CommitInfo { + oid: string; + message: string; + authorName: string; + authorEmail: string; + timestamp: number; +} + export type FileStatus = | "modified" | "added"