494 lines
17 KiB
Rust
494 lines
17 KiB
Rust
use super::*;
|
||
use crate::app::git::{GitChangeKind, GitDiffLine, GitDiffLineKind, GitDiffRow};
|
||
use crate::app::{git_diff_new_scroll_id, git_diff_old_scroll_id};
|
||
use crate::config::GitDiffLayout;
|
||
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![
|
||
toggle(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)),
|
||
Space::new().width(6),
|
||
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_layout == GitDiffLayout::Unified;
|
||
let split_active = self.git_diff_layout == GitDiffLayout::Split;
|
||
let mode = container(
|
||
row![
|
||
button(text("1 column").size(12))
|
||
.padding([4, 10])
|
||
.on_press(Message::SetGitDiffLayout(GitDiffLayout::Unified))
|
||
.style(move |theme, status| segmented_button_style(
|
||
theme,
|
||
status,
|
||
unified_active
|
||
)),
|
||
button(text("2 columns").size(12))
|
||
.padding([4, 10])
|
||
.on_press(Message::SetGitDiffLayout(GitDiffLayout::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).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 width = split_diff_width(&rows);
|
||
let old = scrollable(split_diff_half(&rows, true, width))
|
||
.id(git_diff_old_scroll_id())
|
||
.direction(scrollable::Direction::Horizontal(
|
||
scrollable::Scrollbar::new(),
|
||
))
|
||
.on_scroll(Message::GitDiffScrolled)
|
||
.width(Length::Fill);
|
||
let new = scrollable(split_diff_half(&rows, false, width))
|
||
.id(git_diff_new_scroll_id())
|
||
.direction(scrollable::Direction::Horizontal(
|
||
scrollable::Scrollbar::new(),
|
||
))
|
||
.on_scroll(Message::GitDiffScrolled)
|
||
.width(Length::Fill);
|
||
row![old, rule::vertical(1), new].into()
|
||
}
|
||
|
||
fn split_diff_width(rows: &[GitDiffRow]) -> f32 {
|
||
rows.iter()
|
||
.flat_map(|row| match row {
|
||
GitDiffRow::Header(line) => [Some(line), None],
|
||
GitDiffRow::Pair { old, new } => [old.as_ref(), new.as_ref()],
|
||
})
|
||
.flatten()
|
||
.map(|line| {
|
||
line.text
|
||
.chars()
|
||
.map(|character| if character == '\t' { 4 } else { 1 })
|
||
.sum::<usize>()
|
||
})
|
||
.max()
|
||
.unwrap_or_default() as f32
|
||
* 8.0
|
||
+ 66.0
|
||
}
|
||
|
||
fn split_diff_half(rows: &[GitDiffRow], old: bool, width: f32) -> Element<'static, Message> {
|
||
let mut content = column![].spacing(0).width(width);
|
||
for row in rows {
|
||
content = content.push(match row {
|
||
GitDiffRow::Header(line) => diff_line(line, old),
|
||
GitDiffRow::Pair {
|
||
old: old_line,
|
||
new: new_line,
|
||
} => diff_half(
|
||
if old {
|
||
old_line.as_ref()
|
||
} else {
|
||
new_line.as_ref()
|
||
},
|
||
old,
|
||
),
|
||
});
|
||
}
|
||
content.into()
|
||
}
|
||
|
||
fn diff_half(line: Option<&GitDiffLine>, old: bool) -> Element<'static, Message> {
|
||
line.map_or_else(
|
||
|| {
|
||
container(Space::new().width(Length::Fill))
|
||
.height(24)
|
||
.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))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn split_diff_width_covers_the_longest_side_and_tabs() {
|
||
let rows = [GitDiffRow::Pair {
|
||
old: Some(GitDiffLine {
|
||
kind: GitDiffLineKind::Deletion,
|
||
old_number: Some(1),
|
||
new_number: None,
|
||
text: "short".into(),
|
||
}),
|
||
new: Some(GitDiffLine {
|
||
kind: GitDiffLineKind::Addition,
|
||
old_number: None,
|
||
new_number: Some(1),
|
||
text: "\tlong".into(),
|
||
}),
|
||
}];
|
||
|
||
assert_eq!(split_diff_width(&rows), 130.0);
|
||
}
|
||
}
|