Add Git worktree pane
This commit is contained in:
429
src/app/view/git.rs
Normal file
429
src/app/view/git.rs
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user