Add Git worktree pane

This commit is contained in:
Georg Bauer
2026-07-27 19:44:12 +02:00
parent aebfe7aeb6
commit b9933ae076
9 changed files with 2630 additions and 5 deletions

429
src/app/view/git.rs Normal file
View File

@@ -0,0 +1,429 @@
use super::*;
use crate::app::git::{GitChangeKind, GitDiffLine, GitDiffLineKind, GitDiffMode, GitDiffRow};
use iced::widget::column;
impl App {
pub(super) fn git_detail(&self) -> Element<'_, Message> {
let Some(project) = self.selected_project() else {
return git_empty(
"No project selected",
"Choose a project to inspect its worktree.",
);
};
let Some(state) = self.git_states.get(&project.project.id) else {
return git_empty(
"Not a Git worktree",
"The selected project is not inside a Git repository.",
);
};
let Some(worktree) = self.git_worktrees.get(&project.project.id) else {
return git_empty("Reading worktree", "Git status will appear here shortly.");
};
let counts = [
(GitChangeKind::Added, "Added"),
(GitChangeKind::Modified, "Changed"),
(GitChangeKind::Deleted, "Deleted"),
];
let mut file_list = column![].spacing(0);
for (kind, label) in counts {
let files = worktree
.files
.iter()
.filter(|file| file.kind() == kind)
.collect::<Vec<_>>();
if files.is_empty() {
continue;
}
file_list = file_list.push(
container(
row![
text(label).size(11).color(muted_text()),
Space::new().width(Length::Fill),
text(files.len()).size(11).color(muted_text()),
]
.align_y(Alignment::Center),
)
.padding([8, 12])
.width(Length::Fill),
);
for file in files {
let path = file.path.clone();
let selected = self.git_selected_project == Some(project.project.id)
&& self.git_selected_files.contains(&file.path);
let staged = file.staged_kind.map(|kind| format!("S {}", kind.marker()));
let worktree = file
.worktree_kind
.map(|kind| format!("W {}", kind.marker()));
file_list = file_list.push(rule::horizontal(1)).push(
container(
row![
checkbox(selected).on_toggle({
let path = path.clone();
move |_| Message::ToggleGitFile(path.clone())
}),
text(kind.marker()).size(12).color(git_change_color(kind)),
button(text(&file.display_path).size(13))
.padding(0)
.style(button::text)
.on_press(Message::OpenGitDiff(path)),
Space::new().width(Length::Fill),
staged.map(|label| text(label).size(11).color(staged_color())),
worktree.map(|label| text(label).size(11).color(muted_text())),
]
.spacing(8)
.align_y(Alignment::Center),
)
.padding([9, 12])
.width(Length::Fill),
);
}
}
if worktree.files.is_empty() {
file_list = file_list.push(
container(
column![
text("Worktree clean").size(18),
text("There are no staged or unstaged changes.")
.size(13)
.color(muted_text()),
]
.spacing(6)
.align_x(Alignment::Center),
)
.padding(36)
.center_x(Length::Fill)
.width(Length::Fill),
);
}
let selected = self.git_selected_project == Some(project.project.id);
let can_stage = selected
&& worktree.files.iter().any(|file| {
self.git_selected_files.contains(&file.path) && file.worktree_kind.is_some()
});
let can_unstage = selected
&& worktree.files.iter().any(|file| {
self.git_selected_files.contains(&file.path) && file.staged_kind.is_some()
});
let idle = self.git_operation.is_none();
let commit_ready =
idle && !worktree.files.is_empty() && !self.git_commit_message.trim().is_empty();
let commit_input: Element<'_, Message> = if self.modal_open() {
container(
text(if self.git_commit_message.is_empty() {
"Commit message"
} else {
&self.git_commit_message
})
.size(13)
.color(muted_text()),
)
.padding(9)
.width(Length::Fill)
.style(preference_group_style)
.into()
} else {
text_input("Commit message", &self.git_commit_message)
.on_input(Message::GitCommitMessageChanged)
.on_submit(Message::GitCommit)
.padding(9)
.size(13)
.into()
};
let controls = column![
row![
git_icon_action(
"",
"Stage selected files",
(idle && can_stage).then_some(Message::GitStageSelected)
),
git_icon_action(
"",
"Unstage selected files",
(idle && can_unstage).then_some(Message::GitUnstageSelected)
),
commit_input,
git_icon_action("", "Commit", commit_ready.then_some(Message::GitCommit)),
rule::vertical(22),
git_icon_action("", "Fetch from origin", idle.then_some(Message::GitFetch)),
git_icon_action("", "Pull from origin", idle.then_some(Message::GitPull)),
git_icon_action("", "Push to origin", idle.then_some(Message::GitPush)),
]
.spacing(7)
.align_y(Alignment::Center),
self.git_operation
.as_ref()
.map(|operation| text(format!("{}", operation.label))
.size(12)
.color(muted_text())),
]
.spacing(6);
let summary = format!(
"{} change{}",
worktree.files.len(),
if worktree.files.len() == 1 { "" } else { "s" }
);
container(
column![
row![
column![
text(&project.project.name).size(20),
text(worktree.root.display().to_string())
.size(11)
.color(muted_text()),
]
.spacing(4),
Space::new().width(Length::Fill),
column![
text(&state.label).size(13),
text(summary).size(11).color(muted_text()),
]
.spacing(4)
.align_x(Alignment::End),
]
.align_y(Alignment::Center),
container(scrollable(file_list).height(Length::Fill))
.height(Length::Fill)
.width(Length::Fill)
.style(overview_style),
container(controls)
.padding(12)
.width(Length::Fill)
.style(overview_style),
]
.spacing(12),
)
.padding(Padding::new(28.0).top(22.0))
.height(Length::Fill)
.width(Length::Fill)
.into()
}
pub(super) fn git_commit_all_panel(&self) -> Element<'_, Message> {
let dialog = container(
column![
text("Commit all changes?").size(24),
text("No files are selected and no changes are staged. All worktree changes will be staged and committed.")
.size(14),
row![
Space::new().width(Length::Fill),
action_button("Cancel").on_press(Message::DismissPanel),
action_button("Commit all changes").on_press(Message::ConfirmGitCommitAll),
]
.spacing(8),
]
.spacing(12),
)
.padding(22)
.width(500)
.style(overview_style);
opaque(
container(dialog)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(modal_backdrop_style),
)
}
pub(super) fn git_diff_panel(&self) -> Element<'_, Message> {
let diff = self.git_diff.as_ref().unwrap();
let unified_active = self.git_diff_mode == GitDiffMode::Unified;
let split_active = self.git_diff_mode == GitDiffMode::Split;
let mode = container(
row![
button(text("1 column").size(12))
.padding([4, 10])
.on_press(Message::SetGitDiffMode(GitDiffMode::Unified))
.style(move |theme, status| segmented_button_style(
theme,
status,
unified_active
)),
button(text("2 columns").size(12))
.padding([4, 10])
.on_press(Message::SetGitDiffMode(GitDiffMode::Split))
.style(move |theme, status| segmented_button_style(
theme,
status,
split_active
)),
]
.spacing(2),
)
.padding(2)
.style(segmented_control_style);
let content = if unified_active {
unified_diff(&diff.lines)
} else {
split_diff(diff.split_rows())
};
let diff_scroll = scrollable(content)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::new(),
horizontal: scrollable::Scrollbar::new(),
})
.height(Length::Fill)
.width(Length::Fill);
let dialog = container(
column![
row![
column![text("File diff").size(22), text(&diff.path).size(13)].spacing(4),
Space::new().width(Length::Fill),
mode,
action_button("Close").on_press(Message::DismissPanel),
]
.spacing(10)
.align_y(Alignment::Center),
container(diff_scroll)
.height(Length::Fill)
.width(Length::Fill)
.style(overview_style),
]
.spacing(12),
)
.padding(18)
.max_width(1_240)
.max_height(840)
.width(Length::Fill)
.height(Length::Fill)
.style(overview_style);
opaque(
container(dialog)
.padding([42, 32])
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(modal_backdrop_style),
)
}
}
fn git_empty<'a>(title: &'a str, description: &'a str) -> Element<'a, Message> {
container(
column![
text(title).size(24),
text(description).size(14).color(muted_text()),
]
.spacing(8)
.align_x(Alignment::Center),
)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
fn git_icon_action<'a>(
symbol: &'a str,
label: &'a str,
message: Option<Message>,
) -> Element<'a, Message> {
let mut action = action_button(text(symbol).size(17)).padding([5, 9]);
if let Some(message) = message {
action = action.on_press(message);
}
tooltip(
action,
container(text(label).size(12))
.padding(8)
.style(preference_group_style),
tooltip::Position::Top,
)
.gap(5)
.into()
}
fn unified_diff(lines: &[GitDiffLine]) -> Element<'_, Message> {
let mut content = column![].spacing(0).width(Length::Shrink);
for line in lines {
content = content.push(diff_line(line, false));
}
content.into()
}
fn split_diff(rows: Vec<GitDiffRow>) -> Element<'static, Message> {
let mut content = column![].spacing(0).width(Length::Fill);
for row in rows {
content = content.push(match row {
GitDiffRow::Header(line) => diff_line(&line, false),
GitDiffRow::Pair { old, new } => row![
diff_half(old.as_ref(), true),
rule::vertical(1),
diff_half(new.as_ref(), false),
]
.height(24)
.into(),
});
}
content.into()
}
fn diff_half(line: Option<&GitDiffLine>, old: bool) -> Element<'static, Message> {
line.map_or_else(
|| {
container(Space::new().width(Length::Fill))
.width(Length::Fill)
.into()
},
|line| diff_line(line, old),
)
}
fn diff_line(line: &GitDiffLine, old: bool) -> Element<'static, Message> {
let kind = line.kind;
let number = if old {
line.old_number
} else {
line.new_number.or(line.old_number)
};
container(
row![
text(number.map_or_else(String::new, |number| number.to_string()))
.font(iced::Font::MONOSPACE)
.size(11)
.color(muted_text())
.width(42),
text(line.text.clone())
.font(iced::Font::MONOSPACE)
.size(12)
.wrapping(iced::widget::text::Wrapping::None),
]
.spacing(8)
.align_y(Alignment::Center),
)
.padding([3, 8])
.height(24)
.width(Length::Fill)
.style(move |_| diff_line_style(kind))
.into()
}
fn diff_line_style(kind: GitDiffLineKind) -> container::Style {
let background = match kind {
GitDiffLineKind::Addition => Some(Color::from_rgba8(45, 120, 72, 0.24)),
GitDiffLineKind::Deletion => Some(Color::from_rgba8(170, 55, 64, 0.24)),
GitDiffLineKind::Hunk => Some(Color::from_rgba8(55, 92, 150, 0.22)),
GitDiffLineKind::Section => Some(Color::from_rgba8(255, 255, 255, 0.08)),
GitDiffLineKind::Header | GitDiffLineKind::Context => None,
};
match background {
Some(background) => container::Style::default().background(background),
None => container::Style::default(),
}
}
fn git_change_color(kind: GitChangeKind) -> Color {
match kind {
GitChangeKind::Added => Color::from_rgb8(93, 186, 112),
GitChangeKind::Modified => Color::from_rgb8(224, 174, 82),
GitChangeKind::Deleted => Color::from_rgb8(224, 96, 104),
}
}
fn staged_color() -> Color {
Color::from_rgb8(93, 186, 112)
}
fn modal_backdrop_style(_: &Theme) -> container::Style {
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.72))
}