1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
use crate::components::Component;
use crate::services::GitService;
use crate::app::{AppState, Action, reducer};
use crate::errors::ComponentError;
use crate::input::InputEvent;
use crate::ui::style;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::widgets::{List, ListItem, Paragraph, Block, Borders, Clear, Wrap};
use ratatui::style::Style;
use crossterm::event::{KeyCode, KeyEventKind};
use std::sync::Arc;
fn center_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
use ratatui::layout::{Constraint, Direction, Layout};
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1]);
horizontal[1]
}
pub struct LogPane {
git_service: Arc<GitService>,
}
impl LogPane {
pub fn new(git_service: Arc<GitService>) -> Self {
Self { git_service }
}
}
impl Component for LogPane {
fn handle_event(
&mut self,
event: InputEvent,
state: &AppState,
) -> Result<Option<Action>, ComponentError> {
match event {
InputEvent::Key(key) if key.kind == KeyEventKind::Press => {
match key.code {
KeyCode::Esc => {
// If showing commit detail, close it; otherwise close pane
if state.commit_detail.is_some() {
Ok(Some(Action::SetCommitDetail(None)))
} else {
Ok(Some(Action::FocusPrev)) // Go back to Status pane
}
}
KeyCode::Char('k') | KeyCode::Up => {
Ok(Some(Action::CommitUp))
}
KeyCode::Char('j') | KeyCode::Down => {
Ok(Some(Action::CommitDown))
}
KeyCode::Char('v') => {
Ok(Some(Action::LogToggleSelect))
}
KeyCode::Enter | KeyCode::Char(' ') => {
// If menu is already open, don't show it again (should be handled by menu handler)
if state.log_action_menu {
Ok(None) // Menu handler will process this
} else {
// Show log action menu instead of directly showing details
if let Some(commit) = state.commits.get(state.commit_selected) {
Ok(Some(Action::ShowLogActionMenu(commit.hash.clone())))
} else {
Ok(None)
}
}
}
KeyCode::Char('g') => {
// Toggle graph view
Ok(Some(Action::ToggleLogGraph))
}
KeyCode::Char('i') => {
// Start interactive rebase:
// - If multi-select active: use those commits
// - Otherwise: select only the CURRENT commit (not all from current to end)
let mut ordered: Vec<String> = Vec::new();
if !state.log_selected_hashes.is_empty() {
// Multi-select mode: include all selected commits in order
for c in state.commits.iter() {
if state.log_selected_hashes.contains(&c.hash) {
ordered.push(c.hash.clone());
}
}
// state.commits is Newest -> Oldest.
// Rebase needs Oldest -> Newest.
ordered.reverse();
} else {
// Single commit mode: only the currently highlighted commit
if let Some(commit) = state.commits.get(state.commit_selected) {
ordered.push(commit.hash.clone());
}
}
if ordered.is_empty() {
Ok(None)
} else {
Ok(Some(Action::StartInteractiveRebase(ordered)))
}
}
KeyCode::Char('C') => {
// Cherry-pick selected commit (with confirmation)
if let Some(commit) = state.commits.get(state.commit_selected) {
Ok(Some(Action::CherryPickCommit(commit.short_hash.clone())))
} else {
Ok(None)
}
}
KeyCode::Char('R') => {
// Revert selected commit (with confirmation)
if let Some(commit) = state.commits.get(state.commit_selected) {
Ok(Some(Action::RevertCommit(commit.short_hash.clone())))
} else {
Ok(None)
}
}
_ => Ok(None),
}
}
_ => Ok(None),
}
}
fn update(
&mut self,
action: Action,
state: &mut AppState,
) -> Result<(), ComponentError> {
match action {
Action::RefreshCommits => {
// Refresh commits from git
let path = state.repo_path.clone();
match self.git_service.log(&path) {
Ok(commits) => {
*state = reducer(state.clone(), Action::SetCommits(commits));
}
Err(e) => {
tracing::warn!(error = %e, "log error");
}
}
}
Action::ShowCommitDetail(_) => {
// ShowCommitDetail is handled in the event loop to avoid blocking
// This prevents the UI from freezing when fetching commit details
// The event loop will spawn an async task to fetch the details
}
Action::ToggleLogGraph => {
if state.log_graph {
// Fetch graph text
let path = state.repo_path.clone();
match self.git_service.log_with_graph(&path, 50) {
Ok(text) => {
*state = reducer(state.clone(), Action::SetLogGraphText(text));
}
Err(e) => {
tracing::warn!(error = %e, "log graph error");
*state = reducer(state.clone(), Action::SetStatusError(Some(format!("log graph error: {e}"))));
}
}
}
}
_ => {}
}
Ok(())
}
fn render(&mut self, frame: &mut Frame, area: Rect, state: &AppState) {
let theme = &state.theme;
let selection_style = style::selection(theme);
let items: Vec<ListItem> = state.commits
.iter()
.enumerate()
.map(|(i, commit)| {
let style = if i == state.commit_selected {
selection_style
} else {
Style::default().fg(theme.untracked_color())
};
let selected_marker = if state.log_selected_hashes.contains(&commit.hash) {
"[✓] "
} else {
" "
};
let display = if state.log_graph && !state.log_graph_text.is_empty() {
// Show graph view
format!("{}{} {}", selected_marker, commit.short_hash, commit.message)
} else {
format!("{}{} {} - {}", selected_marker, commit.short_hash, commit.author, commit.message)
};
ListItem::new(display)
.style(style)
})
.collect();
let title = if state.log_graph {
"Log (Graph)"
} else {
"Log"
};
let list = List::new(items)
.style(style::body_style(theme))
.block(style::pane_block(theme, title, false));
frame.render_widget(list, area);
// If commit detail is loaded, render it as a popup.
if let Some(detail) = &state.commit_detail {
let popup = center_rect(80, 70, area);
frame.render_widget(Clear, popup);
// Derive a title from the selected commit if available.
let commit_title = state
.commits
.get(state.commit_selected)
.map(|c| format!("{} - {}", c.short_hash, c.message))
.unwrap_or_else(|| "Commit detail".to_string());
let block = Block::default()
.borders(Borders::ALL)
.title(format!("{} (Esc=close)", commit_title));
let paragraph = Paragraph::new(detail.clone())
.style(style::body_style(theme))
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, popup);
}
}
fn name(&self) -> &'static str {
"LogPane"
}
}