mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 19:33:37 +08:00
feat: add ecc2 git staging ui controls
This commit is contained in:
@@ -91,7 +91,12 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
|||||||
(_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(),
|
(_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(),
|
||||||
(_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(),
|
(_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(),
|
||||||
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
|
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
|
||||||
|
(_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(),
|
||||||
(_, KeyCode::Char('V')) => dashboard.toggle_diff_view_mode(),
|
(_, KeyCode::Char('V')) => dashboard.toggle_diff_view_mode(),
|
||||||
|
(_, KeyCode::Char('S')) => dashboard.stage_selected_git_status(),
|
||||||
|
(_, KeyCode::Char('U')) => dashboard.unstage_selected_git_status(),
|
||||||
|
(_, KeyCode::Char('R')) => dashboard.reset_selected_git_status(),
|
||||||
|
(_, KeyCode::Char('C')) => dashboard.begin_commit_prompt(),
|
||||||
(_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(),
|
(_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(),
|
||||||
(_, KeyCode::Char('}')) => dashboard.next_diff_hunk(),
|
(_, KeyCode::Char('}')) => dashboard.next_diff_hunk(),
|
||||||
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
|
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ pub struct Dashboard {
|
|||||||
diff_view_mode: DiffViewMode,
|
diff_view_mode: DiffViewMode,
|
||||||
selected_conflict_protocol: Option<String>,
|
selected_conflict_protocol: Option<String>,
|
||||||
selected_merge_readiness: Option<worktree::MergeReadiness>,
|
selected_merge_readiness: Option<worktree::MergeReadiness>,
|
||||||
|
selected_git_status_entries: Vec<worktree::GitStatusEntry>,
|
||||||
|
selected_git_status: usize,
|
||||||
output_mode: OutputMode,
|
output_mode: OutputMode,
|
||||||
output_filter: OutputFilter,
|
output_filter: OutputFilter,
|
||||||
output_time_filter: OutputTimeFilter,
|
output_time_filter: OutputTimeFilter,
|
||||||
@@ -101,6 +103,7 @@ pub struct Dashboard {
|
|||||||
collapsed_panes: HashSet<Pane>,
|
collapsed_panes: HashSet<Pane>,
|
||||||
search_input: Option<String>,
|
search_input: Option<String>,
|
||||||
spawn_input: Option<String>,
|
spawn_input: Option<String>,
|
||||||
|
commit_input: Option<String>,
|
||||||
search_query: Option<String>,
|
search_query: Option<String>,
|
||||||
search_scope: SearchScope,
|
search_scope: SearchScope,
|
||||||
search_agent_filter: SearchAgentFilter,
|
search_agent_filter: SearchAgentFilter,
|
||||||
@@ -144,6 +147,7 @@ enum OutputMode {
|
|||||||
Timeline,
|
Timeline,
|
||||||
WorktreeDiff,
|
WorktreeDiff,
|
||||||
ConflictProtocol,
|
ConflictProtocol,
|
||||||
|
GitStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -346,6 +350,8 @@ impl Dashboard {
|
|||||||
diff_view_mode: DiffViewMode::Split,
|
diff_view_mode: DiffViewMode::Split,
|
||||||
selected_conflict_protocol: None,
|
selected_conflict_protocol: None,
|
||||||
selected_merge_readiness: None,
|
selected_merge_readiness: None,
|
||||||
|
selected_git_status_entries: Vec::new(),
|
||||||
|
selected_git_status: 0,
|
||||||
output_mode: OutputMode::SessionOutput,
|
output_mode: OutputMode::SessionOutput,
|
||||||
output_filter: OutputFilter::All,
|
output_filter: OutputFilter::All,
|
||||||
output_time_filter: OutputTimeFilter::AllTime,
|
output_time_filter: OutputTimeFilter::AllTime,
|
||||||
@@ -365,6 +371,7 @@ impl Dashboard {
|
|||||||
collapsed_panes: HashSet::new(),
|
collapsed_panes: HashSet::new(),
|
||||||
search_input: None,
|
search_input: None,
|
||||||
spawn_input: None,
|
spawn_input: None,
|
||||||
|
commit_input: None,
|
||||||
search_query: None,
|
search_query: None,
|
||||||
search_scope: SearchScope::SelectedSession,
|
search_scope: SearchScope::SelectedSession,
|
||||||
search_agent_filter: SearchAgentFilter::AllAgents,
|
search_agent_filter: SearchAgentFilter::AllAgents,
|
||||||
@@ -641,6 +648,14 @@ impl Dashboard {
|
|||||||
});
|
});
|
||||||
(" Conflict Protocol ".to_string(), Text::from(content))
|
(" Conflict Protocol ".to_string(), Text::from(content))
|
||||||
}
|
}
|
||||||
|
OutputMode::GitStatus => {
|
||||||
|
let content = if self.selected_git_status_entries.is_empty() {
|
||||||
|
Text::from(self.empty_git_status_message())
|
||||||
|
} else {
|
||||||
|
Text::from(self.visible_git_status_lines())
|
||||||
|
};
|
||||||
|
(self.output_title(), content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
@@ -712,6 +727,28 @@ impl Dashboard {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.output_mode == OutputMode::GitStatus {
|
||||||
|
let staged = self
|
||||||
|
.selected_git_status_entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.staged)
|
||||||
|
.count();
|
||||||
|
let unstaged = self
|
||||||
|
.selected_git_status_entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.unstaged || entry.untracked)
|
||||||
|
.count();
|
||||||
|
let total = self.selected_git_status_entries.len();
|
||||||
|
let current = if total == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.selected_git_status.min(total.saturating_sub(1)) + 1
|
||||||
|
};
|
||||||
|
return format!(
|
||||||
|
" Git status staged:{staged} unstaged:{unstaged} {current}/{total} "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let filter = format!(
|
let filter = format!(
|
||||||
"{}{}",
|
"{}{}",
|
||||||
self.output_filter.title_suffix(),
|
self.output_filter.title_suffix(),
|
||||||
@@ -757,6 +794,10 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn empty_git_status_message(&self) -> &'static str {
|
||||||
|
"No staged or unstaged changes for this worktree."
|
||||||
|
}
|
||||||
|
|
||||||
fn empty_timeline_message(&self) -> &'static str {
|
fn empty_timeline_message(&self) -> &'static str {
|
||||||
match (
|
match (
|
||||||
self.timeline_scope,
|
self.timeline_scope,
|
||||||
@@ -980,7 +1021,7 @@ impl Dashboard {
|
|||||||
|
|
||||||
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
||||||
let base_text = format!(
|
let base_text = format!(
|
||||||
" [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ",
|
" [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff git status [z] stage [S] unstage [U] reset [R] commit [C] conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ",
|
||||||
self.pane_focus_shortcuts_label(),
|
self.pane_focus_shortcuts_label(),
|
||||||
self.pane_move_shortcuts_label(),
|
self.pane_move_shortcuts_label(),
|
||||||
self.layout_label(),
|
self.layout_label(),
|
||||||
@@ -989,6 +1030,8 @@ impl Dashboard {
|
|||||||
|
|
||||||
let search_prefix = if let Some(input) = self.spawn_input.as_ref() {
|
let search_prefix = if let Some(input) = self.spawn_input.as_ref() {
|
||||||
format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |")
|
format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |")
|
||||||
|
} else if let Some(input) = self.commit_input.as_ref() {
|
||||||
|
format!(" commit>{input}_ | [Enter] commit [Esc] cancel |")
|
||||||
} else if let Some(input) = self.search_input.as_ref() {
|
} else if let Some(input) = self.search_input.as_ref() {
|
||||||
format!(
|
format!(
|
||||||
" /{input}_ | {} | {} | [Enter] apply [Esc] cancel |",
|
" /{input}_ | {} | {} | [Enter] apply [Esc] cancel |",
|
||||||
@@ -1015,6 +1058,7 @@ impl Dashboard {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let text = if self.spawn_input.is_some()
|
let text = if self.spawn_input.is_some()
|
||||||
|
|| self.commit_input.is_some()
|
||||||
|| self.search_input.is_some()
|
|| self.search_input.is_some()
|
||||||
|| self.search_query.is_some()
|
|| self.search_query.is_some()
|
||||||
|| self.pane_command_mode
|
|| self.pane_command_mode
|
||||||
@@ -1075,8 +1119,11 @@ impl Dashboard {
|
|||||||
" y Toggle selected-session timeline view".to_string(),
|
" y Toggle selected-session timeline view".to_string(),
|
||||||
" E Cycle timeline event filter".to_string(),
|
" E Cycle timeline event filter".to_string(),
|
||||||
" v Toggle selected worktree diff in output pane".to_string(),
|
" v Toggle selected worktree diff in output pane".to_string(),
|
||||||
|
" z Toggle selected worktree git status in output pane".to_string(),
|
||||||
" V Toggle diff view mode between split and unified".to_string(),
|
" V Toggle diff view mode between split and unified".to_string(),
|
||||||
" {/} Jump to previous/next diff hunk in the active diff view".to_string(),
|
" {/} Jump to previous/next diff hunk in the active diff view".to_string(),
|
||||||
|
" S/U/R Stage, unstage, or reset the selected git-status entry".to_string(),
|
||||||
|
" C Commit staged changes for the selected worktree".to_string(),
|
||||||
" c Show conflict-resolution protocol for selected conflicted worktree"
|
" c Show conflict-resolution protocol for selected conflicted worktree"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
" e Cycle output content filter: all/errors/tool calls/file changes".to_string(),
|
" e Cycle output content filter: all/errors/tool calls/file changes".to_string(),
|
||||||
@@ -1539,6 +1586,14 @@ impl Dashboard {
|
|||||||
self.refresh_logs();
|
self.refresh_logs();
|
||||||
}
|
}
|
||||||
Pane::Output => {
|
Pane::Output => {
|
||||||
|
if self.output_mode == OutputMode::GitStatus {
|
||||||
|
self.output_follow = false;
|
||||||
|
if self.selected_git_status + 1 < self.selected_git_status_entries.len() {
|
||||||
|
self.selected_git_status += 1;
|
||||||
|
self.sync_output_scroll(self.last_output_height.max(1));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let max_scroll = self.max_output_scroll();
|
let max_scroll = self.max_output_scroll();
|
||||||
if self.output_follow {
|
if self.output_follow {
|
||||||
return;
|
return;
|
||||||
@@ -1578,6 +1633,12 @@ impl Dashboard {
|
|||||||
self.refresh_logs();
|
self.refresh_logs();
|
||||||
}
|
}
|
||||||
Pane::Output => {
|
Pane::Output => {
|
||||||
|
if self.output_mode == OutputMode::GitStatus {
|
||||||
|
self.output_follow = false;
|
||||||
|
self.selected_git_status = self.selected_git_status.saturating_sub(1);
|
||||||
|
self.sync_output_scroll(self.last_output_height.max(1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.output_follow {
|
if self.output_follow {
|
||||||
self.output_follow = false;
|
self.output_follow = false;
|
||||||
self.output_scroll_offset = self.max_output_scroll();
|
self.output_scroll_offset = self.max_output_scroll();
|
||||||
@@ -1789,9 +1850,136 @@ impl Dashboard {
|
|||||||
self.reset_output_view();
|
self.reset_output_view();
|
||||||
self.set_operator_note("showing session output".to_string());
|
self.set_operator_note("showing session output".to_string());
|
||||||
}
|
}
|
||||||
|
OutputMode::GitStatus => {
|
||||||
|
self.output_mode = OutputMode::SessionOutput;
|
||||||
|
self.reset_output_view();
|
||||||
|
self.set_operator_note("showing session output".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn toggle_git_status_mode(&mut self) {
|
||||||
|
match self.output_mode {
|
||||||
|
OutputMode::GitStatus => {
|
||||||
|
self.output_mode = OutputMode::SessionOutput;
|
||||||
|
self.reset_output_view();
|
||||||
|
self.set_operator_note("showing session output".to_string());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let has_worktree = self
|
||||||
|
.sessions
|
||||||
|
.get(self.selected_session)
|
||||||
|
.and_then(|session| session.worktree.as_ref())
|
||||||
|
.is_some();
|
||||||
|
if !has_worktree {
|
||||||
|
self.set_operator_note("selected session has no worktree".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sync_selected_git_status();
|
||||||
|
self.output_mode = OutputMode::GitStatus;
|
||||||
|
self.selected_pane = Pane::Output;
|
||||||
|
self.output_follow = false;
|
||||||
|
self.sync_output_scroll(self.last_output_height.max(1));
|
||||||
|
self.set_operator_note("showing selected worktree git status".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stage_selected_git_status(&mut self) {
|
||||||
|
if self.output_mode != OutputMode::GitStatus {
|
||||||
|
self.set_operator_note(
|
||||||
|
"git staging controls are only available in git status view".to_string(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((entry, worktree)) = self.selected_git_status_context() else {
|
||||||
|
self.set_operator_note("no git status entry selected".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = worktree::stage_path(&worktree, &entry.path) {
|
||||||
|
tracing::warn!("Failed to stage {}: {error}", entry.path);
|
||||||
|
self.set_operator_note(format!("stage failed for {}: {error}", entry.display_path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refresh_after_git_status_action(Some(&entry.path));
|
||||||
|
self.set_operator_note(format!("staged {}", entry.display_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unstage_selected_git_status(&mut self) {
|
||||||
|
if self.output_mode != OutputMode::GitStatus {
|
||||||
|
self.set_operator_note(
|
||||||
|
"git staging controls are only available in git status view".to_string(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((entry, worktree)) = self.selected_git_status_context() else {
|
||||||
|
self.set_operator_note("no git status entry selected".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = worktree::unstage_path(&worktree, &entry.path) {
|
||||||
|
tracing::warn!("Failed to unstage {}: {error}", entry.path);
|
||||||
|
self.set_operator_note(format!("unstage failed for {}: {error}", entry.display_path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refresh_after_git_status_action(Some(&entry.path));
|
||||||
|
self.set_operator_note(format!("unstaged {}", entry.display_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_selected_git_status(&mut self) {
|
||||||
|
if self.output_mode != OutputMode::GitStatus {
|
||||||
|
self.set_operator_note(
|
||||||
|
"git staging controls are only available in git status view".to_string(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((entry, worktree)) = self.selected_git_status_context() else {
|
||||||
|
self.set_operator_note("no git status entry selected".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = worktree::reset_path(&worktree, &entry) {
|
||||||
|
tracing::warn!("Failed to reset {}: {error}", entry.path);
|
||||||
|
self.set_operator_note(format!("reset failed for {}: {error}", entry.display_path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refresh_after_git_status_action(Some(&entry.path));
|
||||||
|
self.set_operator_note(format!("reset {}", entry.display_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin_commit_prompt(&mut self) {
|
||||||
|
if self.output_mode != OutputMode::GitStatus {
|
||||||
|
self.set_operator_note("commit prompt is only available in git status view".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self
|
||||||
|
.sessions
|
||||||
|
.get(self.selected_session)
|
||||||
|
.and_then(|session| session.worktree.as_ref())
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
self.set_operator_note("selected session has no worktree".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.selected_git_status_entries.iter().any(|entry| entry.staged) {
|
||||||
|
self.set_operator_note("no staged changes to commit".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.commit_input = Some(String::new());
|
||||||
|
self.set_operator_note("commit mode | type a message and press Enter".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggle_diff_view_mode(&mut self) {
|
pub fn toggle_diff_view_mode(&mut self) {
|
||||||
if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() {
|
if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() {
|
||||||
self.set_operator_note("no active worktree diff view to toggle".to_string());
|
self.set_operator_note("no active worktree diff view to toggle".to_string());
|
||||||
@@ -2445,7 +2633,7 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_input_mode(&self) -> bool {
|
pub fn is_input_mode(&self) -> bool {
|
||||||
self.spawn_input.is_some() || self.search_input.is_some()
|
self.spawn_input.is_some() || self.search_input.is_some() || self.commit_input.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_active_search(&self) -> bool {
|
pub fn has_active_search(&self) -> bool {
|
||||||
@@ -2553,6 +2741,8 @@ impl Dashboard {
|
|||||||
input.push(ch);
|
input.push(ch);
|
||||||
} else if let Some(input) = self.search_input.as_mut() {
|
} else if let Some(input) = self.search_input.as_mut() {
|
||||||
input.push(ch);
|
input.push(ch);
|
||||||
|
} else if let Some(input) = self.commit_input.as_mut() {
|
||||||
|
input.push(ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2561,6 +2751,8 @@ impl Dashboard {
|
|||||||
input.pop();
|
input.pop();
|
||||||
} else if let Some(input) = self.search_input.as_mut() {
|
} else if let Some(input) = self.search_input.as_mut() {
|
||||||
input.pop();
|
input.pop();
|
||||||
|
} else if let Some(input) = self.commit_input.as_mut() {
|
||||||
|
input.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2569,17 +2761,56 @@ impl Dashboard {
|
|||||||
self.set_operator_note("spawn input cancelled".to_string());
|
self.set_operator_note("spawn input cancelled".to_string());
|
||||||
} else if self.search_input.take().is_some() {
|
} else if self.search_input.take().is_some() {
|
||||||
self.set_operator_note("search input cancelled".to_string());
|
self.set_operator_note("search input cancelled".to_string());
|
||||||
|
} else if self.commit_input.take().is_some() {
|
||||||
|
self.set_operator_note("commit input cancelled".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn submit_input(&mut self) {
|
pub async fn submit_input(&mut self) {
|
||||||
if self.spawn_input.is_some() {
|
if self.spawn_input.is_some() {
|
||||||
self.submit_spawn_prompt().await;
|
self.submit_spawn_prompt().await;
|
||||||
|
} else if self.commit_input.is_some() {
|
||||||
|
self.submit_commit_prompt();
|
||||||
} else {
|
} else {
|
||||||
self.submit_search();
|
self.submit_search();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn submit_commit_prompt(&mut self) {
|
||||||
|
let Some(input) = self.commit_input.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = input.trim().to_string();
|
||||||
|
let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else {
|
||||||
|
self.set_operator_note("no session selected".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(worktree) = self
|
||||||
|
.sessions
|
||||||
|
.get(self.selected_session)
|
||||||
|
.and_then(|session| session.worktree.clone())
|
||||||
|
else {
|
||||||
|
self.set_operator_note("selected session has no worktree".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match worktree::commit_staged(&worktree, &message) {
|
||||||
|
Ok(hash) => {
|
||||||
|
self.refresh_after_git_status_action(None);
|
||||||
|
self.set_operator_note(format!(
|
||||||
|
"committed {} as {}",
|
||||||
|
format_session_id(&session_id),
|
||||||
|
hash
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.commit_input = Some(input);
|
||||||
|
self.set_operator_note(format!("commit failed: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn submit_search(&mut self) {
|
fn submit_search(&mut self) {
|
||||||
let Some(input) = self.search_input.take() else {
|
let Some(input) = self.search_input.take() else {
|
||||||
return;
|
return;
|
||||||
@@ -3017,6 +3248,7 @@ impl Dashboard {
|
|||||||
self.ensure_selected_pane_visible();
|
self.ensure_selected_pane_visible();
|
||||||
self.sync_selected_output();
|
self.sync_selected_output();
|
||||||
self.sync_selected_diff();
|
self.sync_selected_diff();
|
||||||
|
self.sync_selected_git_status();
|
||||||
self.sync_selected_messages();
|
self.sync_selected_messages();
|
||||||
self.sync_selected_lineage();
|
self.sync_selected_lineage();
|
||||||
self.refresh_logs();
|
self.refresh_logs();
|
||||||
@@ -3313,6 +3545,53 @@ impl Dashboard {
|
|||||||
{
|
{
|
||||||
self.output_mode = OutputMode::SessionOutput;
|
self.output_mode = OutputMode::SessionOutput;
|
||||||
}
|
}
|
||||||
|
self.sync_selected_git_status();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_selected_git_status(&mut self) {
|
||||||
|
let session = self.sessions.get(self.selected_session);
|
||||||
|
let worktree = session.and_then(|session| session.worktree.as_ref());
|
||||||
|
self.selected_git_status_entries = worktree
|
||||||
|
.and_then(|worktree| worktree::git_status_entries(worktree).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if self.selected_git_status >= self.selected_git_status_entries.len() {
|
||||||
|
self.selected_git_status = self
|
||||||
|
.selected_git_status_entries
|
||||||
|
.len()
|
||||||
|
.saturating_sub(1);
|
||||||
|
}
|
||||||
|
if self.output_mode == OutputMode::GitStatus && worktree.is_none() {
|
||||||
|
self.output_mode = OutputMode::SessionOutput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_git_status_context(
|
||||||
|
&self,
|
||||||
|
) -> Option<(worktree::GitStatusEntry, crate::session::WorktreeInfo)> {
|
||||||
|
let session = self.sessions.get(self.selected_session)?;
|
||||||
|
let worktree = session.worktree.clone()?;
|
||||||
|
let entry = self
|
||||||
|
.selected_git_status_entries
|
||||||
|
.get(self.selected_git_status)
|
||||||
|
.cloned()?;
|
||||||
|
Some((entry, worktree))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_after_git_status_action(&mut self, preferred_path: Option<&str>) {
|
||||||
|
self.refresh();
|
||||||
|
self.output_mode = OutputMode::GitStatus;
|
||||||
|
self.selected_pane = Pane::Output;
|
||||||
|
self.output_follow = false;
|
||||||
|
if let Some(path) = preferred_path {
|
||||||
|
if let Some(index) = self
|
||||||
|
.selected_git_status_entries
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.path == path)
|
||||||
|
{
|
||||||
|
self.selected_git_status = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.sync_output_scroll(self.last_output_height.max(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_diff_hunk_offsets(&self) -> &[usize] {
|
fn current_diff_hunk_offsets(&self) -> &[usize] {
|
||||||
@@ -3625,6 +3904,42 @@ impl Dashboard {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn visible_git_status_lines(&self) -> Vec<Line<'static>> {
|
||||||
|
self.selected_git_status_entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, entry)| {
|
||||||
|
let marker = if index == self.selected_git_status { ">>" } else { "-" };
|
||||||
|
let mut flags = Vec::new();
|
||||||
|
if entry.conflicted {
|
||||||
|
flags.push("conflict");
|
||||||
|
}
|
||||||
|
if entry.staged {
|
||||||
|
flags.push("staged");
|
||||||
|
}
|
||||||
|
if entry.unstaged {
|
||||||
|
flags.push("unstaged");
|
||||||
|
}
|
||||||
|
if entry.untracked {
|
||||||
|
flags.push("untracked");
|
||||||
|
}
|
||||||
|
let flag_text = if flags.is_empty() {
|
||||||
|
"clean".to_string()
|
||||||
|
} else {
|
||||||
|
flags.join(",")
|
||||||
|
};
|
||||||
|
Line::from(format!(
|
||||||
|
"{} [{}{}] [{}] {}",
|
||||||
|
marker,
|
||||||
|
entry.index_status,
|
||||||
|
entry.worktree_status,
|
||||||
|
flag_text,
|
||||||
|
entry.display_path
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn visible_timeline_lines(&self) -> Vec<Line<'static>> {
|
fn visible_timeline_lines(&self) -> Vec<Line<'static>> {
|
||||||
let show_session_label = self.timeline_scope == SearchScope::AllSessions;
|
let show_session_label = self.timeline_scope == SearchScope::AllSessions;
|
||||||
self.timeline_events()
|
self.timeline_events()
|
||||||
@@ -3928,6 +4243,14 @@ impl Dashboard {
|
|||||||
|
|
||||||
fn sync_output_scroll(&mut self, viewport_height: usize) {
|
fn sync_output_scroll(&mut self, viewport_height: usize) {
|
||||||
self.last_output_height = viewport_height.max(1);
|
self.last_output_height = viewport_height.max(1);
|
||||||
|
if self.output_mode == OutputMode::GitStatus {
|
||||||
|
let max_scroll = self.max_output_scroll();
|
||||||
|
let centered = self
|
||||||
|
.selected_git_status
|
||||||
|
.saturating_sub(self.last_output_height.max(1).saturating_sub(1) / 2);
|
||||||
|
self.output_scroll_offset = centered.min(max_scroll);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let max_scroll = self.max_output_scroll();
|
let max_scroll = self.max_output_scroll();
|
||||||
|
|
||||||
if self.output_follow {
|
if self.output_follow {
|
||||||
@@ -3938,9 +4261,14 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn max_output_scroll(&self) -> usize {
|
fn max_output_scroll(&self) -> usize {
|
||||||
self.visible_output_lines()
|
let total_lines = if self.output_mode == OutputMode::GitStatus {
|
||||||
.len()
|
self.selected_git_status_entries.len()
|
||||||
.saturating_sub(self.last_output_height.max(1))
|
} else if self.output_mode == OutputMode::Timeline {
|
||||||
|
self.visible_timeline_lines().len()
|
||||||
|
} else {
|
||||||
|
self.visible_output_lines().len()
|
||||||
|
};
|
||||||
|
total_lines.saturating_sub(self.last_output_height.max(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_metrics_scroll(&mut self, viewport_height: usize) {
|
fn sync_metrics_scroll(&mut self, viewport_height: usize) {
|
||||||
@@ -6709,6 +7037,80 @@ mod tests {
|
|||||||
assert!(rendered.contains("+new line"));
|
assert!(rendered.contains("+new line"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_git_status_mode_renders_selected_worktree_status() -> Result<()> {
|
||||||
|
let root = std::env::temp_dir().join(format!("ecc2-git-status-{}", Uuid::new_v4()));
|
||||||
|
init_git_repo(&root)?;
|
||||||
|
fs::write(root.join("README.md"), "hello from git status\n")?;
|
||||||
|
|
||||||
|
let mut session = sample_session(
|
||||||
|
"focus-12345678",
|
||||||
|
"planner",
|
||||||
|
SessionState::Running,
|
||||||
|
Some("ecc/focus"),
|
||||||
|
512,
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
session.working_dir = root.clone();
|
||||||
|
session.worktree = Some(WorktreeInfo {
|
||||||
|
path: root.clone(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
base_branch: "main".to_string(),
|
||||||
|
});
|
||||||
|
let mut dashboard = test_dashboard(vec![session], 0);
|
||||||
|
|
||||||
|
dashboard.toggle_git_status_mode();
|
||||||
|
|
||||||
|
assert_eq!(dashboard.output_mode, OutputMode::GitStatus);
|
||||||
|
assert_eq!(
|
||||||
|
dashboard.operator_note.as_deref(),
|
||||||
|
Some("showing selected worktree git status")
|
||||||
|
);
|
||||||
|
assert_eq!(dashboard.output_title(), " Git status staged:0 unstaged:1 1/1 ");
|
||||||
|
let rendered = dashboard.rendered_output_text(180, 20);
|
||||||
|
assert!(rendered.contains("Git status"));
|
||||||
|
assert!(rendered.contains("README.md"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn begin_commit_prompt_opens_commit_input_for_staged_entries() {
|
||||||
|
let mut dashboard = test_dashboard(
|
||||||
|
vec![sample_session(
|
||||||
|
"focus-12345678",
|
||||||
|
"planner",
|
||||||
|
SessionState::Running,
|
||||||
|
Some("ecc/focus"),
|
||||||
|
512,
|
||||||
|
42,
|
||||||
|
)],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
dashboard.output_mode = OutputMode::GitStatus;
|
||||||
|
dashboard.selected_git_status_entries = vec![worktree::GitStatusEntry {
|
||||||
|
path: "README.md".to_string(),
|
||||||
|
display_path: "README.md".to_string(),
|
||||||
|
index_status: 'M',
|
||||||
|
worktree_status: ' ',
|
||||||
|
staged: true,
|
||||||
|
unstaged: false,
|
||||||
|
untracked: false,
|
||||||
|
conflicted: false,
|
||||||
|
}];
|
||||||
|
|
||||||
|
dashboard.begin_commit_prompt();
|
||||||
|
|
||||||
|
assert_eq!(dashboard.commit_input.as_deref(), Some(""));
|
||||||
|
assert_eq!(
|
||||||
|
dashboard.operator_note.as_deref(),
|
||||||
|
Some("commit mode | type a message and press Enter")
|
||||||
|
);
|
||||||
|
let rendered = render_dashboard_text(dashboard, 180, 20);
|
||||||
|
assert!(rendered.contains("commit>_"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_diff_view_mode_switches_to_unified_rendering() {
|
fn toggle_diff_view_mode_switches_to_unified_rendering() {
|
||||||
let mut dashboard = test_dashboard(
|
let mut dashboard = test_dashboard(
|
||||||
@@ -10489,6 +10891,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
diff_view_mode: DiffViewMode::Split,
|
diff_view_mode: DiffViewMode::Split,
|
||||||
selected_conflict_protocol: None,
|
selected_conflict_protocol: None,
|
||||||
selected_merge_readiness: None,
|
selected_merge_readiness: None,
|
||||||
|
selected_git_status_entries: Vec::new(),
|
||||||
|
selected_git_status: 0,
|
||||||
output_mode: OutputMode::SessionOutput,
|
output_mode: OutputMode::SessionOutput,
|
||||||
output_filter: OutputFilter::All,
|
output_filter: OutputFilter::All,
|
||||||
output_time_filter: OutputTimeFilter::AllTime,
|
output_time_filter: OutputTimeFilter::AllTime,
|
||||||
@@ -10507,6 +10911,7 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
collapsed_panes: HashSet::new(),
|
collapsed_panes: HashSet::new(),
|
||||||
search_input: None,
|
search_input: None,
|
||||||
spawn_input: None,
|
spawn_input: None,
|
||||||
|
commit_input: None,
|
||||||
search_query: None,
|
search_query: None,
|
||||||
search_scope: SearchScope::SelectedSession,
|
search_scope: SearchScope::SelectedSession,
|
||||||
search_agent_filter: SearchAgentFilter::AllAgents,
|
search_agent_filter: SearchAgentFilter::AllAgents,
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ pub struct BranchConflictPreview {
|
|||||||
pub right_patch_preview: Option<String>,
|
pub right_patch_preview: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct GitStatusEntry {
|
||||||
|
pub path: String,
|
||||||
|
pub display_path: String,
|
||||||
|
pub index_status: char,
|
||||||
|
pub worktree_status: char,
|
||||||
|
pub staged: bool,
|
||||||
|
pub unstaged: bool,
|
||||||
|
pub untracked: bool,
|
||||||
|
pub conflicted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new git worktree for an agent session.
|
/// Create a new git worktree for an agent session.
|
||||||
pub fn create_for_session(session_id: &str, cfg: &Config) -> Result<WorktreeInfo> {
|
pub fn create_for_session(session_id: &str, cfg: &Config) -> Result<WorktreeInfo> {
|
||||||
let repo_root = std::env::current_dir().context("Failed to resolve repository root")?;
|
let repo_root = std::env::current_dir().context("Failed to resolve repository root")?;
|
||||||
@@ -222,6 +234,124 @@ pub fn diff_summary(worktree: &WorktreeInfo) -> Result<Option<String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn git_status_entries(worktree: &WorktreeInfo) -> Result<Vec<GitStatusEntry>> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&worktree.path)
|
||||||
|
.args(["status", "--porcelain=v1", "--untracked-files=all"])
|
||||||
|
.output()
|
||||||
|
.context("Failed to load git status entries")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
anyhow::bail!("git status failed: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(parse_git_status_entry)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stage_path(worktree: &WorktreeInfo, path: &str) -> Result<()> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&worktree.path)
|
||||||
|
.args(["add", "--"])
|
||||||
|
.arg(path)
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("Failed to stage {}", path))?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
anyhow::bail!("git add failed for {path}: {stderr}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unstage_path(worktree: &WorktreeInfo, path: &str) -> Result<()> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&worktree.path)
|
||||||
|
.args(["reset", "HEAD", "--"])
|
||||||
|
.arg(path)
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("Failed to unstage {}", path))?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
anyhow::bail!("git reset failed for {path}: {stderr}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_path(worktree: &WorktreeInfo, entry: &GitStatusEntry) -> Result<()> {
|
||||||
|
if entry.untracked {
|
||||||
|
let target = worktree.path.join(&entry.path);
|
||||||
|
if !target.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let metadata = fs::symlink_metadata(&target)
|
||||||
|
.with_context(|| format!("Failed to inspect untracked path {}", target.display()))?;
|
||||||
|
if metadata.is_dir() {
|
||||||
|
fs::remove_dir_all(&target)
|
||||||
|
.with_context(|| format!("Failed to remove {}", target.display()))?;
|
||||||
|
} else {
|
||||||
|
fs::remove_file(&target)
|
||||||
|
.with_context(|| format!("Failed to remove {}", target.display()))?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&worktree.path)
|
||||||
|
.args(["restore", "--source=HEAD", "--staged", "--worktree", "--"])
|
||||||
|
.arg(&entry.path)
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("Failed to reset {}", entry.path))?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
anyhow::bail!("git restore failed for {}: {stderr}", entry.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result<String> {
|
||||||
|
let message = message.trim();
|
||||||
|
if message.is_empty() {
|
||||||
|
anyhow::bail!("commit message cannot be empty");
|
||||||
|
}
|
||||||
|
if !has_staged_changes(worktree)? {
|
||||||
|
anyhow::bail!("no staged changes to commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&worktree.path)
|
||||||
|
.args(["commit", "-m", message])
|
||||||
|
.output()
|
||||||
|
.context("Failed to create commit")?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
anyhow::bail!("git commit failed: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rev_parse = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&worktree.path)
|
||||||
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.context("Failed to resolve commit hash")?;
|
||||||
|
if !rev_parse.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&rev_parse.stderr);
|
||||||
|
anyhow::bail!("git rev-parse failed: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&rev_parse.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result<Vec<String>> {
|
pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result<Vec<String>> {
|
||||||
let mut preview = Vec::new();
|
let mut preview = Vec::new();
|
||||||
let base_ref = format!("{}...HEAD", worktree.base_branch);
|
let base_ref = format!("{}...HEAD", worktree.base_branch);
|
||||||
@@ -409,6 +539,10 @@ pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result<bool> {
|
|||||||
Ok(!git_status_short(&worktree.path)?.is_empty())
|
Ok(!git_status_short(&worktree.path)?.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result<bool> {
|
||||||
|
Ok(git_status_entries(worktree)?.iter().any(|entry| entry.staged))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
|
pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
|
||||||
let readiness = merge_readiness(worktree)?;
|
let readiness = merge_readiness(worktree)?;
|
||||||
if readiness.status == MergeReadinessStatus::Conflicted {
|
if readiness.status == MergeReadinessStatus::Conflicted {
|
||||||
@@ -854,6 +988,43 @@ fn validate_branch_name(repo_root: &Path, branch: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_git_status_entry(line: &str) -> Option<GitStatusEntry> {
|
||||||
|
if line.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = line.as_bytes();
|
||||||
|
let index_status = bytes[0] as char;
|
||||||
|
let worktree_status = bytes[1] as char;
|
||||||
|
let raw_path = line.get(3..)?.trim();
|
||||||
|
if raw_path.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let display_path = raw_path.to_string();
|
||||||
|
let normalized_path = raw_path
|
||||||
|
.split(" -> ")
|
||||||
|
.last()
|
||||||
|
.unwrap_or(raw_path)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let conflicted = matches!(
|
||||||
|
(index_status, worktree_status),
|
||||||
|
('U', _)
|
||||||
|
| (_, 'U')
|
||||||
|
| ('A', 'A')
|
||||||
|
| ('D', 'D')
|
||||||
|
);
|
||||||
|
Some(GitStatusEntry {
|
||||||
|
path: normalized_path,
|
||||||
|
display_path,
|
||||||
|
index_status,
|
||||||
|
worktree_status,
|
||||||
|
staged: index_status != ' ' && index_status != '?',
|
||||||
|
unstaged: worktree_status != ' ' && worktree_status != '?',
|
||||||
|
untracked: index_status == '?' && worktree_status == '?',
|
||||||
|
conflicted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_nonempty_lines(stdout: &[u8]) -> Vec<String> {
|
fn parse_nonempty_lines(stdout: &[u8]) -> Vec<String> {
|
||||||
String::from_utf8_lossy(stdout)
|
String::from_utf8_lossy(stdout)
|
||||||
.lines()
|
.lines()
|
||||||
@@ -1331,6 +1502,68 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_status_helpers_stage_unstage_reset_and_commit() -> Result<()> {
|
||||||
|
let root = std::env::temp_dir().join(format!("ecc2-git-status-helpers-{}", Uuid::new_v4()));
|
||||||
|
let repo = init_repo(&root)?;
|
||||||
|
let worktree = WorktreeInfo {
|
||||||
|
path: repo.clone(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
base_branch: "main".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::write(repo.join("README.md"), "hello updated\n")?;
|
||||||
|
fs::write(repo.join("notes.txt"), "draft\n")?;
|
||||||
|
|
||||||
|
let mut entries = git_status_entries(&worktree)?;
|
||||||
|
let readme = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.path == "README.md")
|
||||||
|
.expect("tracked README entry");
|
||||||
|
assert!(readme.unstaged);
|
||||||
|
let notes = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.path == "notes.txt")
|
||||||
|
.expect("untracked notes entry");
|
||||||
|
assert!(notes.untracked);
|
||||||
|
|
||||||
|
stage_path(&worktree, "notes.txt")?;
|
||||||
|
entries = git_status_entries(&worktree)?;
|
||||||
|
let notes = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.path == "notes.txt")
|
||||||
|
.expect("staged notes entry");
|
||||||
|
assert!(notes.staged);
|
||||||
|
assert!(!notes.untracked);
|
||||||
|
|
||||||
|
unstage_path(&worktree, "notes.txt")?;
|
||||||
|
entries = git_status_entries(&worktree)?;
|
||||||
|
let notes = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.path == "notes.txt")
|
||||||
|
.expect("restored notes entry");
|
||||||
|
assert!(notes.untracked);
|
||||||
|
|
||||||
|
let notes_entry = notes.clone();
|
||||||
|
reset_path(&worktree, ¬es_entry)?;
|
||||||
|
assert!(!repo.join("notes.txt").exists());
|
||||||
|
|
||||||
|
stage_path(&worktree, "README.md")?;
|
||||||
|
let hash = commit_staged(&worktree, "update readme")?;
|
||||||
|
assert!(!hash.is_empty());
|
||||||
|
assert!(git_status_entries(&worktree)?.is_empty());
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["log", "-1", "--pretty=%s"])
|
||||||
|
.output()?;
|
||||||
|
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "update readme");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_for_session_links_shared_node_modules_cache() -> Result<()> {
|
fn create_for_session_links_shared_node_modules_cache() -> Result<()> {
|
||||||
let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4()));
|
let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4()));
|
||||||
|
|||||||
Reference in New Issue
Block a user