Add Git worktree pane
This commit is contained in:
1136
Cargo.lock
generated
1136
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
85
src/app.rs
85
src/app.rs
@@ -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
|
||||
|
||||
@@ -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
930
src/app/git.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
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