diff --git a/designs/branches/styles.css b/designs/branches/styles.css index b81fa49..fe3b0a7 100644 --- a/designs/branches/styles.css +++ b/designs/branches/styles.css @@ -103,12 +103,3 @@ .branch-commit-message { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .branch-commit-meta { display: flex; gap: 8px; font-size: 11px; color: var(--text-muted); flex-shrink: 0; } -/* ===== Operation Footer ===== */ -.operation-footer { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-top: 1px solid var(--border); background: var(--bg-secondary); flex-shrink: 0; } -.operation-summary { display: flex; align-items: center; gap: 12px; font-size: 13px; } -.summary-stat { color: var(--text-secondary); } -.summary-stat strong { color: var(--text-primary); } -.summary-stat.additions { color: var(--success); font-family: 'JetBrains Mono', monospace; font-weight: 600; } -.summary-stat.deletions { color: var(--danger); font-family: 'JetBrains Mono', monospace; font-weight: 600; } -.summary-divider { width: 1px; height: 16px; background: var(--border); } -.operation-buttons { display: flex; gap: 8px; } diff --git a/designs/shared/components.css b/designs/shared/components.css index 8e9852a..727c226 100644 --- a/designs/shared/components.css +++ b/designs/shared/components.css @@ -406,6 +406,7 @@ .summary-stat.additions { color: var(--success); font-family: 'JetBrains Mono', monospace; font-weight: 600; } .summary-stat.deletions { color: var(--danger); font-family: 'JetBrains Mono', monospace; font-weight: 600; } .summary-divider { width: 1px; height: 16px; background: var(--border); } +.operation-buttons { display: flex; gap: 8px; } /* ===== Operation Responsive ===== */ @media (max-width: 1023px) { diff --git a/designs/stash/styles.css b/designs/stash/styles.css index 31ac70f..295d8b5 100644 --- a/designs/stash/styles.css +++ b/designs/stash/styles.css @@ -1,76 +1,3 @@ -/* ===== Operation Layout ===== */ -.operation-layout { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: hidden; -} - -.operation-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 0 20px; - height: 48px; - flex-shrink: 0; - border-bottom: 1px solid var(--border); -} - -.operation-info { - display: flex; - align-items: baseline; - gap: 12px; -} - -.operation-title { - font-size: 14px; - font-weight: 600; - margin: 0; -} - -.operation-desc { - font-size: 12px; - color: var(--text-muted); -} - -.page-actions { - display: flex; - gap: 8px; -} - -.operation-two-column { - display: grid; - grid-template-columns: 1fr 1fr; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.operation-left-panel { - display: flex; - flex-direction: column; - border-right: 1px solid var(--border); - overflow: hidden; - min-height: 0; -} - -.operation-right-panel { - display: flex; - flex-direction: column; - overflow-y: auto; - min-height: 0; -} - -.operation-panel { - overflow: hidden; - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - /* ===== Stash List ===== */ .stash-list { flex: 1; @@ -172,302 +99,13 @@ color: var(--accent); } -/* ===== Stats Bar ===== */ -.preview-stats-bar { - display: flex; - align-items: center; - gap: 16px; - padding: 16px 0; - border-top: 1px solid var(--border); -} - -.stats-label { - font-size: 12px; - font-weight: 600; - color: var(--text-muted); -} - -.stats-visual { - flex: 1; - display: flex; - align-items: center; - gap: 16px; -} - -.stats-bar { - flex: 1; - height: 8px; - background: var(--bg-tertiary); - border-radius: 4px; - overflow: hidden; - display: flex; -} - -.stats-bar-add { background: linear-gradient(90deg, #22c55e, #16a34a); height: 100%; } -.stats-bar-del { background: linear-gradient(90deg, #ef4444, #dc2626); height: 100%; } - -.stats-numbers { - display: flex; - gap: 12px; - font-family: 'JetBrains Mono', monospace; - font-size: 13px; - font-weight: 600; -} - -.stats-numbers .additions { color: var(--success); } -.stats-numbers .deletions { color: var(--danger); } - -/* ===== Operation Footer ===== */ -.operation-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-top: 1px solid var(--border); - background: var(--bg-secondary); - flex-shrink: 0; -} - -.operation-summary { - display: flex; - align-items: center; - gap: 12px; - font-size: 13px; -} - -.summary-stat { color: var(--text-secondary); } -.summary-stat strong { color: var(--text-primary); } -.summary-stat.additions { color: var(--success); font-family: 'JetBrains Mono', monospace; font-weight: 600; } -.summary-stat.deletions { color: var(--danger); font-family: 'JetBrains Mono', monospace; font-weight: 600; } -.summary-divider { width: 1px; height: 16px; background: var(--border); } - -.operation-buttons { +/* ===== Stash Page Actions ===== */ +.page-actions { display: flex; gap: 8px; } -/* ===== Responsive Design ===== */ - -/* Large screens (1400px+) */ - - -/* Medium screens (1024px - 1199px) */ - - -/* Tablet / Small screens (768px - 1023px) */ -@media (max-width: 1023px) { - .operation-two-column { - grid-template-columns: 1fr; - } - .operation-left-panel { - border-right: none; - border-bottom: 1px solid var(--border); - max-height: 50%; - } - - - - -} - -/* Mobile / Very small screens (below 768px) */ -@media (max-width: 767px) { - - .operation-two-column { - grid-template-columns: 1fr; - } - .operation-left-panel { - border-right: none; - border-bottom: 1px solid var(--border); - max-height: 50%; - } - - - - - - -} - -/* ===== Changes Preview ===== */ -.changes-preview { - display: flex; - flex-direction: column; - gap: 16px; - padding: 20px; -} - -.preview-header { - padding-bottom: 16px; - border-bottom: 1px solid var(--border); -} - -.preview-commit-info { - display: flex; - flex-direction: column; - gap: 6px; -} - -.preview-hash { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; -} - +/* ===== Stash Preview Hash ===== */ .preview-hash.stash { color: var(--accent); } - -.preview-message { - font-size: 15px; - font-weight: 600; - line-height: 1.4; -} - -.preview-author { - font-size: 12px; - color: var(--text-muted); -} - -.preview-section { - display: flex; - flex-direction: column; - gap: 12px; -} - -.section-header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.section-title { - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.section-count { - background: var(--bg-tertiary); - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - color: var(--text-muted); -} - -/* ===== Preview File List ===== */ -.preview-files-list { - display: flex; - flex-direction: column; - overflow: hidden; -} - -.preview-file { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-bottom: 1px solid var(--border); - font-size: 12px; - cursor: pointer; - transition: background-color 0.15s ease; -} - -.preview-file:hover { background: var(--bg-tertiary); } -.preview-file.expanded { background: var(--accent-dim); } - -.preview-file-status { - width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - font-weight: 700; - font-size: 11px; - flex-shrink: 0; -} - -.preview-file-status.added { background: rgba(34, 197, 94, 0.2); color: var(--success); } -.preview-file-status.deleted { background: rgba(239, 68, 68, 0.2); color: var(--danger); } -.preview-file-status.modified { background: rgba(59, 130, 246, 0.2); color: var(--accent); } - -.preview-file-path { - flex: 1; - font-family: 'JetBrains Mono', monospace; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.preview-file-stats { - display: flex; - gap: 8px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; -} - -.stat-add { color: var(--success); } -.stat-del { color: var(--danger); } - -.preview-file-expand { - width: 16px; - height: 16px; - color: var(--text-muted); - flex-shrink: 0; - transition: transform 0.2s ease; -} - -.preview-file-expand svg { width: 16px; height: 16px; } -.preview-file.expanded .preview-file-expand { transform: rotate(180deg); color: var(--accent); } - -/* ===== Preview Diff ===== */ -.preview-file-diff { - border-bottom: 1px solid var(--border); - background: var(--bg-secondary); - overflow: hidden; -} - -.preview-file-diff .diff-preview-content { - max-height: 200px; - overflow-y: auto; -} - -.preview-file-diff .diff-hunk { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; -} - -.preview-file-diff .diff-hunk-header { - padding: 6px 12px; - background: var(--purple-dim); - color: var(--purple); - font-size: 10px; -} - -.preview-file-diff .diff-line { - display: flex; -} - -.preview-file-diff .diff-line .line-num { - width: 32px; - padding: 2px 8px; - text-align: right; - color: var(--text-muted); - background: var(--bg-tertiary); - flex-shrink: 0; -} - -.preview-file-diff .diff-line .line-code { - flex: 1; - padding: 2px 12px; - white-space: pre; -} - -.preview-file-diff .diff-line.add { background: rgba(34, 197, 94, 0.1); } -.preview-file-diff .diff-line.add .line-code { color: var(--success); } -.preview-file-diff .diff-line.del { background: rgba(239, 68, 68, 0.1); } -.preview-file-diff .diff-line.del .line-code { color: var(--danger); } -.preview-file-diff .diff-line.context .line-code { color: var(--text-secondary); } diff --git a/src-tauri/src/commands/cherry_pick.rs b/src-tauri/src/commands/cherry_pick.rs new file mode 100644 index 0000000..41fbf4d --- /dev/null +++ b/src-tauri/src/commands/cherry_pick.rs @@ -0,0 +1,51 @@ +use tauri::State; + +use crate::git::types::{CherryPickMode, CherryPickResult}; +use crate::state::AppState; + +#[tauri::command] +pub fn cherry_pick( + oids: Vec, + mode: CherryPickMode, + state: State<'_, AppState>, +) -> Result { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + let oid_refs: Vec<&str> = oids.iter().map(|s| s.as_str()).collect(); + backend + .cherry_pick(&oid_refs, mode) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn is_cherry_picking(state: State<'_, AppState>) -> Result { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.is_cherry_picking().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn abort_cherry_pick(state: State<'_, AppState>) -> Result<(), String> { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.abort_cherry_pick().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn continue_cherry_pick(state: State<'_, AppState>) -> Result { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.continue_cherry_pick().map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 06031bb..5315a54 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod ai; pub mod branch; +pub mod cherry_pick; pub mod config; pub mod conflict; pub mod git; @@ -7,5 +8,6 @@ pub mod history; pub mod hosting; pub mod rebase; pub mod remote; +pub mod revert; pub mod stash; pub mod tag; diff --git a/src-tauri/src/commands/revert.rs b/src-tauri/src/commands/revert.rs new file mode 100644 index 0000000..96fa76c --- /dev/null +++ b/src-tauri/src/commands/revert.rs @@ -0,0 +1,48 @@ +use tauri::State; + +use crate::git::types::{RevertMode, RevertResult}; +use crate::state::AppState; + +#[tauri::command] +pub fn revert( + oid: String, + mode: RevertMode, + state: State<'_, AppState>, +) -> Result { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.revert(&oid, mode).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn is_reverting(state: State<'_, AppState>) -> Result { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.is_reverting().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn abort_revert(state: State<'_, AppState>) -> Result<(), String> { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.abort_revert().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn continue_revert(state: State<'_, AppState>) -> Result { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.continue_revert().map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/git/backend.rs b/src-tauri/src/git/backend.rs index d72128e..ec77be8 100644 --- a/src-tauri/src/git/backend.rs +++ b/src-tauri/src/git/backend.rs @@ -2,10 +2,11 @@ use std::path::Path; use crate::git::error::GitResult; use crate::git::types::{ - BlameResult, BranchInfo, CommitDetail, CommitInfo, CommitLogResult, CommitResult, ConflictFile, - ConflictResolution, DiffOptions, FetchResult, FileDiff, HunkIdentifier, LineRange, LogFilter, - MergeBaseContent, MergeOption, MergeResult, PullOption, PushResult, RebaseResult, RebaseState, - RebaseTodoEntry, RemoteInfo, RepoStatus, StashEntry, TagInfo, + BlameResult, BranchInfo, CherryPickMode, CherryPickResult, CommitDetail, CommitInfo, + CommitLogResult, CommitResult, ConflictFile, ConflictResolution, DiffOptions, FetchResult, + FileDiff, HunkIdentifier, LineRange, LogFilter, MergeBaseContent, MergeOption, MergeResult, + PullOption, PushResult, RebaseResult, RebaseState, RebaseTodoEntry, RemoteInfo, RepoStatus, + RevertMode, RevertResult, StashEntry, TagInfo, }; pub trait GitBackend: Send + Sync { @@ -88,4 +89,16 @@ pub trait GitBackend: Send + Sync { fn get_rebase_state(&self) -> GitResult>; fn get_rebase_todo(&self, onto: &str, limit: usize) -> GitResult>; fn get_merge_base_content(&self, path: &str) -> GitResult; + + // Cherry-pick operations + fn cherry_pick(&self, oids: &[&str], mode: CherryPickMode) -> GitResult; + fn is_cherry_picking(&self) -> GitResult; + fn abort_cherry_pick(&self) -> GitResult<()>; + fn continue_cherry_pick(&self) -> GitResult; + + // Revert operations + fn revert(&self, oid: &str, mode: RevertMode) -> GitResult; + fn is_reverting(&self) -> GitResult; + fn abort_revert(&self) -> GitResult<()>; + fn continue_revert(&self) -> GitResult; } diff --git a/src-tauri/src/git/error.rs b/src-tauri/src/git/error.rs index daa81eb..bb5d116 100644 --- a/src-tauri/src/git/error.rs +++ b/src-tauri/src/git/error.rs @@ -91,6 +91,12 @@ pub enum GitError { #[error("failed to rebase: {0}")] RebaseFailed(#[source] Box), + + #[error("failed to cherry-pick: {0}")] + CherryPickFailed(#[source] Box), + + #[error("failed to revert: {0}")] + RevertFailed(#[source] Box), } pub type GitResult = Result; diff --git a/src-tauri/src/git/git2_backend.rs b/src-tauri/src/git/git2_backend.rs index cea2121..d0984a9 100644 --- a/src-tauri/src/git/git2_backend.rs +++ b/src-tauri/src/git/git2_backend.rs @@ -9,13 +9,14 @@ use crate::git::auth::create_credentials_callback; use crate::git::backend::GitBackend; use crate::git::error::{GitError, GitResult}; use crate::git::types::{ - BlameLine, BlameResult, BranchInfo, CommitDetail, CommitFileChange, CommitFileStatus, - CommitGraphRow, CommitInfo, CommitLogResult, CommitRef, CommitRefKind, CommitResult, - CommitStats, ConflictBlock, ConflictFile, ConflictResolution, DiffHunk, DiffLine, DiffLineKind, - DiffOptions, FetchResult, FileDiff, FileStatus, FileStatusKind, GraphEdge, GraphNodeType, - HunkIdentifier, LineRange, LogFilter, MergeBaseContent, MergeKind, MergeOption, MergeResult, - PullOption, PushResult, RebaseAction, RebaseResult, RebaseState, RebaseTodoEntry, RemoteInfo, - RepoStatus, StagingState, StashEntry, TagInfo, WordSegment, + BlameLine, BlameResult, BranchInfo, CherryPickMode, CherryPickResult, CommitDetail, + CommitFileChange, CommitFileStatus, CommitGraphRow, CommitInfo, CommitLogResult, CommitRef, + CommitRefKind, CommitResult, CommitStats, ConflictBlock, ConflictFile, ConflictResolution, + DiffHunk, DiffLine, DiffLineKind, DiffOptions, FetchResult, FileDiff, FileStatus, + FileStatusKind, GraphEdge, GraphNodeType, HunkIdentifier, LineRange, LogFilter, + MergeBaseContent, MergeKind, MergeOption, MergeResult, PullOption, PushResult, RebaseAction, + RebaseResult, RebaseState, RebaseTodoEntry, RemoteInfo, RepoStatus, RevertMode, RevertResult, + StagingState, StashEntry, TagInfo, WordSegment, }; pub struct Git2Backend { @@ -1652,6 +1653,303 @@ impl GitBackend for Git2Backend { theirs_content, }) } + + fn cherry_pick(&self, oids: &[&str], mode: CherryPickMode) -> GitResult { + let repo = self.repo.lock().unwrap(); + + for oid_str in oids { + let oid = + Oid::from_str(oid_str).map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let commit = repo + .find_commit(oid) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + + let mut opts = git2::CherrypickOptions::new(); + if mode == CherryPickMode::Merge { + opts.mainline(1); + } + repo.cherrypick(&commit, Some(&mut opts)) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + + let index = repo + .index() + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + + if index.has_conflicts() { + let conflicts = collect_conflict_paths(&index); + return Ok(CherryPickResult { + completed: false, + conflicts, + oid: None, + }); + } + + if mode == CherryPickMode::NoCommit { + let _ = repo.cleanup_state(); + continue; + } + + // Create commit manually since git2 cherrypick only applies to worktree + let mut index = repo + .index() + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let tree_oid = index + .write_tree() + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let tree = repo + .find_tree(tree_oid) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let sig = repo + .signature() + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let head_commit = repo + .head() + .and_then(|h| h.peel_to_commit()) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + + let original_msg = commit.message().unwrap_or(""); + let message = match mode { + CherryPickMode::Normal => { + format!("{original_msg}\n\n(cherry picked from commit {oid_str})") + } + CherryPickMode::Merge => original_msg.to_string(), + CherryPickMode::NoCommit => unreachable!(), + }; + + repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&head_commit]) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + + let _ = repo.cleanup_state(); + } + + let head_oid = if mode == CherryPickMode::NoCommit { + None + } else { + repo.head() + .ok() + .and_then(|h| h.target()) + .map(|o| o.to_string()) + }; + + Ok(CherryPickResult { + completed: true, + conflicts: Vec::new(), + oid: head_oid, + }) + } + + fn is_cherry_picking(&self) -> GitResult { + let repo = self.repo.lock().unwrap(); + Ok(repo.state() == git2::RepositoryState::CherryPick) + } + + fn abort_cherry_pick(&self) -> GitResult<()> { + let repo = self.repo.lock().unwrap(); + repo.cleanup_state() + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force())) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + Ok(()) + } + + fn continue_cherry_pick(&self) -> GitResult { + let repo = self.repo.lock().unwrap(); + + let index = repo + .index() + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + + if index.has_conflicts() { + return Err(GitError::CherryPickFailed( + "unresolved conflicts remain".into(), + )); + } + + let mut index = repo + .index() + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let tree_oid = index + .write_tree() + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let tree = repo + .find_tree(tree_oid) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let sig = repo + .signature() + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let head_commit = repo + .head() + .and_then(|h| h.peel_to_commit()) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + + let cherry_head_path = repo.path().join("CHERRY_PICK_HEAD"); + let cherry_head_content = std::fs::read_to_string(&cherry_head_path) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let cherry_oid = Oid::from_str(cherry_head_content.trim()) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + let cherry_commit = repo + .find_commit(cherry_oid) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + + let original_msg = cherry_commit.message().unwrap_or(""); + let message = format!( + "{original_msg}\n\n(cherry picked from commit {})", + cherry_oid + ); + + let oid = repo + .commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&head_commit]) + .map_err(|e| GitError::CherryPickFailed(Box::new(e)))?; + + let _ = repo.cleanup_state(); + + Ok(CherryPickResult { + completed: true, + conflicts: Vec::new(), + oid: Some(oid.to_string()), + }) + } + + fn revert(&self, oid_str: &str, mode: RevertMode) -> GitResult { + let repo = self.repo.lock().unwrap(); + + let oid = Oid::from_str(oid_str).map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let commit = repo + .find_commit(oid) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + + repo.revert(&commit, None) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + + let index = repo + .index() + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + + if index.has_conflicts() { + let conflicts = collect_conflict_paths(&index); + return Ok(RevertResult { + completed: false, + conflicts, + oid: None, + }); + } + + if mode == RevertMode::NoCommit || mode == RevertMode::Edit { + let _ = repo.cleanup_state(); + return Ok(RevertResult { + completed: true, + conflicts: Vec::new(), + oid: None, + }); + } + + let mut index = repo + .index() + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let tree_oid = index + .write_tree() + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let tree = repo + .find_tree(tree_oid) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let sig = repo + .signature() + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let head_commit = repo + .head() + .and_then(|h| h.peel_to_commit()) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + + let original_msg = commit.message().unwrap_or("").lines().next().unwrap_or(""); + let message = format!("Revert \"{original_msg}\"\n\nThis reverts commit {oid_str}."); + + let new_oid = repo + .commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&head_commit]) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + + let _ = repo.cleanup_state(); + + Ok(RevertResult { + completed: true, + conflicts: Vec::new(), + oid: Some(new_oid.to_string()), + }) + } + + fn is_reverting(&self) -> GitResult { + let repo = self.repo.lock().unwrap(); + Ok(repo.state() == git2::RepositoryState::Revert) + } + + fn abort_revert(&self) -> GitResult<()> { + let repo = self.repo.lock().unwrap(); + repo.cleanup_state() + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force())) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + Ok(()) + } + + fn continue_revert(&self) -> GitResult { + let repo = self.repo.lock().unwrap(); + + let index = repo + .index() + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + + if index.has_conflicts() { + return Err(GitError::RevertFailed("unresolved conflicts remain".into())); + } + + let mut index = repo + .index() + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let tree_oid = index + .write_tree() + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let tree = repo + .find_tree(tree_oid) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let sig = repo + .signature() + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let head_commit = repo + .head() + .and_then(|h| h.peel_to_commit()) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + + let revert_head_path = repo.path().join("REVERT_HEAD"); + let revert_head_content = std::fs::read_to_string(&revert_head_path) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let revert_oid = Oid::from_str(revert_head_content.trim()) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + let revert_commit = repo + .find_commit(revert_oid) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + + let original_msg = revert_commit + .message() + .unwrap_or("") + .lines() + .next() + .unwrap_or(""); + let message = format!( + "Revert \"{original_msg}\"\n\nThis reverts commit {}.", + revert_oid + ); + + let oid = repo + .commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&head_commit]) + .map_err(|e| GitError::RevertFailed(Box::new(e)))?; + + let _ = repo.cleanup_state(); + + Ok(RevertResult { + completed: true, + conflicts: Vec::new(), + oid: Some(oid.to_string()), + }) + } } impl Git2Backend { diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 78e8458..e8bcc65 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -375,3 +375,37 @@ pub struct MergeBaseContent { pub ours_content: String, pub theirs_content: String, } + +// === Cherry-pick types === + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CherryPickMode { + Normal, + NoCommit, + Merge, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CherryPickResult { + pub completed: bool, + pub conflicts: Vec, + pub oid: Option, +} + +// === Revert types === + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RevertMode { + Auto, + NoCommit, + Edit, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RevertResult { + pub completed: bool, + pub conflicts: Vec, + pub oid: Option, +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fabdf0c..dc07887 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -145,6 +145,14 @@ pub fn run() { commands::rebase::get_rebase_state, commands::rebase::get_rebase_todo, commands::rebase::get_merge_base_content, + commands::cherry_pick::cherry_pick, + commands::cherry_pick::is_cherry_picking, + commands::cherry_pick::abort_cherry_pick, + commands::cherry_pick::continue_cherry_pick, + commands::revert::revert, + commands::revert::is_reverting, + commands::revert::abort_revert, + commands::revert::continue_revert, commands::ai::detect_cli_adapters, commands::ai::generate_commit_message, commands::ai::review_diff, diff --git a/src-tauri/tests/git2_backend_test.rs b/src-tauri/tests/git2_backend_test.rs index 052cc91..10f501a 100644 --- a/src-tauri/tests/git2_backend_test.rs +++ b/src-tauri/tests/git2_backend_test.rs @@ -5,8 +5,8 @@ use std::process::Command; use app_lib::git::backend::GitBackend; use app_lib::git::git2_backend::Git2Backend; use app_lib::git::types::{ - ConflictResolution, DiffLineKind, DiffOptions, HunkIdentifier, LineRange, LogFilter, - MergeOption, PullOption, + CherryPickMode, ConflictResolution, DiffLineKind, DiffOptions, HunkIdentifier, LineRange, + LogFilter, MergeOption, PullOption, RevertMode, }; fn init_test_repo(dir: &Path) { @@ -1532,3 +1532,410 @@ fn resolve_conflict_block_resolves_single_block() { assert!(content.contains("feature-change")); assert!(!content.contains("<<<<<<<")); } + +// ============================ +// Cherry-pick tests +// ============================ + +/// Helper: create a repo with a main branch containing an initial commit, +/// then create a feature branch with one extra commit, and switch back to main. +/// Returns (backend, feature_commit_oid). +fn setup_cherry_pick_repo(dir: &Path) -> (Git2Backend, String) { + let backend = init_repo_with_commit(dir); + let default_branch = backend.current_branch().unwrap(); + + // Create feature branch with a new commit + backend.create_branch("feature").unwrap(); + backend.checkout_branch("feature").unwrap(); + + fs::write(dir.join("cherry.txt"), "cherry content\n").unwrap(); + backend.stage(Path::new("cherry.txt")).unwrap(); + backend.commit("feature: add cherry.txt", false).unwrap(); + + // Get the OID of the feature commit + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 1, 0).unwrap(); + let feature_oid = log.commits[0].oid.clone(); + + // Switch back to main branch + backend.checkout_branch(&default_branch).unwrap(); + + (backend, feature_oid) +} + +#[test] +fn cherry_pick_normal_mode() { + let tmp = tempfile::tempdir().unwrap(); + let (backend, feature_oid) = setup_cherry_pick_repo(tmp.path()); + + let result = backend + .cherry_pick(&[&feature_oid], CherryPickMode::Normal) + .unwrap(); + + assert!( + result.completed, + "Cherry-pick should complete without conflicts" + ); + assert!(result.oid.is_some(), "Should produce a new commit OID"); + assert!(result.conflicts.is_empty(), "Should have no conflicts"); + + // Verify the file was applied + let content = fs::read_to_string(tmp.path().join("cherry.txt")).unwrap(); + assert_eq!(content, "cherry content\n"); + + // Verify not in cherry-pick state + assert!(!backend.is_cherry_picking().unwrap()); +} + +#[test] +fn cherry_pick_no_commit_mode() { + let tmp = tempfile::tempdir().unwrap(); + let (backend, feature_oid) = setup_cherry_pick_repo(tmp.path()); + + let result = backend + .cherry_pick(&[&feature_oid], CherryPickMode::NoCommit) + .unwrap(); + + assert!( + result.completed, + "Cherry-pick should complete without conflicts" + ); + assert!( + result.oid.is_none(), + "NoCommit mode should not produce a commit OID" + ); + + // Verify the file is in the worktree + let content = fs::read_to_string(tmp.path().join("cherry.txt")).unwrap(); + assert_eq!(content, "cherry content\n"); + + // Verify changes are staged + let status = backend.status().unwrap(); + assert!( + status + .files + .iter() + .any(|f| f.path == "cherry.txt" + && f.staging == app_lib::git::types::StagingState::Staged), + "cherry.txt should be staged" + ); +} + +#[test] +fn cherry_pick_conflict_and_abort() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + let default_branch = backend.current_branch().unwrap(); + + // Create a file on main + fs::write(tmp.path().join("conflict.txt"), "main content\n").unwrap(); + backend.stage(Path::new("conflict.txt")).unwrap(); + backend.commit("main: add conflict.txt", false).unwrap(); + + // Create feature branch and modify the same file + backend.create_branch("feature").unwrap(); + backend.checkout_branch("feature").unwrap(); + fs::write(tmp.path().join("conflict.txt"), "feature content\n").unwrap(); + backend.stage(Path::new("conflict.txt")).unwrap(); + backend + .commit("feature: modify conflict.txt", false) + .unwrap(); + + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 1, 0).unwrap(); + let feature_oid = log.commits[0].oid.clone(); + + // Switch to main and modify the same file differently + backend.checkout_branch(&default_branch).unwrap(); + fs::write(tmp.path().join("conflict.txt"), "different main content\n").unwrap(); + backend.stage(Path::new("conflict.txt")).unwrap(); + backend + .commit("main: modify conflict.txt differently", false) + .unwrap(); + + // Cherry-pick should detect conflicts + let result = backend + .cherry_pick(&[&feature_oid], CherryPickMode::Normal) + .unwrap(); + + assert!(!result.completed, "Should have conflicts"); + assert!( + !result.conflicts.is_empty(), + "Should list conflicting files" + ); + assert!( + backend.is_cherry_picking().unwrap(), + "Should be in cherry-pick state" + ); + + // Abort the cherry-pick + backend.abort_cherry_pick().unwrap(); + assert!( + !backend.is_cherry_picking().unwrap(), + "Should no longer be in cherry-pick state" + ); + + // Working directory should be clean (back to main content) + let content = fs::read_to_string(tmp.path().join("conflict.txt")).unwrap(); + assert_eq!(content, "different main content\n"); +} + +#[test] +fn is_cherry_picking_false_by_default() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + assert!(!backend.is_cherry_picking().unwrap()); +} + +// ============================ +// Revert tests +// ============================ + +#[test] +fn revert_auto_mode() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + // Create a file and commit + fs::write(tmp.path().join("revert_target.txt"), "to be reverted\n").unwrap(); + backend.stage(Path::new("revert_target.txt")).unwrap(); + backend.commit("add revert_target.txt", false).unwrap(); + + // Get the OID of the commit to revert + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 1, 0).unwrap(); + let target_oid = log.commits[0].oid.clone(); + + let result = backend.revert(&target_oid, RevertMode::Auto).unwrap(); + + assert!(result.completed, "Revert should complete without conflicts"); + assert!(result.oid.is_some(), "Should produce a new commit OID"); + assert!(result.conflicts.is_empty(), "Should have no conflicts"); + + // The file should be deleted since the commit that added it was reverted + assert!( + !tmp.path().join("revert_target.txt").exists(), + "File should be removed by revert" + ); + + assert!(!backend.is_reverting().unwrap()); +} + +#[test] +fn revert_no_commit_mode() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + // Create a file and commit + fs::write(tmp.path().join("revert_nc.txt"), "no-commit revert\n").unwrap(); + backend.stage(Path::new("revert_nc.txt")).unwrap(); + backend.commit("add revert_nc.txt", false).unwrap(); + + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 1, 0).unwrap(); + let target_oid = log.commits[0].oid.clone(); + + let result = backend.revert(&target_oid, RevertMode::NoCommit).unwrap(); + + assert!(result.completed, "Revert should complete without conflicts"); + assert!( + result.oid.is_none(), + "NoCommit mode should not produce a commit OID" + ); + + // File should be removed in worktree + assert!( + !tmp.path().join("revert_nc.txt").exists(), + "File should be removed in worktree" + ); +} + +#[test] +fn revert_conflict_and_abort() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + // Create a file and commit + fs::write(tmp.path().join("revert_conflict.txt"), "original\n").unwrap(); + backend.stage(Path::new("revert_conflict.txt")).unwrap(); + backend.commit("add revert_conflict.txt", false).unwrap(); + + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 1, 0).unwrap(); + let add_oid = log.commits[0].oid.clone(); + + // Modify the file in a later commit (this will conflict with reverting the addition) + fs::write(tmp.path().join("revert_conflict.txt"), "modified content\n").unwrap(); + backend.stage(Path::new("revert_conflict.txt")).unwrap(); + backend.commit("modify revert_conflict.txt", false).unwrap(); + + // Try to revert the original addition commit — should conflict + let result = backend.revert(&add_oid, RevertMode::Auto).unwrap(); + + assert!(!result.completed, "Revert should have conflicts"); + assert!( + !result.conflicts.is_empty(), + "Should list conflicting files" + ); + assert!(backend.is_reverting().unwrap(), "Should be in revert state"); + + // Abort + backend.abort_revert().unwrap(); + assert!( + !backend.is_reverting().unwrap(), + "Should no longer be in revert state" + ); + + // File should be restored + let content = fs::read_to_string(tmp.path().join("revert_conflict.txt")).unwrap(); + assert_eq!(content, "modified content\n"); +} + +#[test] +fn is_reverting_false_by_default() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + assert!(!backend.is_reverting().unwrap()); +} + +#[test] +fn continue_cherry_pick_after_conflict_resolution() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + let default_branch = backend.current_branch().unwrap(); + + // Create a file on main + fs::write(tmp.path().join("conflict.txt"), "main content\n").unwrap(); + backend.stage(Path::new("conflict.txt")).unwrap(); + backend.commit("main: add conflict.txt", false).unwrap(); + + // Create feature branch and modify the same file + backend.create_branch("feature").unwrap(); + backend.checkout_branch("feature").unwrap(); + fs::write(tmp.path().join("conflict.txt"), "feature content\n").unwrap(); + backend.stage(Path::new("conflict.txt")).unwrap(); + backend + .commit("feature: modify conflict.txt", false) + .unwrap(); + + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 1, 0).unwrap(); + let feature_oid = log.commits[0].oid.clone(); + + // Switch to main and modify the same file differently + backend.checkout_branch(&default_branch).unwrap(); + fs::write(tmp.path().join("conflict.txt"), "different main content\n").unwrap(); + backend.stage(Path::new("conflict.txt")).unwrap(); + backend + .commit("main: modify conflict.txt differently", false) + .unwrap(); + + // Cherry-pick should detect conflicts + let result = backend + .cherry_pick(&[&feature_oid], CherryPickMode::Normal) + .unwrap(); + assert!(!result.completed); + assert!(backend.is_cherry_picking().unwrap()); + + // Resolve the conflict and mark resolved + backend + .resolve_conflict("conflict.txt", ConflictResolution::Theirs) + .unwrap(); + backend.mark_resolved("conflict.txt").unwrap(); + + // Continue cherry-pick + let result = backend.continue_cherry_pick().unwrap(); + assert!(result.completed); + assert!(result.oid.is_some()); + assert!(!backend.is_cherry_picking().unwrap()); + + let content = fs::read_to_string(tmp.path().join("conflict.txt")).unwrap(); + assert_eq!(content, "feature content\n"); +} + +#[test] +fn continue_revert_after_conflict_resolution() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + // Create a file and commit + fs::write(tmp.path().join("revert_cont.txt"), "original\n").unwrap(); + backend.stage(Path::new("revert_cont.txt")).unwrap(); + backend.commit("add revert_cont.txt", false).unwrap(); + + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 1, 0).unwrap(); + let add_oid = log.commits[0].oid.clone(); + + // Modify the file in a later commit to cause conflict on revert + fs::write(tmp.path().join("revert_cont.txt"), "modified content\n").unwrap(); + backend.stage(Path::new("revert_cont.txt")).unwrap(); + backend.commit("modify revert_cont.txt", false).unwrap(); + + // Revert the original addition — should conflict + let result = backend.revert(&add_oid, RevertMode::Auto).unwrap(); + assert!(!result.completed); + assert!(backend.is_reverting().unwrap()); + + // Resolve the conflict manually and mark resolved + backend + .resolve_conflict( + "revert_cont.txt", + ConflictResolution::Manual("resolved content\n".to_string()), + ) + .unwrap(); + backend.mark_resolved("revert_cont.txt").unwrap(); + + // Continue revert + let result = backend.continue_revert().unwrap(); + assert!(result.completed); + assert!(result.oid.is_some()); + assert!(!backend.is_reverting().unwrap()); + + let content = fs::read_to_string(tmp.path().join("revert_cont.txt")).unwrap(); + assert_eq!(content, "resolved content\n"); +} diff --git a/src/App.tsx b/src/App.tsx index 4b97aa5..4aa83c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,11 +10,13 @@ import { useTheme } from "./hooks/useTheme"; import { BlamePage } from "./pages/blame"; import { BranchesPage } from "./pages/branches"; import { ChangesPage } from "./pages/changes"; +import { CherryPickPage } from "./pages/cherry-pick"; import { ConflictModal } from "./pages/conflict"; import { FileHistoryPage } from "./pages/file-history"; import { HistoryPage } from "./pages/history"; import { HostingPage } from "./pages/hosting"; import { RebasePage } from "./pages/rebase"; +import { RevertPage } from "./pages/revert"; import { StashPage } from "./pages/stash"; import type { PullOption } from "./services/git"; import { useConfigStore } from "./stores/configStore"; @@ -35,6 +37,8 @@ export function App() { const rebasing = useGitStore((s) => s.rebasing); const fetchMergeState = useGitStore((s) => s.fetchMergeState); const fetchRebaseState = useGitStore((s) => s.fetchRebaseState); + const fetchCherryPickState = useGitStore((s) => s.fetchCherryPickState); + const fetchRevertState = useGitStore((s) => s.fetchRevertState); const fetchStashes = useGitStore((s) => s.fetchStashes); const loadConfig = useConfigStore((s) => s.loadConfig); const addToast = useUIStore((s) => s.addToast); @@ -64,6 +68,12 @@ export function App() { fetchRebaseState().catch((e: unknown) => { addToast(String(e), "error"); }); + fetchCherryPickState().catch((e: unknown) => { + addToast(String(e), "error"); + }); + fetchRevertState().catch((e: unknown) => { + addToast(String(e), "error"); + }); }, [ loadConfig, fetchBranch, @@ -71,6 +81,8 @@ export function App() { fetchStashes, fetchMergeState, fetchRebaseState, + fetchCherryPickState, + fetchRevertState, addToast, ]); @@ -87,7 +99,20 @@ export function App() { fetchRebaseState().catch((e: unknown) => { console.error("Auto-refresh rebase state failed:", e); }); - }, [fetchStatus, fetchBranch, fetchMergeState, fetchRebaseState]); + fetchCherryPickState().catch((e: unknown) => { + console.error("Auto-refresh cherry-pick state failed:", e); + }); + fetchRevertState().catch((e: unknown) => { + console.error("Auto-refresh revert state failed:", e); + }); + }, [ + fetchStatus, + fetchBranch, + fetchMergeState, + fetchRebaseState, + fetchCherryPickState, + fetchRevertState, + ]); useFileWatcher(handleRepoChanged); @@ -158,6 +183,8 @@ export function App() { {activePage === "file-history" && } {activePage === "stash" && } {activePage === "rebase" && } + {activePage === "cherry-pick" && } + {activePage === "revert" && } {activePage === "hosting" && } diff --git a/src/components/organisms/OperationPreview.tsx b/src/components/organisms/OperationPreview.tsx new file mode 100644 index 0000000..38d66e9 --- /dev/null +++ b/src/components/organisms/OperationPreview.tsx @@ -0,0 +1,82 @@ +import type { CommitDetail } from "../../services/history"; +import { formatRelativeDate } from "../../utils/date"; +import { statusSymbol } from "../../utils/statusSymbol"; + +interface OperationPreviewProps { + detail: CommitDetail | null; + hashClassName: string; +} + +export function OperationPreview({ + detail, + hashClassName, +}: OperationPreviewProps) { + if (!detail) { + return ( +
+

Select a commit to preview changes

+
+ ); + } + + const totalAdd = detail.stats.additions; + const totalDel = detail.stats.deletions; + const total = totalAdd + totalDel; + const addPct = total > 0 ? (totalAdd / total) * 100 : 0; + const delPct = total > 0 ? (totalDel / total) * 100 : 0; + + return ( +
+
+
+
+ {detail.info.short_oid} +
+
{detail.info.message}
+
+ {detail.info.author_name} ·{" "} + {formatRelativeDate(detail.info.author_date)} +
+
+
+ +
+
+ Changed Files + {detail.files.length} +
+
+ {detail.files.map((file) => { + const st = statusSymbol(file.status); + return ( +
+
+ {st.symbol} +
+
{file.path}
+
+ +{file.additions} + -{file.deletions} +
+
+ ); + })} +
+
+ +
+
Impact
+
+
+
+
+
+
+ +{totalAdd} + -{totalDel} +
+
+
+
+ ); +} diff --git a/src/components/organisms/Sidebar.tsx b/src/components/organisms/Sidebar.tsx index a0af85d..7453460 100644 --- a/src/components/organisms/Sidebar.tsx +++ b/src/components/organisms/Sidebar.tsx @@ -68,34 +68,68 @@ export function Sidebar({ changesCount }: SidebarProps) { +
+
+
Advanced
+ +
diff --git a/src/hooks/useCommitLog.ts b/src/hooks/useCommitLog.ts new file mode 100644 index 0000000..52138ed --- /dev/null +++ b/src/hooks/useCommitLog.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; +import type { CommitInfo } from "../services/history"; +import { getCommitLog } from "../services/history"; +import { useUIStore } from "../stores/uiStore"; + +const DEFAULT_LIMIT = 100; + +const EMPTY_FILTER = { + author: null, + since: null, + until: null, + message: null, + path: null, +}; + +export function useCommitLog(limit: number = DEFAULT_LIMIT) { + const [commits, setCommits] = useState([]); + const addToast = useUIStore((s) => s.addToast); + + useEffect(() => { + getCommitLog(EMPTY_FILTER, limit, 0) + .then((result) => setCommits(result.commits)) + .catch((e: unknown) => addToast(String(e), "error")); + }, [limit, addToast]); + + return commits; +} diff --git a/src/hooks/useCommitSearch.ts b/src/hooks/useCommitSearch.ts new file mode 100644 index 0000000..26245cf --- /dev/null +++ b/src/hooks/useCommitSearch.ts @@ -0,0 +1,19 @@ +import { useMemo, useState } from "react"; +import type { CommitInfo } from "../services/history"; + +export function useCommitSearch(commits: CommitInfo[]) { + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search) return commits; + const lower = search.toLowerCase(); + return commits.filter( + (c) => + c.message.toLowerCase().includes(lower) || + c.short_oid.includes(search) || + c.author_name.toLowerCase().includes(lower), + ); + }, [commits, search]); + + return { search, setSearch, filtered } as const; +} diff --git a/src/main.tsx b/src/main.tsx index a8a5fac..e32bde4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,6 +17,9 @@ import "./styles/conflict.css"; import "./styles/ai.css"; import "./styles/settings.css"; import "./styles/hosting.css"; +import "./styles/operation.css"; +import "./styles/cherry-pick.css"; +import "./styles/revert.css"; const root = document.getElementById("root"); if (!root) throw new Error("Root element not found"); diff --git a/src/pages/cherry-pick/index.tsx b/src/pages/cherry-pick/index.tsx new file mode 100644 index 0000000..c130a73 --- /dev/null +++ b/src/pages/cherry-pick/index.tsx @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useState } from "react"; +import type { CherryPickMode } from "../../services/cherryPick"; +import { getBranchCommits } from "../../services/git"; +import type { CommitDetail, CommitInfo } from "../../services/history"; +import { getCommitDetail } from "../../services/history"; +import { useGitStore } from "../../stores/gitStore"; +import { useUIStore } from "../../stores/uiStore"; +import "../../styles/cherry-pick.css"; +import { CherryPickCommitList } from "./organisms/CherryPickCommitList"; +import { CherryPickOptions } from "./organisms/CherryPickOptions"; +import { CherryPickPreview } from "./organisms/CherryPickPreview"; + +export function CherryPickPage() { + const [selectedBranch, setSelectedBranch] = useState(""); + const [commits, setCommits] = useState([]); + const [selectedOids, setSelectedOids] = useState>(new Set()); + const [mode, setMode] = useState("normal"); + const [previewDetail, setPreviewDetail] = useState(null); + const [executing, setExecuting] = useState(false); + + const branches = useGitStore((s) => s.branches); + const currentBranch = useGitStore((s) => s.currentBranch); + const fetchBranches = useGitStore((s) => s.fetchBranches); + const cherryPick = useGitStore((s) => s.cherryPick); + const fetchStatus = useGitStore((s) => s.fetchStatus); + const addToast = useUIStore((s) => s.addToast); + const openModal = useUIStore((s) => s.openModal); + + useEffect(() => { + fetchBranches().catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [fetchBranches, addToast]); + + const availableBranches = branches.filter( + (b) => b.name !== currentBranch && !b.is_remote, + ); + + const handleSelectBranch = useCallback( + async (branch: string) => { + setSelectedBranch(branch); + setSelectedOids(new Set()); + setPreviewDetail(null); + if (!branch) { + setCommits([]); + return; + } + try { + const result = await getBranchCommits(branch, 100); + setCommits(result); + } catch (e: unknown) { + addToast(String(e), "error"); + setCommits([]); + } + }, + [addToast], + ); + + const handleToggle = useCallback( + (oid: string) => { + setSelectedOids((prev) => { + const next = new Set(prev); + if (next.has(oid)) { + next.delete(oid); + } else { + next.add(oid); + } + return next; + }); + + getCommitDetail(oid) + .then(setPreviewDetail) + .catch((e: unknown) => addToast(String(e), "error")); + }, + [addToast], + ); + + const handleExecute = useCallback(async () => { + if (selectedOids.size === 0) return; + setExecuting(true); + try { + const oids = commits + .filter((c) => selectedOids.has(c.oid)) + .map((c) => c.oid); + const result = await cherryPick(oids, mode); + if (result.completed) { + addToast(`Cherry-pick completed: ${oids.length} commit(s)`, "success"); + setSelectedOids(new Set()); + setPreviewDetail(null); + await fetchStatus(); + } else { + addToast( + `Cherry-pick has conflicts in ${result.conflicts.length} file(s)`, + "warning", + ); + openModal("conflict"); + } + } catch (e: unknown) { + addToast(`Cherry-pick failed: ${String(e)}`, "error"); + } finally { + setExecuting(false); + } + }, [ + selectedOids, + commits, + mode, + cherryPick, + addToast, + fetchStatus, + openModal, + ]); + + const selectedCount = selectedOids.size; + const totalAdd = previewDetail?.stats.additions ?? 0; + const totalDel = previewDetail?.stats.deletions ?? 0; + const fileCount = previewDetail?.stats.files_changed ?? 0; + + return ( +
+
+
+

Cherry-pick

+ + Apply specific commits to the current branch + +
+
+
+ + +
+
+
+ + +
+
+ +
+
+
+
+ + {selectedCount} commit(s) selected + + {previewDetail && ( + <> + + +{totalAdd} + -{totalDel} + + {fileCount} files + + )} +
+ +
+
+ ); +} diff --git a/src/pages/cherry-pick/molecules/CherryPickCommitRow.tsx b/src/pages/cherry-pick/molecules/CherryPickCommitRow.tsx new file mode 100644 index 0000000..dd80deb --- /dev/null +++ b/src/pages/cherry-pick/molecules/CherryPickCommitRow.tsx @@ -0,0 +1,38 @@ +import type { CommitInfo } from "../../../services/history"; +import { formatRelativeDate } from "../../../utils/date"; + +interface CherryPickCommitRowProps { + commit: CommitInfo; + selected: boolean; + onToggle: (oid: string) => void; +} + +export function CherryPickCommitRow({ + commit, + selected, + onToggle, +}: CherryPickCommitRowProps) { + return ( + + ); +} diff --git a/src/pages/cherry-pick/organisms/CherryPickCommitList.tsx b/src/pages/cherry-pick/organisms/CherryPickCommitList.tsx new file mode 100644 index 0000000..81a8e0e --- /dev/null +++ b/src/pages/cherry-pick/organisms/CherryPickCommitList.tsx @@ -0,0 +1,44 @@ +import { useCommitSearch } from "../../../hooks/useCommitSearch"; +import type { CommitInfo } from "../../../services/history"; +import { CherryPickCommitRow } from "../molecules/CherryPickCommitRow"; + +interface CherryPickCommitListProps { + commits: CommitInfo[]; + selectedOids: Set; + onToggle: (oid: string) => void; +} + +export function CherryPickCommitList({ + commits, + selectedOids, + onToggle, +}: CherryPickCommitListProps) { + const { search, setSearch, filtered } = useCommitSearch(commits); + + return ( +
+
+ Select commits to apply +
+ setSearch(e.target.value)} + /> +
+
+
+ {filtered.map((commit) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/cherry-pick/organisms/CherryPickOptions.tsx b/src/pages/cherry-pick/organisms/CherryPickOptions.tsx new file mode 100644 index 0000000..1c95f51 --- /dev/null +++ b/src/pages/cherry-pick/organisms/CherryPickOptions.tsx @@ -0,0 +1,55 @@ +import type { CherryPickMode } from "../../../services/cherryPick"; + +interface CherryPickOptionsProps { + mode: CherryPickMode; + onModeChange: (mode: CherryPickMode) => void; +} + +const MODES: { value: CherryPickMode; title: string; desc: string }[] = [ + { + value: "normal", + title: "Normal (-x)", + desc: "Create commit with original info in message", + }, + { + value: "no_commit", + title: "No Commit (--no-commit)", + desc: "Apply changes without creating a commit", + }, + { + value: "merge", + title: "Allow Merge (-m)", + desc: "Allow cherry-picking merge commits", + }, +]; + +export function CherryPickOptions({ + mode, + onModeChange, +}: CherryPickOptionsProps) { + return ( +
+

Cherry-pick Mode

+
+ {MODES.map((m) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/cherry-pick/organisms/CherryPickPreview.tsx b/src/pages/cherry-pick/organisms/CherryPickPreview.tsx new file mode 100644 index 0000000..a87ad88 --- /dev/null +++ b/src/pages/cherry-pick/organisms/CherryPickPreview.tsx @@ -0,0 +1,10 @@ +import { OperationPreview } from "../../../components/organisms/OperationPreview"; +import type { CommitDetail } from "../../../services/history"; + +interface CherryPickPreviewProps { + detail: CommitDetail | null; +} + +export function CherryPickPreview({ detail }: CherryPickPreviewProps) { + return ; +} diff --git a/src/pages/revert/index.tsx b/src/pages/revert/index.tsx new file mode 100644 index 0000000..fd4e0fd --- /dev/null +++ b/src/pages/revert/index.tsx @@ -0,0 +1,120 @@ +import { useCallback, useState } from "react"; +import { useCommitLog } from "../../hooks/useCommitLog"; +import type { CommitDetail } from "../../services/history"; +import { getCommitDetail } from "../../services/history"; +import type { RevertMode } from "../../services/revert"; +import { useGitStore } from "../../stores/gitStore"; +import { useUIStore } from "../../stores/uiStore"; +import { RevertCommitList } from "./organisms/RevertCommitList"; +import { RevertOptions } from "./organisms/RevertOptions"; +import { RevertPreview } from "./organisms/RevertPreview"; + +export function RevertPage() { + const commits = useCommitLog(); + const [selectedOid, setSelectedOid] = useState(null); + const [mode, setMode] = useState("auto"); + const [previewDetail, setPreviewDetail] = useState(null); + const [executing, setExecuting] = useState(false); + + const revertCommit = useGitStore((s) => s.revertCommit); + const fetchStatus = useGitStore((s) => s.fetchStatus); + const addToast = useUIStore((s) => s.addToast); + const openModal = useUIStore((s) => s.openModal); + + const handleSelect = useCallback( + (oid: string) => { + setSelectedOid(oid); + getCommitDetail(oid) + .then(setPreviewDetail) + .catch((e: unknown) => addToast(String(e), "error")); + }, + [addToast], + ); + + const handleExecute = useCallback(async () => { + if (!selectedOid) return; + setExecuting(true); + try { + const result = await revertCommit(selectedOid, mode); + if (result.completed) { + addToast("Revert completed successfully", "success"); + setSelectedOid(null); + setPreviewDetail(null); + await fetchStatus(); + } else { + addToast( + `Revert has conflicts in ${result.conflicts.length} file(s)`, + "warning", + ); + openModal("conflict"); + } + } catch (e: unknown) { + addToast(`Revert failed: ${String(e)}`, "error"); + } finally { + setExecuting(false); + } + }, [selectedOid, mode, revertCommit, addToast, fetchStatus, openModal]); + + const totalAdd = previewDetail?.stats.additions ?? 0; + const totalDel = previewDetail?.stats.deletions ?? 0; + const fileCount = previewDetail?.stats.files_changed ?? 0; + + return ( +
+
+
+

Revert

+ + Create a new commit that undoes changes + +
+
+
+
+ + +
+
+ +
+
+
+
+ + {selectedOid ? 1 : 0} commit selected + + {previewDetail && ( + <> + + +{totalAdd} + -{totalDel} + + {fileCount} files + + )} +
+ +
+
+ ); +} diff --git a/src/pages/revert/molecules/RevertCommitRow.tsx b/src/pages/revert/molecules/RevertCommitRow.tsx new file mode 100644 index 0000000..006d946 --- /dev/null +++ b/src/pages/revert/molecules/RevertCommitRow.tsx @@ -0,0 +1,45 @@ +import type { CommitInfo } from "../../../services/history"; +import { formatRelativeDate } from "../../../utils/date"; + +interface RevertCommitRowProps { + commit: CommitInfo; + selected: boolean; + onSelect: (oid: string) => void; +} + +export function RevertCommitRow({ + commit, + selected, + onSelect, +}: RevertCommitRowProps) { + return ( + + ); +} diff --git a/src/pages/revert/organisms/RevertCommitList.tsx b/src/pages/revert/organisms/RevertCommitList.tsx new file mode 100644 index 0000000..5529abf --- /dev/null +++ b/src/pages/revert/organisms/RevertCommitList.tsx @@ -0,0 +1,44 @@ +import { useCommitSearch } from "../../../hooks/useCommitSearch"; +import type { CommitInfo } from "../../../services/history"; +import { RevertCommitRow } from "../molecules/RevertCommitRow"; + +interface RevertCommitListProps { + commits: CommitInfo[]; + selectedOid: string | null; + onSelect: (oid: string) => void; +} + +export function RevertCommitList({ + commits, + selectedOid, + onSelect, +}: RevertCommitListProps) { + const { search, setSearch, filtered } = useCommitSearch(commits); + + return ( +
+
+ Select commit to revert +
+ setSearch(e.target.value)} + /> +
+
+
+ {filtered.map((commit) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/revert/organisms/RevertOptions.tsx b/src/pages/revert/organisms/RevertOptions.tsx new file mode 100644 index 0000000..c193041 --- /dev/null +++ b/src/pages/revert/organisms/RevertOptions.tsx @@ -0,0 +1,52 @@ +import type { RevertMode } from "../../../services/revert"; + +interface RevertOptionsProps { + mode: RevertMode; + onModeChange: (mode: RevertMode) => void; +} + +const MODES: { value: RevertMode; title: string; desc: string }[] = [ + { + value: "auto", + title: "Auto Commit", + desc: "Automatically create a revert commit", + }, + { + value: "no_commit", + title: "No Commit (--no-commit)", + desc: "Apply changes without creating a commit", + }, + { + value: "edit", + title: "Edit Message (--edit)", + desc: "Edit the commit message before committing", + }, +]; + +export function RevertOptions({ mode, onModeChange }: RevertOptionsProps) { + return ( +
+

Revert Mode

+
+ {MODES.map((m) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/revert/organisms/RevertPreview.tsx b/src/pages/revert/organisms/RevertPreview.tsx new file mode 100644 index 0000000..1fb151c --- /dev/null +++ b/src/pages/revert/organisms/RevertPreview.tsx @@ -0,0 +1,10 @@ +import { OperationPreview } from "../../../components/organisms/OperationPreview"; +import type { CommitDetail } from "../../../services/history"; + +interface RevertPreviewProps { + detail: CommitDetail | null; +} + +export function RevertPreview({ detail }: RevertPreviewProps) { + return ; +} diff --git a/src/services/cherryPick.ts b/src/services/cherryPick.ts new file mode 100644 index 0000000..62c5bc8 --- /dev/null +++ b/src/services/cherryPick.ts @@ -0,0 +1,28 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type CherryPickMode = "normal" | "no_commit" | "merge"; + +export interface CherryPickResult { + completed: boolean; + conflicts: string[]; + oid: string | null; +} + +export function cherryPick( + oids: string[], + mode: CherryPickMode, +): Promise { + return invoke("cherry_pick", { oids, mode }); +} + +export function isCherryPicking(): Promise { + return invoke("is_cherry_picking"); +} + +export function abortCherryPick(): Promise { + return invoke("abort_cherry_pick"); +} + +export function continueCherryPick(): Promise { + return invoke("continue_cherry_pick"); +} diff --git a/src/services/revert.ts b/src/services/revert.ts new file mode 100644 index 0000000..155c099 --- /dev/null +++ b/src/services/revert.ts @@ -0,0 +1,28 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type RevertMode = "auto" | "no_commit" | "edit"; + +export interface RevertResult { + completed: boolean; + conflicts: string[]; + oid: string | null; +} + +export function revertCommit( + oid: string, + mode: RevertMode, +): Promise { + return invoke("revert", { oid, mode }); +} + +export function isReverting(): Promise { + return invoke("is_reverting"); +} + +export function abortRevert(): Promise { + return invoke("abort_revert"); +} + +export function continueRevert(): Promise { + return invoke("continue_revert"); +} diff --git a/src/stores/__tests__/gitStore.test.ts b/src/stores/__tests__/gitStore.test.ts index 1cbe0a7..dda7f5f 100644 --- a/src/stores/__tests__/gitStore.test.ts +++ b/src/stores/__tests__/gitStore.test.ts @@ -23,6 +23,8 @@ describe("gitStore", () => { merging: false, rebasing: false, rebaseState: null, + cherryPicking: false, + reverting: false, conflictFiles: [], loading: false, error: null, @@ -1163,6 +1165,238 @@ describe("gitStore", () => { }); }); + describe("cherryPick", () => { + it("returns result and resets cherryPicking on completion", async () => { + const mockResult = { completed: true, conflicts: [], oid: "abc123" }; + mockedInvoke.mockResolvedValueOnce(mockResult); + + const result = await useGitStore + .getState() + .cherryPick(["abc123"], "normal"); + + expect(result).toEqual(mockResult); + expect(useGitStore.getState().cherryPicking).toBe(false); + }); + + it("sets cherryPicking when conflicts exist", async () => { + const mockResult = { + completed: false, + conflicts: ["file.txt"], + oid: null, + }; + mockedInvoke.mockResolvedValueOnce(mockResult); + + const result = await useGitStore + .getState() + .cherryPick(["abc123"], "normal"); + + expect(result.completed).toBe(false); + expect(useGitStore.getState().cherryPicking).toBe(true); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("cherry-pick error")); + + await expect( + useGitStore.getState().cherryPick(["abc123"], "normal"), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("cherry-pick error"); + }); + }); + + describe("fetchCherryPickState", () => { + it("sets cherryPicking on success", async () => { + mockedInvoke.mockResolvedValueOnce(true); + + await useGitStore.getState().fetchCherryPickState(); + + expect(useGitStore.getState().cherryPicking).toBe(true); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("cherry-pick state error")); + + await expect( + useGitStore.getState().fetchCherryPickState(), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("cherry-pick state error"); + }); + }); + + describe("abortCherryPick", () => { + it("resets cherryPicking state on success", async () => { + useGitStore.setState({ cherryPicking: true }); + mockedInvoke.mockResolvedValueOnce(undefined); + + await useGitStore.getState().abortCherryPick(); + + expect(useGitStore.getState().cherryPicking).toBe(false); + expect(useGitStore.getState().conflictFiles).toEqual([]); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("abort cherry-pick error")); + + await expect(useGitStore.getState().abortCherryPick()).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("abort cherry-pick error"); + }); + }); + + describe("continueCherryPick", () => { + it("resets state when completed", async () => { + useGitStore.setState({ cherryPicking: true }); + mockedInvoke.mockResolvedValueOnce({ completed: true, conflicts: [] }); + + const result = await useGitStore.getState().continueCherryPick(); + + expect(result.completed).toBe(true); + expect(useGitStore.getState().cherryPicking).toBe(false); + expect(useGitStore.getState().conflictFiles).toEqual([]); + }); + + it("keeps cherryPicking state when not completed", async () => { + useGitStore.setState({ cherryPicking: true }); + mockedInvoke.mockResolvedValueOnce({ + completed: false, + conflicts: ["file.txt"], + }); + + const result = await useGitStore.getState().continueCherryPick(); + + expect(result.completed).toBe(false); + expect(useGitStore.getState().cherryPicking).toBe(true); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce( + new Error("continue cherry-pick error"), + ); + + await expect( + useGitStore.getState().continueCherryPick(), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain( + "continue cherry-pick error", + ); + }); + }); + + describe("revertCommit", () => { + it("returns result and resets reverting on completion", async () => { + const mockResult = { completed: true, conflicts: [], oid: "abc123" }; + mockedInvoke.mockResolvedValueOnce(mockResult); + + const result = await useGitStore + .getState() + .revertCommit("abc123", "auto"); + + expect(result).toEqual(mockResult); + expect(useGitStore.getState().reverting).toBe(false); + }); + + it("sets reverting when conflicts exist", async () => { + const mockResult = { + completed: false, + conflicts: ["file.txt"], + oid: null, + }; + mockedInvoke.mockResolvedValueOnce(mockResult); + + const result = await useGitStore + .getState() + .revertCommit("abc123", "auto"); + + expect(result.completed).toBe(false); + expect(useGitStore.getState().reverting).toBe(true); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("revert error")); + + await expect( + useGitStore.getState().revertCommit("abc123", "auto"), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("revert error"); + }); + }); + + describe("fetchRevertState", () => { + it("sets reverting on success", async () => { + mockedInvoke.mockResolvedValueOnce(true); + + await useGitStore.getState().fetchRevertState(); + + expect(useGitStore.getState().reverting).toBe(true); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("revert state error")); + + await expect(useGitStore.getState().fetchRevertState()).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("revert state error"); + }); + }); + + describe("abortRevert", () => { + it("resets reverting state on success", async () => { + useGitStore.setState({ reverting: true }); + mockedInvoke.mockResolvedValueOnce(undefined); + + await useGitStore.getState().abortRevert(); + + expect(useGitStore.getState().reverting).toBe(false); + expect(useGitStore.getState().conflictFiles).toEqual([]); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("abort revert error")); + + await expect(useGitStore.getState().abortRevert()).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("abort revert error"); + }); + }); + + describe("continueRevert", () => { + it("resets state when completed", async () => { + useGitStore.setState({ reverting: true }); + mockedInvoke.mockResolvedValueOnce({ completed: true, conflicts: [] }); + + const result = await useGitStore.getState().continueRevert(); + + expect(result.completed).toBe(true); + expect(useGitStore.getState().reverting).toBe(false); + expect(useGitStore.getState().conflictFiles).toEqual([]); + }); + + it("keeps reverting state when not completed", async () => { + useGitStore.setState({ reverting: true }); + mockedInvoke.mockResolvedValueOnce({ + completed: false, + conflicts: ["file.txt"], + }); + + const result = await useGitStore.getState().continueRevert(); + + expect(result.completed).toBe(false); + expect(useGitStore.getState().reverting).toBe(true); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("continue revert error")); + + await expect(useGitStore.getState().continueRevert()).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("continue revert error"); + }); + }); + describe("clearError", () => { it("clears the error state", async () => { mockedInvoke.mockRejectedValueOnce(new Error("some error")); diff --git a/src/stores/gitStore.ts b/src/stores/gitStore.ts index dee66b8..c95f906 100644 --- a/src/stores/gitStore.ts +++ b/src/stores/gitStore.ts @@ -1,4 +1,11 @@ import { create } from "zustand"; +import type { CherryPickMode, CherryPickResult } from "../services/cherryPick"; +import { + abortCherryPick as abortCherryPickService, + cherryPick as cherryPickService, + continueCherryPick as continueCherryPickService, + isCherryPicking as isCherryPickingService, +} from "../services/cherryPick"; import type { ConflictFile, ConflictResolution } from "../services/conflict"; import { abortMerge as abortMergeService, @@ -66,6 +73,13 @@ import { isRebasing as isRebasingService, rebase as rebaseService, } from "../services/rebase"; +import type { RevertMode, RevertResult } from "../services/revert"; +import { + abortRevert as abortRevertService, + continueRevert as continueRevertService, + isReverting as isRevertingService, + revertCommit as revertCommitService, +} from "../services/revert"; import type { StashEntry } from "../services/stash"; import { applyStash as applyStashService, @@ -95,6 +109,8 @@ interface GitState { merging: boolean; rebasing: boolean; rebaseState: RebaseState | null; + cherryPicking: boolean; + reverting: boolean; conflictFiles: ConflictFile[]; loading: boolean; error: string | null; @@ -164,6 +180,17 @@ interface GitActions { abortRebase: () => Promise; continueRebase: () => Promise; getRebaseTodo: (onto: string) => Promise; + cherryPick: ( + oids: string[], + mode: CherryPickMode, + ) => Promise; + fetchCherryPickState: () => Promise; + abortCherryPick: () => Promise; + continueCherryPick: () => Promise; + revertCommit: (oid: string, mode: RevertMode) => Promise; + fetchRevertState: () => Promise; + abortRevert: () => Promise; + continueRevert: () => Promise; clearError: () => void; } @@ -178,6 +205,8 @@ export const useGitStore = create((set) => ({ merging: false, rebasing: false, rebaseState: null, + cherryPicking: false, + reverting: false, conflictFiles: [], loading: false, error: null, @@ -657,6 +686,102 @@ export const useGitStore = create((set) => ({ } }, + cherryPick: async (oids: string[], mode: CherryPickMode) => { + try { + const result = await cherryPickService(oids, mode); + if (result.completed) { + set({ cherryPicking: false }); + } else { + set({ cherryPicking: true }); + } + return result; + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + fetchCherryPickState: async () => { + try { + const cherryPicking = await isCherryPickingService(); + set({ cherryPicking }); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + abortCherryPick: async () => { + try { + await abortCherryPickService(); + set({ cherryPicking: false, conflictFiles: [] }); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + continueCherryPick: async () => { + try { + const result = await continueCherryPickService(); + if (result.completed) { + set({ cherryPicking: false, conflictFiles: [] }); + } + return result; + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + revertCommit: async (oid: string, mode: RevertMode) => { + try { + const result = await revertCommitService(oid, mode); + if (result.completed) { + set({ reverting: false }); + } else { + set({ reverting: true }); + } + return result; + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + fetchRevertState: async () => { + try { + const reverting = await isRevertingService(); + set({ reverting }); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + abortRevert: async () => { + try { + await abortRevertService(); + set({ reverting: false, conflictFiles: [] }); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + continueRevert: async () => { + try { + const result = await continueRevertService(); + if (result.completed) { + set({ reverting: false, conflictFiles: [] }); + } + return result; + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + clearError: () => { set({ error: null }); }, diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 4f294ef..5efc6e2 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -9,6 +9,8 @@ export type PageId = | "file-history" | "stash" | "rebase" + | "cherry-pick" + | "revert" | "hosting"; interface BlameTarget { diff --git a/src/styles/branches.css b/src/styles/branches.css index 7b22fe2..41bf601 100644 --- a/src/styles/branches.css +++ b/src/styles/branches.css @@ -393,86 +393,3 @@ font-size: 11px; margin-bottom: 12px; } - -/* ===== Operation Footer ===== */ -.operation-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-top: 1px solid var(--border); - background: var(--bg-secondary); - flex-shrink: 0; -} - -.operation-summary { - display: flex; - align-items: center; - gap: 12px; - font-size: 13px; -} - -.summary-stat { - color: var(--text-secondary); -} - -.summary-stat strong { - color: var(--text-primary); -} - -.summary-divider { - width: 1px; - height: 16px; - background: var(--border); -} - -.operation-buttons { - display: flex; - gap: 8px; -} - -/* ===== Merge Options ===== */ -.option-mode-selector { - display: flex; - flex-direction: column; - gap: 8px; -} - -.option-mode { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 12px; - background: var(--bg-primary); - border: 2px solid transparent; - border-radius: 8px; - cursor: pointer; - transition: all 0.15s ease; -} - -.option-mode:hover { - border-color: var(--text-muted); -} - -.option-mode.selected { - border-color: var(--accent); -} - -.option-mode input { - display: none; -} - -.mode-content { - flex: 1; -} - -.mode-title { - font-size: 13px; - font-weight: 600; - margin-bottom: 4px; -} - -.mode-desc { - font-size: 11px; - color: var(--text-muted); -} diff --git a/src/styles/cherry-pick.css b/src/styles/cherry-pick.css new file mode 100644 index 0000000..1bbd4cf --- /dev/null +++ b/src/styles/cherry-pick.css @@ -0,0 +1,159 @@ +/* ===== Branch Selector ===== */ +.cherry-branch-selector { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.cherry-branch-selector label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.cherry-branch-selector select { + appearance: none; + -webkit-appearance: none; + flex: 1; + max-width: 300px; + padding: 6px 32px 6px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-family: "JetBrains Mono", monospace; + font-size: 12px; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%238b949e'%3E%3Cpath d='M4.646 6.646a.5.5 0 0 1 .708 0L8 9.293l2.646-2.647a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 16px; + transition: border-color 0.15s ease; +} + +.cherry-branch-selector select:hover { + border-color: var(--accent); +} + +.cherry-branch-selector select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); +} + +.cherry-branch-selector select option { + background: var(--bg-secondary); + color: var(--text-primary); + padding: 8px; +} + +/* ===== Cherry-pick Commit List ===== */ +.cherry-commit-list { + flex: 1; + overflow-y: auto; +} + +.cherry-commit-row { + display: flex; + align-items: flex-start; + width: 100%; + padding: 14px 16px; + border: none; + border-bottom: 1px solid var(--border); + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.cherry-commit-row:hover { + background: var(--bg-hover); +} +.cherry-commit-row.selected { + background: var(--accent-dim); + border-left: 3px solid #ec4899; +} +.cherry-commit-row.selected:hover { + background: var(--accent-dim); +} + +.cherry-checkbox { + margin-right: 12px; + padding-top: 2px; +} +.cherry-checkbox input { + width: 1.15em; + height: 1.15em; + appearance: none; + -webkit-appearance: none; + border-radius: 50%; + border: 0.1em solid #6b6b76; + background: transparent; + display: grid; + place-content: center; + cursor: pointer; +} + +.cherry-checkbox input::before { + content: ""; + width: 0.65em; + height: 0.65em; + border-radius: 50%; + transform: scale(0); + transition: 120ms transform ease-in-out; + background-color: #ec4899; +} + +.cherry-checkbox input:checked { + border-color: #ec4899; +} +.cherry-checkbox input:checked::before { + transform: scale(1); +} + +.cherry-graph { + width: 24px; + display: flex; + flex-direction: column; + align-items: center; + margin-right: 12px; +} +.graph-node.cherry { + width: 12px; + height: 12px; + background: linear-gradient(135deg, #f472b6, #ec4899); + border-radius: 50%; +} + +.cherry-info { + flex: 1; + min-width: 0; +} +.cherry-message { + font-size: 13px; + font-weight: 500; + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cherry-meta { + display: flex; + gap: 12px; + font-size: 11px; + color: var(--text-muted); +} +.cherry-hash { + font-family: "JetBrains Mono", monospace; + color: #ec4899; +} + +/* ===== Cherry-pick Preview Hash ===== */ +.preview-hash.cherry { + color: #ec4899; +} diff --git a/src/styles/components.css b/src/styles/components.css index 55db9ef..b69d769 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -66,6 +66,19 @@ filter: brightness(1.1); } +.btn-warning { + background: var(--warning); + color: #fff; +} +.btn-warning:hover { + filter: brightness(1.1); +} +.btn-warning:disabled { + opacity: 0.4; + cursor: not-allowed; + filter: none; +} + .btn-danger-outline { background: transparent; color: var(--danger); @@ -414,44 +427,6 @@ } } -/* ===== Operation Footer ===== */ -.operation-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-top: 1px solid var(--border); - background: var(--bg-secondary); - flex-shrink: 0; -} -.operation-summary { - display: flex; - align-items: center; - gap: 12px; - font-size: 13px; -} -.summary-stat { - color: var(--text-secondary); -} -.summary-stat strong { - color: var(--text-primary); -} -.summary-stat.additions { - color: var(--success); - font-family: "JetBrains Mono", monospace; - font-weight: 600; -} -.summary-stat.deletions { - color: var(--danger); - font-family: "JetBrains Mono", monospace; - font-weight: 600; -} -.summary-divider { - width: 1px; - height: 16px; - background: var(--border); -} - /* ===== Scrollbar ===== */ ::-webkit-scrollbar { width: 8px; diff --git a/src/styles/operation.css b/src/styles/operation.css new file mode 100644 index 0000000..23ce795 --- /dev/null +++ b/src/styles/operation.css @@ -0,0 +1,334 @@ +/* ===== Operation Layout ===== */ +.operation-layout { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +.operation-header { + display: flex; + align-items: center; + gap: 16px; + padding: 0 20px; + height: 48px; + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} + +.operation-info { + display: flex; + align-items: baseline; + gap: 12px; +} +.operation-title { + font-size: 14px; + font-weight: 600; + margin: 0; +} +.operation-desc { + font-size: 12px; + color: var(--text-muted); +} + +/* ===== Operation Two Column ===== */ +.operation-two-column { + display: grid; + grid-template-columns: 1fr 1fr; + flex: 1; + min-height: 0; + overflow: hidden; +} +.operation-left-panel { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + overflow: hidden; + min-height: 0; +} +.operation-right-panel { + display: flex; + flex-direction: column; + overflow-y: auto; + min-height: 0; +} +.operation-panel { + overflow: hidden; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* ===== Operation Options ===== */ +.operation-options-inline { + padding: 16px; + border-top: 1px solid var(--border); +} +.operation-options-inline h3 { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.option-mode-selector { + display: flex; + flex-direction: column; + gap: 8px; +} +.option-mode { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px; + background: var(--bg-primary); + border: 2px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; +} +.option-mode:hover { + border-color: var(--text-muted); +} +.option-mode.selected { + border-color: var(--accent); +} +.option-mode.selected:hover { + border-color: var(--accent); +} +.option-mode input { + display: none; +} +.mode-content { + flex: 1; +} +.mode-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 4px; +} +.mode-desc { + font-size: 11px; + color: var(--text-muted); +} + +/* ===== Graph Line ===== */ +.graph-line { + width: 2px; + flex: 1; + background: var(--border); + min-height: 16px; +} + +/* ===== Changes Preview ===== */ +.changes-preview { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; +} +.preview-header { + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} +.preview-commit-info { + display: flex; + flex-direction: column; + gap: 6px; +} +.preview-hash { + font-family: "JetBrains Mono", monospace; + font-size: 12px; + font-weight: 600; +} +.preview-message { + font-size: 15px; + font-weight: 600; + line-height: 1.4; +} +.preview-author { + font-size: 12px; + color: var(--text-muted); +} + +/* ===== Preview Section ===== */ +.preview-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ===== Preview Files ===== */ +.preview-files-list { + display: flex; + flex-direction: column; + overflow: hidden; +} +.preview-file { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + font-size: 12px; + cursor: pointer; + transition: background-color 0.15s ease; +} +.preview-file:hover { + background: var(--bg-hover); +} +.preview-file-status { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + font-weight: 700; + font-size: 11px; + flex-shrink: 0; +} +.preview-file-status.added { + background: rgba(34, 197, 94, 0.2); + color: var(--success); +} +.preview-file-status.deleted { + background: rgba(239, 68, 68, 0.2); + color: var(--danger); +} +.preview-file-status.modified { + background: rgba(59, 130, 246, 0.2); + color: var(--accent); +} +.preview-file-path { + flex: 1; + font-family: "JetBrains Mono", monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.preview-file-stats { + display: flex; + gap: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 11px; +} + +/* ===== Stats Bar ===== */ +.preview-stats-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 0; + border-top: 1px solid var(--border); +} +.stats-label { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); +} +.stats-visual { + flex: 1; + display: flex; + align-items: center; + gap: 16px; +} +.stats-bar { + flex: 1; + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; + display: flex; +} +.stats-bar-add { + background: linear-gradient(90deg, #22c55e, #16a34a); + height: 100%; +} +.stats-bar-del { + background: linear-gradient(90deg, #ef4444, #dc2626); + height: 100%; +} +.stats-numbers { + display: flex; + gap: 12px; + font-family: "JetBrains Mono", monospace; + font-size: 13px; + font-weight: 600; +} +.stats-numbers .additions { + color: var(--success); +} +.stats-numbers .deletions { + color: var(--danger); +} + +/* ===== Operation Footer ===== */ +.operation-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-top: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} +.operation-summary { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; +} +.summary-stat { + color: var(--text-secondary); +} +.summary-stat strong { + color: var(--text-primary); +} +.summary-stat.additions { + color: var(--success); + font-family: "JetBrains Mono", monospace; + font-weight: 600; +} +.summary-stat.deletions { + color: var(--danger); + font-family: "JetBrains Mono", monospace; + font-weight: 600; +} +.summary-divider { + width: 1px; + height: 16px; + background: var(--border); +} + +/* ===== Preview Empty ===== */ +.preview-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px 20px; + color: var(--text-muted); + font-size: 13px; + text-align: center; +} + +/* ===== Operation Responsive ===== */ +@media (max-width: 1023px) { + .operation-two-column { + grid-template-columns: 1fr; + } + .operation-left-panel { + border-right: none; + border-bottom: 1px solid var(--border); + max-height: 50%; + } + .operation-right-panel { + max-height: 50%; + } +} diff --git a/src/styles/revert.css b/src/styles/revert.css new file mode 100644 index 0000000..0d10969 --- /dev/null +++ b/src/styles/revert.css @@ -0,0 +1,107 @@ +/* ===== Revert Commit List ===== */ +.revert-commit-list { + flex: 1; + overflow-y: auto; +} + +.revert-commit-row { + display: flex; + align-items: flex-start; + width: 100%; + padding: 14px 16px; + border: none; + border-bottom: 1px solid var(--border); + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.revert-commit-row:hover { + background: var(--bg-hover); +} +.revert-commit-row.selected { + background: var(--warning-dim); + border-left: 3px solid var(--warning); +} +.revert-commit-row.selected:hover { + background: var(--warning-dim); +} + +.revert-radio { + margin-right: 12px; + padding-top: 2px; +} +.revert-radio input { + width: 1.15em; + height: 1.15em; + appearance: none; + -webkit-appearance: none; + border-radius: 50%; + border: 0.1em solid #6b6b76; + background: transparent; + display: grid; + place-content: center; + cursor: pointer; +} + +.revert-radio input::before { + content: ""; + width: 0.65em; + height: 0.65em; + border-radius: 50%; + transform: scale(0); + transition: 120ms transform ease-in-out; + background-color: var(--warning); +} + +.revert-radio input:checked { + border-color: var(--warning); +} +.revert-radio input:checked::before { + transform: scale(1); +} + +.revert-graph { + width: 24px; + display: flex; + flex-direction: column; + align-items: center; + margin-right: 12px; +} +.graph-node.revert { + width: 12px; + height: 12px; + background: linear-gradient(135deg, #fbbf24, #f59e0b); + border-radius: 50%; +} + +.revert-info { + flex: 1; + min-width: 0; +} +.revert-message { + font-size: 13px; + font-weight: 500; + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.revert-meta { + display: flex; + gap: 12px; + font-size: 11px; + color: var(--text-muted); +} +.revert-hash { + font-family: "JetBrains Mono", monospace; + color: var(--warning); +} + +/* ===== Revert Preview Hash ===== */ +.preview-hash.warning { + color: var(--warning); +} diff --git a/src/styles/stash.css b/src/styles/stash.css index 9df4c7d..219131b 100644 --- a/src/styles/stash.css +++ b/src/styles/stash.css @@ -1,63 +1,3 @@ -/* ===== Operation Layout ===== */ -.operation-layout { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: hidden; -} - -.operation-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 0 20px; - height: 48px; - flex-shrink: 0; - border-bottom: 1px solid var(--border); -} - -.operation-info { - display: flex; - align-items: baseline; - gap: 12px; -} - -.operation-title { - font-size: 14px; - font-weight: 600; - margin: 0; -} - -.operation-desc { - font-size: 12px; - color: var(--text-muted); -} - -.operation-two-column { - display: grid; - grid-template-columns: 1fr 1fr; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.operation-left-panel { - display: flex; - flex-direction: column; - border-right: 1px solid var(--border); - overflow: hidden; - min-height: 0; -} - -.operation-right-panel { - display: flex; - flex-direction: column; - overflow-y: auto; - min-height: 0; -} - /* ===== Stash List ===== */ .stash-list { flex: 1; @@ -165,328 +105,15 @@ color: var(--accent); } -/* ===== Stats Bar ===== */ -.preview-stats-bar { - display: flex; - align-items: center; - gap: 16px; - padding: 16px 0; - border-top: 1px solid var(--border); -} - -.stats-label { - font-size: 12px; - font-weight: 600; - color: var(--text-muted); -} - -.stats-visual { - flex: 1; - display: flex; - align-items: center; - gap: 16px; -} - -.stats-bar { - flex: 1; - height: 8px; - background: var(--bg-tertiary); - border-radius: 4px; - overflow: hidden; - display: flex; -} - -.stats-bar-add { - background: linear-gradient(90deg, #22c55e, #16a34a); - height: 100%; -} - -.stats-bar-del { - background: linear-gradient(90deg, #ef4444, #dc2626); - height: 100%; -} - -.stats-numbers { - display: flex; - gap: 12px; - font-family: "JetBrains Mono", monospace; - font-size: 13px; - font-weight: 600; -} - -.stats-numbers .additions { - color: var(--success); -} - -.stats-numbers .deletions { - color: var(--danger); -} - -/* ===== Operation Footer ===== */ -.operation-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-top: 1px solid var(--border); - background: var(--bg-secondary); - flex-shrink: 0; -} - -.operation-summary { - display: flex; - align-items: center; - gap: 12px; - font-size: 13px; -} - -.operation-buttons { - display: flex; - gap: 8px; -} - -/* ===== Changes Preview ===== */ -.changes-preview { - display: flex; - flex-direction: column; - gap: 16px; - padding: 20px; -} - -.preview-header { - padding-bottom: 16px; - border-bottom: 1px solid var(--border); -} - -.preview-commit-info { - display: flex; - flex-direction: column; - gap: 6px; -} - -.preview-hash { - font-family: "JetBrains Mono", monospace; - font-size: 12px; - font-weight: 600; -} - +/* ===== Stash-specific Preview Hash ===== */ .preview-hash.stash { color: var(--accent); } -.preview-message { - font-size: 15px; - font-weight: 600; - line-height: 1.4; -} - -.preview-author { - font-size: 12px; - color: var(--text-muted); -} - -.preview-section { - display: flex; - flex-direction: column; - gap: 12px; -} - -.section-header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.section-title { - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.section-count { - background: var(--bg-tertiary); - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - color: var(--text-muted); -} - -/* ===== Preview File List ===== */ -.preview-files-list { - display: flex; - flex-direction: column; - overflow: hidden; -} - -.preview-file { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border: none; - border-bottom: 1px solid var(--border); - background: transparent; - font: inherit; - font-size: 12px; - cursor: pointer; - transition: background-color 0.15s ease; - width: 100%; - text-align: left; - color: inherit; -} - -.preview-file:hover { - background: var(--bg-tertiary); -} - -.preview-file.expanded { - background: var(--accent-dim); -} - -.preview-file-status { - width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - font-weight: 700; - font-size: 11px; - flex-shrink: 0; -} - -.preview-file-status.added { - background: rgba(34, 197, 94, 0.2); - color: var(--success); -} - -.preview-file-status.deleted { - background: rgba(239, 68, 68, 0.2); - color: var(--danger); -} - -.preview-file-status.modified { - background: rgba(59, 130, 246, 0.2); - color: var(--accent); -} - -.preview-file-path { - flex: 1; - font-family: "JetBrains Mono", monospace; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.preview-file-stats { +/* ===== Stash Operation Buttons ===== */ +.operation-buttons { display: flex; gap: 8px; - font-family: "JetBrains Mono", monospace; - font-size: 11px; -} - -.stat-add { - color: var(--success); -} - -.stat-del { - color: var(--danger); -} - -.preview-file-expand { - width: 16px; - height: 16px; - color: var(--text-muted); - flex-shrink: 0; - transition: transform 0.2s ease; -} - -.preview-file-expand svg { - width: 16px; - height: 16px; -} - -.preview-file.expanded .preview-file-expand { - transform: rotate(180deg); - color: var(--accent); -} - -/* ===== Preview Diff ===== */ -.preview-file-diff { - border-bottom: 1px solid var(--border); - background: var(--bg-secondary); - overflow: hidden; -} - -.preview-file-diff .diff-preview-content { - max-height: 200px; - overflow-y: auto; -} - -.preview-file-diff .diff-hunk { - font-family: "JetBrains Mono", monospace; - font-size: 11px; -} - -.preview-file-diff .diff-hunk-header { - padding: 6px 12px; - background: var(--purple-dim); - color: var(--purple); - font-size: 10px; -} - -.preview-file-diff .diff-line { - display: flex; -} - -.preview-file-diff .diff-line .line-num { - width: 32px; - padding: 2px 8px; - text-align: right; - color: var(--text-muted); - background: var(--bg-tertiary); - flex-shrink: 0; -} - -.preview-file-diff .diff-line .line-code { - flex: 1; - padding: 2px 12px; - white-space: pre; -} - -.preview-file-diff .diff-line.add { - background: rgba(34, 197, 94, 0.1); -} - -.preview-file-diff .diff-line.add .line-code { - color: var(--success); -} - -.preview-file-diff .diff-line.del { - background: rgba(239, 68, 68, 0.1); -} - -.preview-file-diff .diff-line.del .line-code { - color: var(--danger); -} - -.preview-file-diff .diff-line.context .line-code { - color: var(--text-secondary); -} - -/* ===== Responsive ===== */ -@media (max-width: 1023px) { - .operation-two-column { - grid-template-columns: 1fr; - } - - .operation-left-panel { - border-right: none; - border-bottom: 1px solid var(--border); - max-height: 50%; - } } /* ===== Empty State ===== */ diff --git a/src/utils/__tests__/statusSymbol.test.ts b/src/utils/__tests__/statusSymbol.test.ts new file mode 100644 index 0000000..1326fd4 --- /dev/null +++ b/src/utils/__tests__/statusSymbol.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { statusSymbol } from "../statusSymbol"; + +describe("statusSymbol", () => { + it("returns + and added for added status", () => { + const result = statusSymbol("added"); + + expect(result.symbol).toBe("+"); + expect(result.className).toBe("added"); + }); + + it("returns - and deleted for deleted status", () => { + const result = statusSymbol("deleted"); + + expect(result.symbol).toBe("-"); + expect(result.className).toBe("deleted"); + }); + + it("returns ~ and modified for modified status", () => { + const result = statusSymbol("modified"); + + expect(result.symbol).toBe("~"); + expect(result.className).toBe("modified"); + }); + + it("returns R and modified for renamed status", () => { + const result = statusSymbol("renamed"); + + expect(result.symbol).toBe("R"); + expect(result.className).toBe("modified"); + }); +}); diff --git a/src/utils/statusSymbol.ts b/src/utils/statusSymbol.ts new file mode 100644 index 0000000..208688a --- /dev/null +++ b/src/utils/statusSymbol.ts @@ -0,0 +1,17 @@ +import type { CommitFileStatus } from "../services/history"; + +export function statusSymbol(status: CommitFileStatus): { + symbol: string; + className: string; +} { + switch (status) { + case "added": + return { symbol: "+", className: "added" }; + case "deleted": + return { symbol: "-", className: "deleted" }; + case "modified": + return { symbol: "~", className: "modified" }; + case "renamed": + return { symbol: "R", className: "modified" }; + } +}