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

1136
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ cc = "1.3.0"
[dependencies]
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] }
diesel_migrations = "2.3.2"
gix = { version = "0.86.0", default-features = false, features = ["max-performance-safe", "sha1", "status"] }
iced = { version = "0.14.0", features = ["advanced", "highlighter", "image-without-codecs", "markdown", "svg", "tokio"] }
image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
memmap2 = "0.9.11"

View File

@@ -20,6 +20,14 @@ the current local branch. An unsaved draft can be moved with the project menu;
saved chats keep their original project. Use the branch menu to switch local
branches when that project's chats are idle.
The **Git** pane shows added, changed, and deleted files from the current
worktree, including separate staged and worktree markers. Select files to stage,
unstage, or commit them, or commit the existing staged set. Committing with no
selection or staged files asks before staging and committing every change. File
names open a colored diff that can switch between unified and side-by-side
layouts. The fetch, pull, and push actions use the `origin` remote and report
authentication, hook, conflict, and other Git errors without hiding them.
Quitting with active chats asks for confirmation. Confirming stops their model
and tool work; canceling leaves every chat running.

View File

@@ -1,4 +1,5 @@
mod generation;
mod git;
mod model_manager;
mod preferences;
mod projects;
@@ -7,6 +8,7 @@ mod view;
pub(crate) use view::app_theme;
use generation::ChatMessage;
use git::{ActiveGitOperation, GitDiff, GitDiffMode, GitWorktree};
use model_manager::{ActiveDownload, ModelDownload, ModelOperation};
use preferences::PreferenceDraft;
#[cfg(test)]
@@ -41,6 +43,7 @@ const APP_ID: &str = "de.rfc1437.ds4server";
const METRICS_SAMPLE_INTERVAL: Duration = Duration::from_millis(200);
/// Disc scans are cheap but not free; the explorer does not need 5 Hz.
const CACHE_SCAN_INTERVAL: Duration = Duration::from_secs(2);
const GIT_SCAN_INTERVAL: Duration = Duration::from_secs(2);
pub(super) const MIN_SIDEBAR_WIDTH: i32 = 180;
pub(super) const MAX_SIDEBAR_WIDTH: i32 = 520;
@@ -67,6 +70,15 @@ pub(crate) struct App {
/// when its first chat turn is stored, so empty ones vanish on restart.
drafts: HashMap<i32, String>,
git_states: HashMap<i32, GitState>,
git_worktrees: HashMap<i32, GitWorktree>,
git_selected_project: Option<i32>,
git_selected_files: HashSet<PathBuf>,
git_commit_message: String,
git_diff: Option<GitDiff>,
git_diff_mode: GitDiffMode,
git_commit_all_confirmation: bool,
git_operation: Option<ActiveGitOperation>,
last_git_scan: Instant,
#[cfg(target_os = "macos")]
background_chats: HashMap<i32, ChatSnapshot>,
/// Session whose quick-actions menu is open.
@@ -227,6 +239,7 @@ pub(super) enum DetailTab {
#[default]
Chat,
A2ui,
Git,
Stats,
}
@@ -401,6 +414,18 @@ pub(crate) enum Message {
CreateSession(i32),
DraftProjectChanged(i32),
SwitchGitBranch(String),
ToggleGitFile(PathBuf),
OpenGitDiff(PathBuf),
SetGitDiffMode(GitDiffMode),
GitCommitMessageChanged(String),
GitStageSelected,
GitUnstageSelected,
GitCommit,
ConfirmGitCommitAll,
GitFetch,
GitPull,
GitPush,
GitOperationTick,
DiscardSession(i32),
SelectSession(i32, i32),
RequestDeleteSession(i32),
@@ -416,6 +441,7 @@ pub(crate) enum Message {
ToggleArchivedSessions(i32),
ShowChat,
ShowA2ui,
ShowGit,
ShowStats,
ToggleSidebar,
StartSidebarDrag,
@@ -479,6 +505,15 @@ impl App {
selected_session: None,
drafts,
git_states: HashMap::new(),
git_worktrees: HashMap::new(),
git_selected_project: None,
git_selected_files: HashSet::new(),
git_commit_message: String::new(),
git_diff: None,
git_diff_mode: GitDiffMode::default(),
git_commit_all_confirmation: false,
git_operation: None,
last_git_scan: Instant::now() - GIT_SCAN_INTERVAL,
#[cfg(target_os = "macos")]
background_chats: HashMap::new(),
session_menu: None,
@@ -612,6 +647,15 @@ impl App {
selected_session: None,
drafts: HashMap::new(),
git_states: HashMap::new(),
git_worktrees: HashMap::new(),
git_selected_project: None,
git_selected_files: HashSet::new(),
git_commit_message: String::new(),
git_diff: None,
git_diff_mode: GitDiffMode::default(),
git_commit_all_confirmation: false,
git_operation: None,
last_git_scan: Instant::now() - GIT_SCAN_INTERVAL,
#[cfg(target_os = "macos")]
background_chats: HashMap::new(),
session_menu: None,
@@ -987,6 +1031,10 @@ impl App {
Message::DismissPanel => {
if self.quit_confirmation {
self.quit_confirmation = false;
} else if self.git_diff.is_some() {
self.git_diff = None;
} else if self.git_commit_all_confirmation {
self.git_commit_all_confirmation = false;
} else if self.pending_session_delete.is_some() {
self.pending_session_delete = None;
} else if self.pending_a2ui_dismissal.is_some() {
@@ -1012,6 +1060,13 @@ impl App {
return scroll_chat_to_end();
}
Message::ShowA2ui => self.detail_tab = DetailTab::A2ui,
Message::ShowGit => {
self.detail_tab = DetailTab::Git;
if let Some(project_id) = self.selected_project {
self.refresh_git_state();
self.refresh_git_worktree(project_id);
}
}
Message::ShowStats => {
self.detail_tab = DetailTab::Stats;
self.scan_kv_cache();
@@ -1625,6 +1680,11 @@ impl App {
}
self.drafts.remove(&project_id);
self.git_states.remove(&project_id);
self.git_worktrees.remove(&project_id);
if self.git_selected_project == Some(project_id) {
self.git_selected_project = None;
self.git_selected_files.clear();
}
if self.config.interface.last_project_id == Some(project_id) {
self.config.interface.last_project_id = None;
let _ = self.config.save(&config_path());
@@ -1656,6 +1716,18 @@ impl App {
}
Message::DraftProjectChanged(project_id) => self.move_draft_to_project(project_id),
Message::SwitchGitBranch(branch) => self.switch_git_branch(&branch),
Message::ToggleGitFile(path) => self.toggle_git_file(path),
Message::OpenGitDiff(path) => self.open_git_diff(&path),
Message::SetGitDiffMode(mode) => self.git_diff_mode = mode,
Message::GitCommitMessageChanged(message) => self.git_commit_message = message,
Message::GitStageSelected => self.stage_selected_git_files(),
Message::GitUnstageSelected => self.unstage_selected_git_files(),
Message::GitCommit => self.commit_git(),
Message::ConfirmGitCommitAll => self.confirm_commit_all_git(),
Message::GitFetch => self.start_git_remote("fetch"),
Message::GitPull => self.start_git_remote("pull"),
Message::GitPush => self.start_git_remote("push"),
Message::GitOperationTick => self.poll_git_operation(),
Message::DiscardSession(project_id) => self.discard_session(project_id),
Message::OpenSessionMenu(session_id) => {
self.session_rename = None;
@@ -1961,6 +2033,11 @@ impl App {
iced::time::every(Duration::from_secs(1)).map(|_| Message::DownloadProgressTick),
);
}
if self.git_operation.is_some() {
subscriptions.push(
iced::time::every(Duration::from_millis(100)).map(|_| Message::GitOperationTick),
);
}
#[cfg(target_os = "macos")]
let titling = self.active_titling.is_some();
#[cfg(not(target_os = "macos"))]
@@ -2054,6 +2131,14 @@ impl App {
{
self.scan_kv_cache();
}
if self.detail_tab == DetailTab::Git
&& self.git_operation.is_none()
&& self.last_git_scan.elapsed() >= GIT_SCAN_INTERVAL
&& let Some(project_id) = self.selected_project
{
self.refresh_git_state();
self.refresh_git_worktree(project_id);
}
}
/// Removes one cache file. Checkpoints are disposable: the next turn on that

View File

@@ -689,6 +689,11 @@ impl App {
}
if was_generating && !self.generating {
self.refresh_git_state();
if self.detail_tab == DetailTab::Git
&& let Some(project_id) = self.selected_project
{
self.refresh_git_worktree(project_id);
}
}
changed
}

930
src/app/git.rs Normal file
View File

@@ -0,0 +1,930 @@
use super::*;
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::process::{Command, Output};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum GitChangeKind {
Added,
Modified,
Deleted,
}
impl GitChangeKind {
pub(super) fn marker(self) -> &'static str {
match self {
Self::Added => "A",
Self::Modified => "M",
Self::Deleted => "D",
}
}
}
#[derive(Clone, Debug)]
pub(super) struct GitFile {
pub(super) path: PathBuf,
pub(super) display_path: String,
pub(super) staged_kind: Option<GitChangeKind>,
pub(super) worktree_kind: Option<GitChangeKind>,
}
impl GitFile {
pub(super) fn kind(&self) -> GitChangeKind {
self.worktree_kind.or(self.staged_kind).unwrap()
}
}
#[derive(Clone, Debug)]
pub(super) struct GitWorktree {
pub(super) root: PathBuf,
pub(super) files: Vec<GitFile>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum GitDiffMode {
#[default]
Unified,
Split,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum GitDiffLineKind {
Section,
Header,
Hunk,
Context,
Addition,
Deletion,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct GitDiffLine {
pub(super) kind: GitDiffLineKind,
pub(super) old_number: Option<u32>,
pub(super) new_number: Option<u32>,
pub(super) text: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum GitDiffRow {
Header(GitDiffLine),
Pair {
old: Option<GitDiffLine>,
new: Option<GitDiffLine>,
},
}
#[derive(Clone, Debug)]
pub(super) struct GitDiff {
pub(super) path: String,
pub(super) lines: Vec<GitDiffLine>,
}
impl GitDiff {
pub(super) fn split_rows(&self) -> Vec<GitDiffRow> {
let mut rows = Vec::new();
let mut index = 0;
while index < self.lines.len() {
let line = &self.lines[index];
match line.kind {
GitDiffLineKind::Section | GitDiffLineKind::Header | GitDiffLineKind::Hunk => {
rows.push(GitDiffRow::Header(line.clone()));
index += 1;
}
GitDiffLineKind::Context => {
rows.push(GitDiffRow::Pair {
old: Some(line.clone()),
new: Some(line.clone()),
});
index += 1;
}
GitDiffLineKind::Deletion => {
let deletion_start = index;
while index < self.lines.len()
&& self.lines[index].kind == GitDiffLineKind::Deletion
{
index += 1;
}
let addition_start = index;
while index < self.lines.len()
&& self.lines[index].kind == GitDiffLineKind::Addition
{
index += 1;
}
let deletions = &self.lines[deletion_start..addition_start];
let additions = &self.lines[addition_start..index];
for offset in 0..deletions.len().max(additions.len()) {
rows.push(GitDiffRow::Pair {
old: deletions.get(offset).cloned(),
new: additions.get(offset).cloned(),
});
}
}
GitDiffLineKind::Addition => {
rows.push(GitDiffRow::Pair {
old: None,
new: Some(line.clone()),
});
index += 1;
}
}
}
rows
}
}
pub(super) struct ActiveGitOperation {
pub(super) project_id: i32,
pub(super) label: String,
receiver: mpsc::Receiver<Result<(), String>>,
clear_commit_on_success: bool,
}
impl App {
pub(super) fn refresh_git_worktree(&mut self, project_id: i32) {
let Some(path) = self
.projects
.iter()
.find(|item| item.project.id == project_id)
.map(|item| PathBuf::from(&item.project.path))
else {
self.git_worktrees.remove(&project_id);
return;
};
match read_worktree(&path) {
Ok(worktree) => {
self.git_worktrees.insert(project_id, worktree);
if self.git_selected_project == Some(project_id) {
let files = &self.git_worktrees[&project_id].files;
self.git_selected_files
.retain(|path| files.iter().any(|file| file.path == *path));
}
}
Err(error) => {
self.git_worktrees.remove(&project_id);
if self.git_states.contains_key(&project_id) {
self.error = Some(format!("Could not read Git status: {error}"));
}
}
}
if self.selected_project == Some(project_id) {
self.last_git_scan = Instant::now();
}
}
pub(super) fn open_git_diff(&mut self, path: &Path) {
let Some(project_id) = self.selected_project else {
return;
};
let Some(worktree) = self.git_worktrees.get(&project_id) else {
return;
};
let Some(file) = worktree.files.iter().find(|file| file.path == path) else {
self.error = Some("The selected Git file is no longer available.".into());
return;
};
match load_diff(&worktree.root, file) {
Ok(diff) => {
self.git_diff = Some(diff);
self.error = None;
}
Err(error) => self.error = Some(error),
}
}
pub(super) fn toggle_git_file(&mut self, path: PathBuf) {
let Some(project_id) = self.selected_project else {
return;
};
if !self
.git_worktrees
.get(&project_id)
.is_some_and(|worktree| worktree.files.iter().any(|file| file.path == path))
{
return;
}
if self.git_selected_project != Some(project_id) {
self.git_selected_files.clear();
self.git_selected_project = Some(project_id);
}
if !self.git_selected_files.remove(&path) {
self.git_selected_files.insert(path);
}
}
pub(super) fn stage_selected_git_files(&mut self) {
let Some((project_id, root, paths)) =
self.git_selection(|file| file.worktree_kind.is_some())
else {
self.error = Some("Select at least one file to stage.".into());
return;
};
self.start_git_operation(project_id, "Staging files", false, move || {
stage_paths(&root, &paths)
});
}
pub(super) fn unstage_selected_git_files(&mut self) {
let Some((project_id, root, paths)) = self.git_selection(|file| file.staged_kind.is_some())
else {
self.error = Some("Select at least one file to unstage.".into());
return;
};
self.start_git_operation(project_id, "Unstaging files", false, move || {
unstage_paths(&root, &paths)
});
}
pub(super) fn commit_git(&mut self) {
let message = self.git_commit_message.trim().to_owned();
if message.is_empty() {
self.error = Some("Enter a commit message.".into());
return;
}
let Some(project_id) = self.selected_project else {
return;
};
let Some(worktree) = self.git_worktrees.get(&project_id) else {
self.error = Some("The selected project is not a Git worktree.".into());
return;
};
if self.project_has_active_chat(project_id) {
self.error = Some("Stop active chats before committing worktree changes.".into());
return;
}
let root = worktree.root.clone();
let selected = self.selected_git_paths(project_id);
if !selected.is_empty() {
self.start_git_operation(project_id, "Committing selected files", true, move || {
run_git_paths(&root, &["add", "--"], &selected)?;
run_git_commit_paths(&root, &message, &selected)
});
} else if worktree.files.iter().any(|file| file.staged_kind.is_some()) {
self.start_git_operation(project_id, "Committing staged changes", true, move || {
run_git_args(&root, &["commit", "-m", &message]).map(|_| ())
});
} else if worktree.files.is_empty() {
self.error = Some("There are no changes to commit.".into());
} else {
self.git_commit_all_confirmation = true;
}
}
pub(super) fn confirm_commit_all_git(&mut self) {
let message = self.git_commit_message.trim().to_owned();
let Some(project_id) = self.selected_project else {
return;
};
let Some(worktree) = self.git_worktrees.get(&project_id) else {
return;
};
let root = worktree.root.clone();
self.git_commit_all_confirmation = false;
self.start_git_operation(project_id, "Committing all changes", true, move || {
run_git_args(&root, &["add", "--all"])?;
run_git_args(&root, &["commit", "-m", &message]).map(|_| ())
});
}
pub(super) fn start_git_remote(&mut self, action: &'static str) {
let Some(project_id) = self.selected_project else {
return;
};
let Some(worktree) = self.git_worktrees.get(&project_id) else {
self.error = Some("The selected project is not a Git worktree.".into());
return;
};
if action == "pull" && self.project_has_active_chat(project_id) {
self.error = Some("Stop active chats before pulling worktree changes.".into());
return;
}
let root = worktree.root.clone();
let label = match action {
"fetch" => "Fetching from origin",
"pull" => "Pulling from origin",
"push" => "Pushing to origin",
_ => return,
};
let branch = self
.git_states
.get(&project_id)
.and_then(|state| state.current.clone());
if action != "fetch" && branch.is_none() {
self.error = Some("Attach HEAD to a local branch before pulling or pushing.".into());
return;
}
self.start_git_operation(project_id, label, false, move || {
run_git_remote(&root, action, branch.as_deref())
});
}
pub(super) fn poll_git_operation(&mut self) {
let result =
self.git_operation
.as_ref()
.and_then(|operation| match operation.receiver.try_recv() {
Ok(result) => Some(result),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
Some(Err("The Git operation stopped unexpectedly.".into()))
}
});
let Some(result) = result else {
return;
};
let operation = self.git_operation.take().unwrap();
match result {
Ok(()) => {
if operation.clear_commit_on_success {
self.git_commit_message.clear();
self.git_selected_files.clear();
}
self.error = None;
}
Err(error) => self.error = Some(error),
}
self.refresh_git_worktree(operation.project_id);
if self.selected_project == Some(operation.project_id) {
self.refresh_git_state();
}
}
fn git_selection(
&self,
include: impl Fn(&GitFile) -> bool,
) -> Option<(i32, PathBuf, Vec<PathBuf>)> {
let project_id = self.selected_project?;
let worktree = self.git_worktrees.get(&project_id)?;
let paths = self.selected_git_paths_matching(project_id, include);
(!paths.is_empty()).then(|| (project_id, worktree.root.clone(), paths))
}
fn selected_git_paths(&self, project_id: i32) -> Vec<PathBuf> {
self.selected_git_paths_matching(project_id, |_| true)
}
fn selected_git_paths_matching(
&self,
project_id: i32,
include: impl Fn(&GitFile) -> bool,
) -> Vec<PathBuf> {
if self.git_selected_project != Some(project_id) {
return Vec::new();
}
self.git_worktrees
.get(&project_id)
.map(|worktree| {
worktree
.files
.iter()
.filter(|file| self.git_selected_files.contains(&file.path) && include(file))
.map(|file| file.path.clone())
.collect()
})
.unwrap_or_default()
}
fn start_git_operation(
&mut self,
project_id: i32,
label: &str,
clear_commit_on_success: bool,
operation: impl FnOnce() -> Result<(), String> + Send + 'static,
) {
if self.git_operation.is_some() {
self.error = Some("Another Git operation is still running.".into());
return;
}
if self.project_has_active_chat(project_id)
&& !matches!(label, "Fetching from origin" | "Pushing to origin")
{
self.error = Some("Stop active chats before changing Git state.".into());
return;
}
let (sender, receiver) = mpsc::channel();
if let Err(error) = thread::Builder::new()
.name("ds4-git".into())
.spawn(move || {
let _ = sender.send(operation());
})
{
self.error = Some(format!("Could not start the Git operation: {error}"));
return;
}
self.git_operation = Some(ActiveGitOperation {
project_id,
label: label.into(),
receiver,
clear_commit_on_success,
});
self.error = None;
}
}
fn read_worktree(project_path: &Path) -> Result<GitWorktree, String> {
let repository = gix::discover(project_path).map_err(|error| error.to_string())?;
let root = repository
.workdir()
.ok_or_else(|| "bare repositories do not have a worktree".to_owned())?
.to_path_buf();
let dirwalk = repository
.dirwalk_options()
.map_err(|error| error.to_string())?;
let mut files = BTreeMap::<PathBuf, GitFile>::new();
let changes = repository
.status(gix::progress::Discard)
.map_err(|error| error.to_string())?
.index_worktree_options_mut(|options| options.dirwalk_options = Some(dirwalk))
.untracked_files(gix::status::UntrackedFiles::Files)
.into_iter(Vec::<gix::bstr::BString>::new())
.map_err(|error| error.to_string())?;
for change in changes {
let change = change.map_err(|error| error.to_string())?;
let path = gix::path::from_bstr(change.location()).into_owned();
let display_path = path.to_string_lossy().into_owned();
let file = files.entry(path.clone()).or_insert_with(|| GitFile {
path,
display_path,
staged_kind: None,
worktree_kind: None,
});
match change {
gix::status::Item::IndexWorktree(change) => {
file.worktree_kind = change.summary().map(worktree_kind);
}
gix::status::Item::TreeIndex(change) => {
file.staged_kind = Some(match change {
gix::diff::index::Change::Addition { .. } => GitChangeKind::Added,
gix::diff::index::Change::Deletion { .. } => GitChangeKind::Deleted,
gix::diff::index::Change::Modification { .. }
| gix::diff::index::Change::Rewrite { .. } => GitChangeKind::Modified,
});
}
}
}
files.retain(|_, file| file.staged_kind.is_some() || file.worktree_kind.is_some());
Ok(GitWorktree {
root,
files: files.into_values().collect(),
})
}
fn worktree_kind(summary: gix::status::index_worktree::iter::Summary) -> GitChangeKind {
use gix::status::index_worktree::iter::Summary;
match summary {
Summary::Added | Summary::IntentToAdd => GitChangeKind::Added,
Summary::Removed => GitChangeKind::Deleted,
Summary::Modified
| Summary::TypeChange
| Summary::Renamed
| Summary::Copied
| Summary::Conflict => GitChangeKind::Modified,
}
}
fn load_diff(root: &Path, file: &GitFile) -> Result<GitDiff, String> {
let mut sections = Vec::new();
if file.staged_kind.is_some() {
let output = run_git_path(
root,
&["diff", "--cached", "--no-ext-diff", "--no-color", "--"],
&file.path,
)?;
sections.push(("Staged changes", output));
}
if file.worktree_kind.is_some() {
let mut output = run_git_path(
root,
&["diff", "--no-ext-diff", "--no-color", "--"],
&file.path,
)?;
if output.is_empty() && file.worktree_kind == Some(GitChangeKind::Added) {
output = run_no_index_diff(root, &file.path)?;
}
sections.push(("Worktree changes", output));
}
let mut lines = Vec::new();
for (label, content) in sections {
lines.push(GitDiffLine {
kind: GitDiffLineKind::Section,
old_number: None,
new_number: None,
text: label.into(),
});
lines.extend(parse_unified_diff(&content));
}
if lines.is_empty() {
lines.push(GitDiffLine {
kind: GitDiffLineKind::Header,
old_number: None,
new_number: None,
text: "No textual diff is available.".into(),
});
}
Ok(GitDiff {
path: file.display_path.clone(),
lines,
})
}
fn parse_unified_diff(diff: &str) -> Vec<GitDiffLine> {
let mut old_number = None;
let mut new_number = None;
diff.lines()
.map(|line| {
let (kind, old, new) = if line.starts_with("@@") {
let mut parts = line.split_whitespace();
let _ = parts.next();
old_number = parts.next().and_then(diff_range_start);
new_number = parts.next().and_then(diff_range_start);
(GitDiffLineKind::Hunk, None, None)
} else if line.starts_with("---") || line.starts_with("+++") {
(GitDiffLineKind::Header, None, None)
} else if line.starts_with('-') {
let current = old_number;
old_number = old_number.map(|number| number.saturating_add(1));
(GitDiffLineKind::Deletion, current, None)
} else if line.starts_with('+') {
let current = new_number;
new_number = new_number.map(|number| number.saturating_add(1));
(GitDiffLineKind::Addition, None, current)
} else if line.starts_with(' ') {
let current_old = old_number;
let current_new = new_number;
old_number = old_number.map(|number| number.saturating_add(1));
new_number = new_number.map(|number| number.saturating_add(1));
(GitDiffLineKind::Context, current_old, current_new)
} else {
(GitDiffLineKind::Header, None, None)
};
GitDiffLine {
kind,
old_number: old,
new_number: new,
text: line.to_owned(),
}
})
.collect()
}
fn diff_range_start(range: &str) -> Option<u32> {
range
.get(1..)?
.split_once(',')
.map_or(range.get(1..), |(start, _)| Some(start))?
.parse()
.ok()
}
fn git_has_head(root: &Path) -> bool {
Command::new("git")
.arg("-C")
.arg(root)
.args(["rev-parse", "--verify", "HEAD"])
.output()
.is_ok_and(|output| output.status.success())
}
fn stage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> {
run_git_paths(root, &["add", "--"], paths).map(|_| ())
}
fn unstage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> {
if git_has_head(root) {
run_git_paths(root, &["restore", "--staged", "--"], paths)?;
} else {
run_git_paths(
root,
&["rm", "--cached", "--quiet", "--ignore-unmatch", "--"],
paths,
)?;
}
Ok(())
}
fn run_git_commit_paths(root: &Path, message: &str, paths: &[PathBuf]) -> Result<(), String> {
let mut command = git_command(root);
command.args(["commit", "-m", message, "--"]);
command.args(paths);
finish_git(command.output(), "commit selected files").map(|_| ())
}
fn run_git_paths(root: &Path, arguments: &[&str], paths: &[PathBuf]) -> Result<String, String> {
let mut command = git_command(root);
command.args(arguments);
command.args(paths);
finish_git(
command.output(),
arguments.first().copied().unwrap_or("run Git"),
)
}
fn run_git_path(root: &Path, arguments: &[&str], path: &Path) -> Result<String, String> {
run_git_paths(root, arguments, &[path.to_path_buf()])
}
fn run_git_args(root: &Path, arguments: &[&str]) -> Result<String, String> {
let mut command = git_command(root);
command.args(arguments);
finish_git(
command.output(),
arguments.first().copied().unwrap_or("run Git"),
)
}
fn run_git_remote(root: &Path, action: &str, branch: Option<&str>) -> Result<(), String> {
let mut command = git_command(root);
match action {
"fetch" => {
command.args(["fetch", "origin"]);
}
"pull" => {
command.args([
"pull",
"--no-rebase",
"origin",
branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?,
]);
}
"push" => {
command.args([
"push",
"origin",
&format!(
"HEAD:{}",
branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?
),
]);
}
_ => return Err("The Git remote action is unavailable.".into()),
}
finish_git(command.output(), action).map(|_| ())
}
fn run_no_index_diff(root: &Path, path: &Path) -> Result<String, String> {
let mut command = git_command(root);
command.args(["diff", "--no-index", "--no-color", "--"]);
command.arg(OsStr::new("/dev/null"));
command.arg(root.join(path));
let output = command
.output()
.map_err(|error| format!("Could not run Git diff: {error}"))?;
if output.status.success() || output.status.code() == Some(1) {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
finish_git(Ok(output), "diff")
}
}
fn git_command(root: &Path) -> Command {
let mut command = Command::new("git");
command.arg("-C").arg(root).env("GIT_TERMINAL_PROMPT", "0");
command
}
fn finish_git(output: std::io::Result<Output>, action: &str) -> Result<String, String> {
let output = output.map_err(|error| format!("Could not {action}: {error}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
Err(if detail.is_empty() {
format!("Could not {action}.")
} else {
format!("Could not {action}: {detail}")
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
#[test]
fn gitoxide_status_merges_staged_and_worktree_changes() {
let directory = repository_fixture();
std::fs::write(directory.join("added.txt"), "added\n").unwrap();
run_git_args(&directory, &["config", "status.showUntrackedFiles", "no"]).unwrap();
std::fs::write(directory.join("changed.txt"), "changed\n").unwrap();
run_git_args(&directory, &["add", "changed.txt"]).unwrap();
std::fs::write(directory.join("changed.txt"), "changed again\n").unwrap();
std::fs::remove_file(directory.join("deleted.txt")).unwrap();
let worktree = read_worktree(&directory).unwrap();
let added = worktree
.files
.iter()
.find(|file| file.display_path == "added.txt")
.unwrap();
assert_eq!(added.worktree_kind, Some(GitChangeKind::Added));
let changed = worktree
.files
.iter()
.find(|file| file.display_path == "changed.txt")
.unwrap();
assert_eq!(changed.staged_kind, Some(GitChangeKind::Modified));
assert_eq!(changed.worktree_kind, Some(GitChangeKind::Modified));
let diff = load_diff(&worktree.root, changed).unwrap();
assert_eq!(
diff.lines
.iter()
.filter(|line| line.kind == GitDiffLineKind::Section)
.count(),
2
);
assert!(
diff.lines
.iter()
.any(|line| line.kind == GitDiffLineKind::Addition)
);
assert!(
diff.lines
.iter()
.any(|line| line.kind == GitDiffLineKind::Deletion)
);
let deleted = worktree
.files
.iter()
.find(|file| file.display_path == "deleted.txt")
.unwrap();
assert_eq!(deleted.worktree_kind, Some(GitChangeKind::Deleted));
let added_path = PathBuf::from("added.txt");
stage_paths(&directory, std::slice::from_ref(&added_path)).unwrap();
let worktree = read_worktree(&directory).unwrap();
let added = worktree
.files
.iter()
.find(|file| file.path == added_path)
.unwrap();
assert_eq!(added.staged_kind, Some(GitChangeKind::Added));
unstage_paths(&directory, std::slice::from_ref(&added_path)).unwrap();
let worktree = read_worktree(&directory).unwrap();
let added = worktree
.files
.iter()
.find(|file| file.path == added_path)
.unwrap();
assert_eq!(added.staged_kind, None);
assert_eq!(added.worktree_kind, Some(GitChangeKind::Added));
stage_paths(&directory, std::slice::from_ref(&added_path)).unwrap();
run_git_commit_paths(
&directory,
"Commit selected file",
std::slice::from_ref(&added_path),
)
.unwrap();
let worktree = read_worktree(&directory).unwrap();
assert!(!worktree.files.iter().any(|file| file.path == added_path));
assert_eq!(
worktree
.files
.iter()
.find(|file| file.display_path == "changed.txt")
.unwrap()
.staged_kind,
Some(GitChangeKind::Modified)
);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn unstage_works_before_the_first_commit() {
let directory = empty_repository_fixture();
std::fs::write(directory.join("new.txt"), "new\n").unwrap();
let path = PathBuf::from("new.txt");
stage_paths(&directory, std::slice::from_ref(&path)).unwrap();
unstage_paths(&directory, std::slice::from_ref(&path)).unwrap();
let worktree = read_worktree(&directory).unwrap();
let file = worktree
.files
.iter()
.find(|file| file.path == path)
.unwrap();
assert_eq!(file.staged_kind, None);
assert_eq!(file.worktree_kind, Some(GitChangeKind::Added));
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn origin_fetch_pull_and_push_use_the_current_branch() {
let local = repository_fixture();
let remote = local.with_extension("remote.git");
let collaborator = local.with_extension("collaborator");
let output = Command::new("git")
.args(["init", "--bare"])
.arg(&remote)
.output()
.unwrap();
assert!(output.status.success());
run_git_args(
&local,
&["remote", "add", "origin", remote.to_str().unwrap()],
)
.unwrap();
run_git_remote(&local, "push", Some("main")).unwrap();
run_git_args(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]).unwrap();
let output = Command::new("git")
.arg("clone")
.arg(&remote)
.arg(&collaborator)
.output()
.unwrap();
assert!(output.status.success());
run_git_args(&collaborator, &["config", "user.name", "DS4Server Test"]).unwrap();
run_git_args(
&collaborator,
&["config", "user.email", "test@ds4server.invalid"],
)
.unwrap();
std::fs::write(collaborator.join("remote.txt"), "from origin\n").unwrap();
run_git_args(&collaborator, &["add", "remote.txt"]).unwrap();
run_git_args(&collaborator, &["commit", "-m", "Remote change"]).unwrap();
run_git_remote(&collaborator, "push", Some("main")).unwrap();
run_git_remote(&local, "fetch", None).unwrap();
run_git_remote(&local, "pull", Some("main")).unwrap();
assert_eq!(
std::fs::read_to_string(local.join("remote.txt")).unwrap(),
"from origin\n"
);
std::fs::write(local.join("local.txt"), "to origin\n").unwrap();
run_git_args(&local, &["add", "local.txt"]).unwrap();
run_git_args(&local, &["commit", "-m", "Local change"]).unwrap();
run_git_remote(&local, "push", Some("main")).unwrap();
assert_eq!(
run_git_args(&remote, &["show", "main:local.txt"])
.unwrap()
.trim(),
"to origin"
);
std::fs::remove_dir_all(local).unwrap();
std::fs::remove_dir_all(remote).unwrap();
std::fs::remove_dir_all(collaborator).unwrap();
}
#[test]
fn unified_diff_parser_aligns_replacements_for_split_view() {
let lines = parse_unified_diff(
"diff --git a/file b/file\n--- a/file\n+++ b/file\n@@ -2,2 +2,2 @@\n same\n-old\n+new\n",
);
assert_eq!(lines[4].old_number, Some(2));
assert_eq!(lines[4].new_number, Some(2));
assert_eq!(lines[5].old_number, Some(3));
assert_eq!(lines[6].new_number, Some(3));
let rows = GitDiff {
path: "file".into(),
lines,
}
.split_rows();
assert!(rows.iter().any(|row| matches!(
row,
GitDiffRow::Pair {
old: Some(old),
new: Some(new),
} if old.kind == GitDiffLineKind::Deletion
&& new.kind == GitDiffLineKind::Addition
)));
}
fn repository_fixture() -> PathBuf {
let directory = empty_repository_fixture();
std::fs::write(directory.join("changed.txt"), "original\n").unwrap();
std::fs::write(directory.join("deleted.txt"), "original\n").unwrap();
run_git_args(&directory, &["add", "changed.txt", "deleted.txt"]).unwrap();
run_git_args(&directory, &["commit", "-m", "Initial"]).unwrap();
directory
}
fn empty_repository_fixture() -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"ds4-server-git-pane-{}-{nonce}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&directory).unwrap();
run_git_args(&directory, &["init", "-b", "main"]).unwrap();
run_git_args(&directory, &["config", "user.name", "DS4Server Test"]).unwrap();
run_git_args(
&directory,
&["config", "user.email", "test@ds4server.invalid"],
)
.unwrap();
directory
}
}

View File

@@ -220,8 +220,17 @@ impl App {
/// Selects a project and stores it as the one to reopen on the next launch.
pub(super) fn remember_project(&mut self, project_id: i32) {
if self.selected_project != Some(project_id) {
self.git_selected_project = None;
self.git_selected_files.clear();
self.git_diff = None;
self.git_commit_all_confirmation = false;
}
self.selected_project = Some(project_id);
self.refresh_git_state();
if self.detail_tab == DetailTab::Git {
self.refresh_git_worktree(project_id);
}
if self.config.interface.last_project_id == Some(project_id) {
return;
}
@@ -273,6 +282,7 @@ impl App {
}
None => {
self.git_states.remove(&project_id);
self.git_worktrees.remove(&project_id);
}
}
}
@@ -281,6 +291,15 @@ impl App {
let Some(project_id) = self.selected_project else {
return;
};
if self
.git_operation
.as_ref()
.is_some_and(|operation| operation.project_id == project_id)
{
self.error =
Some("Wait for the current Git operation before switching branches.".into());
return;
}
if self.project_has_active_chat(project_id) {
self.error = Some("Stop active chats before switching branches.".into());
return;
@@ -304,8 +323,9 @@ impl App {
};
match git_switch(&path, branch) {
Ok(()) => {
self.refresh_git_state();
self.error = None;
self.refresh_git_state();
self.refresh_git_worktree(project_id);
}
Err(error) => self.error = Some(error),
}

View File

@@ -1,5 +1,6 @@
mod a2ui;
mod chat;
mod git;
mod model_manager;
mod preferences;
mod stats;
@@ -102,6 +103,8 @@ impl App {
/// tree, so the layers below have to stay out of the dialog's field order.
pub(super) fn modal_open(&self) -> bool {
self.quit_confirmation
|| self.git_diff.is_some()
|| self.git_commit_all_confirmation
|| self.pending_project_path.is_some()
|| self.pending_session_delete.is_some()
|| self.session_rename.is_some()
@@ -176,6 +179,10 @@ impl App {
layers.push(self.session_menu_panel(session));
} else if let Some(surface_id) = &self.pending_a2ui_dismissal {
layers.push(self.a2ui_dismiss_panel(surface_id));
} else if self.git_commit_all_confirmation {
layers.push(self.git_commit_all_panel());
} else if self.git_diff.is_some() {
layers.push(self.git_diff_panel());
} else if let Some(panel) = self.a2ui_modal_panel() {
layers.push(panel);
}
@@ -192,6 +199,10 @@ impl App {
layers.push(self.session_menu_panel(session));
} else if let Some(surface_id) = &self.pending_a2ui_dismissal {
layers.push(self.a2ui_dismiss_panel(surface_id));
} else if self.git_commit_all_confirmation {
layers.push(self.git_commit_all_panel());
} else if self.git_diff.is_some() {
layers.push(self.git_diff_panel());
} else if let Some(panel) = self.a2ui_modal_panel() {
layers.push(panel);
}
@@ -505,6 +516,7 @@ impl App {
fn detail_tabs(&self) -> Element<'_, Message> {
let chat_active = self.detail_tab == DetailTab::Chat;
let a2ui_active = self.detail_tab == DetailTab::A2ui;
let git_active = self.detail_tab == DetailTab::Git;
let stats_active = self.detail_tab == DetailTab::Stats;
let tabs = container(
row![
@@ -520,6 +532,12 @@ impl App {
.padding([0, 16])
.on_press(Message::ShowA2ui)
.style(move |theme, status| segmented_button_style(theme, status, a2ui_active)),
button(text("Git").size(13))
.width(88)
.height(TITLE_BAR_CONTROL - 4.0)
.padding([0, 16])
.on_press(Message::ShowGit)
.style(move |theme, status| segmented_button_style(theme, status, git_active)),
button(text("Stats").size(13))
.width(88)
.height(TITLE_BAR_CONTROL - 4.0)
@@ -542,6 +560,7 @@ impl App {
match self.detail_tab {
DetailTab::Chat => self.chat_detail(),
DetailTab::A2ui => self.a2ui_detail(),
DetailTab::Git => self.git_detail(),
DetailTab::Stats => self.stats_dashboard(),
}
}

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))
}