Files
DS4Server/src/app/view/git.rs
2026-07-28 21:11:46 +02:00

494 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}