mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
feat(agents): review a task's commits in the Changes pane (#2960)
A worktree task's Changes pane only listed the cumulative diff since the task started, so work the agent committed was mixed into the current changes. It now groups the task's work: uncommitted files compared with HEAD, and each commit made since the task started with the files it changed against its first parent. Double-clicking a commit or one of its files opens the parent-to-commit diff; commit files are history and are not rolled back. Rollback in this view restores files to HEAD. Accept still brings over the task's whole work, committed or not. View Options > Group By > Commit switches back to the flat since-start list; tasks in the project's own checkout keep that list. Co-authored-by: panda-student <71381346+panda-student@users.noreply.github.com>
This commit is contained in:
@@ -1343,6 +1343,15 @@ impl AgentManager {
|
||||
blocking(move || at.changes(&before)).await
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and, for a worktree task, the commits made since it
|
||||
/// started (newest first). Both are read against the same `HEAD`.
|
||||
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
|
||||
let task = self.task(id)?;
|
||||
let at = task.base();
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || at.grouped_changes(&before)).await
|
||||
}
|
||||
|
||||
/// Content of `path` (in the task's directory) when the task started.
|
||||
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
|
||||
let at = self.task(id)?.base();
|
||||
@@ -1353,9 +1362,14 @@ impl AgentManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
|
||||
/// were when it started (files the user had changed then, as the user left them).
|
||||
pub async fn rollback_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as
|
||||
/// `view` compares them: as they were when it started, or as they are in `HEAD`.
|
||||
pub async fn rollback_changes(
|
||||
&self,
|
||||
id: &str,
|
||||
rel_paths: Option<Vec<String>>,
|
||||
view: AgentChangesView,
|
||||
) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
|
||||
@@ -1366,8 +1380,10 @@ impl AgentManager {
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
|
||||
};
|
||||
let restamped = blocking(move || {
|
||||
let changes = at.changes(&before)?;
|
||||
let rev = at.rev()?;
|
||||
let (changes, rev) = match view {
|
||||
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
|
||||
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
|
||||
};
|
||||
let sources = match rev.as_deref() {
|
||||
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
|
||||
None => Default::default(),
|
||||
|
||||
@@ -484,7 +484,8 @@ pub struct NewAgentTask {
|
||||
pub base: Option<String>,
|
||||
}
|
||||
|
||||
/// A file the task changed, relative to [`AgentTask::base_commit`].
|
||||
/// A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
|
||||
/// grouped view.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentChange {
|
||||
@@ -493,6 +494,44 @@ pub struct AgentChange {
|
||||
pub kind: ide_vcs::ChangeKind,
|
||||
pub added: u32,
|
||||
pub removed: u32,
|
||||
/// Path before a rename; the left side of its diff reads this path.
|
||||
pub old_path: Option<String>,
|
||||
}
|
||||
|
||||
/// A commit made while the task ran, with the files it changed against its first parent.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommit {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub summary: String,
|
||||
pub message: String,
|
||||
pub time_ms: f64,
|
||||
/// First parent, or `None` for a root commit.
|
||||
pub parent: Option<String>,
|
||||
pub files: Vec<AgentChange>,
|
||||
}
|
||||
|
||||
/// Which comparison a rollback of the task's changes uses.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentChangesView {
|
||||
/// Everything since the commit the task started from.
|
||||
#[default]
|
||||
SinceStart,
|
||||
/// The uncommitted files against `HEAD`; commits made since the start stay.
|
||||
Group,
|
||||
}
|
||||
|
||||
/// A task's uncommitted files and the commits made since it started.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentChanges {
|
||||
/// Files changed against `HEAD`, less those changed before the task started and untouched since.
|
||||
pub uncommitted: Vec<AgentChange>,
|
||||
/// Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty
|
||||
/// for tasks working in the project's own checkout.
|
||||
pub commits: Vec<AgentCommit>,
|
||||
}
|
||||
|
||||
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
|
||||
|
||||
@@ -2853,14 +2853,14 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
}
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]);
|
||||
|
||||
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()]), AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(read("other.txt"), "base\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]);
|
||||
|
||||
// After a restart, from the saved copy.
|
||||
mgr.shutdown();
|
||||
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
|
||||
assert!(!dir.join("new.txt").exists());
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
@@ -2973,7 +2973,7 @@ async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
|
||||
|
||||
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
|
||||
assert_eq!(base.map(|b| b.replace("\r\n", "\n")).as_deref(), Some("base\n"), "{eol:?}");
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}: the user's work survives the rollback");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
@@ -2996,7 +2996,7 @@ async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_ba
|
||||
repo.git(&["reset", "-q", "HEAD~1"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine-new.txt"), "my new file\n", "{eol:?}");
|
||||
@@ -3023,7 +3023,7 @@ async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
|
||||
repo.git(&["checkout", "-q", "feature"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("other.txt"), "feature\n", "{eol:?}");
|
||||
assert_eq!(repo.read("only-feature.txt"), "feature\n", "{eol:?}");
|
||||
@@ -3048,7 +3048,7 @@ async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
|
||||
repo.write("a.txt", "agent\nagain\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to before it");
|
||||
assert!(!repo.dir.join("b.txt").exists(), "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
@@ -3069,7 +3069,7 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
|
||||
let listed = changed(&mgr, &task.id).await;
|
||||
assert_eq!(listed, ["CASE.java", "New.java"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, Some(listed)).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, Some(listed), AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("Old.java"), "class Old {}\n", "{eol:?}");
|
||||
assert!(!repo.dir.join("New.java").exists(), "{eol:?}");
|
||||
assert_eq!(repo.read("Case.java"), "class Case {}\n", "{eol:?}");
|
||||
@@ -3082,6 +3082,27 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_group_view_rolls_back_only_uncommitted_files() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.commit("init");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
repo.commit("agent work");
|
||||
repo.write("a.txt", "agent\nagain\n");
|
||||
|
||||
let grouped = mgr.grouped_changes(&task.id).await.unwrap();
|
||||
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"], "{eol:?}");
|
||||
assert!(grouped.commits.is_empty(), "{eol:?}: local tasks have no commits to group");
|
||||
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::Group).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to the start");
|
||||
assert!(mgr.grouped_changes(&task.id).await.unwrap().uncommitted.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
|
||||
for eol in EOLS {
|
||||
@@ -3092,7 +3113,7 @@ async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_c
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"], "{eol:?}");
|
||||
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None, "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert!(!repo.dir.join("new.txt").exists(), "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "mine\n", "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Task worktrees and the changes made in them.
|
||||
|
||||
use crate::model::AgentChange;
|
||||
use crate::model::{AgentChange, AgentChanges, AgentCommit};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use ide_vcs::{ChangeKind, LogQuery, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
@@ -35,6 +35,14 @@ pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> {
|
||||
Ok((info.head.unwrap_or_else(|| commit.clone()), commit))
|
||||
}
|
||||
|
||||
/// `HEAD`'s commit in `cwd`, `None` outside a repository or before its first commit.
|
||||
pub(crate) fn head_commit(cwd: &Path) -> IdeResult<Option<String>> {
|
||||
match discover(cwd)? {
|
||||
Some(repo) => Ok(repo.head()?.1),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// `git worktree add -b agent/<slug> <dir>/<slug> <base>`.
|
||||
pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult<Created> {
|
||||
let repo = repo_at(project_root)?;
|
||||
@@ -111,7 +119,14 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let (added, removed) = stat(&f.path);
|
||||
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
|
||||
AgentChange {
|
||||
path: IpcPath(root.join(&f.path)),
|
||||
rel_path: f.path,
|
||||
kind: f.kind,
|
||||
added,
|
||||
removed,
|
||||
old_path: f.old_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
|
||||
@@ -140,6 +155,7 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
|
||||
kind: ChangeKind::Deleted,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
old_path: None,
|
||||
};
|
||||
Some((f.path, source))
|
||||
})
|
||||
@@ -148,7 +164,57 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
|
||||
|
||||
fn added(path: IpcPath, rel_path: String) -> AgentChange {
|
||||
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
|
||||
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
|
||||
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0, old_path: None }
|
||||
}
|
||||
|
||||
/// The commits from `base` (exclusive) to `head` (inclusive) in `cwd`, newest first, each
|
||||
/// with the files it changed against its first parent and their line counts.
|
||||
pub(crate) fn commits(cwd: &Path, base: &str, head: &str) -> IdeResult<Vec<AgentCommit>> {
|
||||
if base == head {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
|
||||
let root = repo.root().to_path_buf();
|
||||
let query = LogQuery { range: Some(format!("{base}..{head}")), ..LogQuery::default() };
|
||||
let mut out = Vec::new();
|
||||
for commit in repo.log(&query)? {
|
||||
let parent = commit.parents.first().cloned();
|
||||
// A root commit has no parent to count lines against; its files still show.
|
||||
let stats: std::collections::HashMap<String, (u32, u32)> = match &parent {
|
||||
Some(parent) => repo
|
||||
.diff_numstat(parent, &commit.id, &[])?
|
||||
.into_iter()
|
||||
.map(|s| (s.path, (s.added, s.removed)))
|
||||
.collect(),
|
||||
None => std::collections::HashMap::new(),
|
||||
};
|
||||
let mut files: Vec<AgentChange> = repo
|
||||
.commit_files(&commit.id)?
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let (added, removed) = stats.get(&f.path).copied().unwrap_or((0, 0));
|
||||
AgentChange {
|
||||
path: IpcPath(root.join(&f.path)),
|
||||
rel_path: f.path,
|
||||
kind: f.kind,
|
||||
added,
|
||||
removed,
|
||||
old_path: f.old_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
out.push(AgentCommit {
|
||||
id: commit.id,
|
||||
short_id: commit.short_id,
|
||||
summary: commit.summary,
|
||||
message: commit.message,
|
||||
time_ms: commit.time_ms,
|
||||
parent,
|
||||
files,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether two paths name one file (a case-insensitive disk resolving both spellings).
|
||||
@@ -243,6 +309,34 @@ impl TaskBase {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and, for a worktree task, the commits made since it
|
||||
/// started (newest first). Both read the same `HEAD`, so a commit landing between the two
|
||||
/// reads cannot put a file in both lists.
|
||||
pub(crate) fn grouped_changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<AgentChanges> {
|
||||
let cwd = self.cwd();
|
||||
let Some(repo) = discover(cwd)? else { return Ok(AgentChanges::default()) };
|
||||
let head = repo.head()?.1;
|
||||
let uncommitted = match &head {
|
||||
Some(head) => without_untouched(changes(cwd, head)?, before),
|
||||
None => {
|
||||
// No commits yet: every file is new, as in `changes` for a local task.
|
||||
let mut all: Vec<AgentChange> = repo
|
||||
.status()?
|
||||
.into_iter()
|
||||
.filter(|s| s.path.is_file())
|
||||
.map(|s| added(s.path, s.rel_path))
|
||||
.collect();
|
||||
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
without_untouched(all, before)
|
||||
}
|
||||
};
|
||||
let commits = match (self, &head) {
|
||||
(Self::Worktree { cwd, base }, Some(head)) => commits(cwd, base, head)?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(AgentChanges { uncommitted, commits })
|
||||
}
|
||||
|
||||
/// The revision the task's changed files go back to on rollback, and are compared with:
|
||||
/// the commit the task started from, or the current `HEAD` once the user committed on top
|
||||
/// of it, so those commits stay. `None` while the repository has no commits.
|
||||
@@ -598,6 +692,88 @@ mod tests {
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_since_the_base_are_listed_with_their_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
|
||||
assert!(commits(&created.path, &created.base_commit, &created.base_commit).unwrap().is_empty());
|
||||
|
||||
std::fs::write(created.path.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
git(&created.path, &["add", "."]);
|
||||
git(&created.path, &["commit", "-q", "-m", "change a"]);
|
||||
std::fs::write(created.path.join("b.txt"), "b\n").unwrap();
|
||||
git(&created.path, &["add", "."]);
|
||||
git(&created.path, &["commit", "-q", "-m", "add b"]);
|
||||
|
||||
let head = head_of(&created.path).unwrap().1;
|
||||
let log = commits(&created.path, &created.base_commit, &head).unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.summary.as_str()).collect::<Vec<_>>(), ["add b", "change a"]);
|
||||
assert_eq!(log[0].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["b.txt"]);
|
||||
assert_eq!((log[0].files[0].added, log[0].files[0].removed), (1, 0));
|
||||
assert_eq!(log[1].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
|
||||
assert_eq!((log[1].files[0].added, log[1].files[0].removed), (1, 0));
|
||||
assert!(log.iter().all(|c| c.parent.is_some() && !c.message.is_empty()));
|
||||
|
||||
// Nothing is uncommitted after the commits; editing a committed file brings it back.
|
||||
let at = TaskBase::Worktree { cwd: created.path.clone(), base: created.base_commit.clone() };
|
||||
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
|
||||
assert!(grouped.uncommitted.is_empty());
|
||||
assert_eq!(grouped.commits.len(), 2);
|
||||
std::fs::write(created.path.join("a.txt"), "one\ntwo\nthree\n").unwrap();
|
||||
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
|
||||
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
|
||||
assert_eq!(grouped.commits.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_in_a_commit_keeps_its_old_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
let base = head_of(&repo).unwrap().1;
|
||||
|
||||
std::fs::rename(repo.join("a.txt"), repo.join("b.txt")).unwrap();
|
||||
std::fs::write(repo.join("b.txt"), "one\ntwo\nthree\nfour\nFIVE\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "rename a"]);
|
||||
|
||||
let head = head_of(&repo).unwrap().1;
|
||||
let log = commits(&repo, &base, &head).unwrap();
|
||||
assert_eq!(log.len(), 1);
|
||||
assert_eq!(log[0].files.len(), 1);
|
||||
assert_eq!(log[0].files[0].rel_path, "b.txt");
|
||||
assert_eq!(log[0].files[0].old_path.as_deref(), Some("a.txt"));
|
||||
assert_eq!(log[0].files[0].kind, ChangeKind::Renamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_task_has_no_commits() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
|
||||
let at = TaskBase::Local { cwd: repo.clone(), start: None };
|
||||
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
|
||||
assert!(grouped.commits.is_empty());
|
||||
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -5,9 +5,9 @@ use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
|
||||
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
|
||||
NewAgentTask,
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
|
||||
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
|
||||
McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -304,16 +304,28 @@ pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<A
|
||||
host.project(window)?.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and the commits made since it started.
|
||||
pub async fn grouped_changes(host: &Host, window: &str, task: String) -> IdeResult<AgentChanges> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.grouped_changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
|
||||
pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them.
|
||||
pub async fn rollback_changes(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
task: String,
|
||||
paths: Option<Vec<String>>,
|
||||
view: Option<AgentChangesView>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback_changes(&task, paths).await
|
||||
host.project(window)?.agents.rollback_changes(&task, paths, view.unwrap_or_default()).await
|
||||
}
|
||||
|
||||
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
@@ -476,9 +488,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await },
|
||||
);
|
||||
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_grouped_changes", |c| async move { grouped_changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
|
||||
d.add("agent_rollback_changes", |c| async move {
|
||||
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?, c.arg("view")?).await
|
||||
});
|
||||
d.add("agent_accept_changes", |c| async move {
|
||||
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
use crate::state::AppState;
|
||||
use ide_agent::registry::AgentRegistry;
|
||||
use ide_agent::{
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
|
||||
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpServer, McpServerInput, NewAgentTask,
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
|
||||
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
|
||||
McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeResult, IpcPath, RequestId};
|
||||
use ide_host::ops::agent;
|
||||
@@ -370,7 +371,18 @@ pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: Str
|
||||
agent::changes(&state.host, window.label(), task).await
|
||||
}
|
||||
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
|
||||
/// The task's uncommitted changes and the commits made since it started.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_grouped_changes(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
) -> IdeResult<AgentChanges> {
|
||||
agent::grouped_changes(&state.host, window.label(), task).await
|
||||
}
|
||||
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_rollback_changes(
|
||||
@@ -378,8 +390,9 @@ pub async fn agent_rollback_changes(
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
paths: Option<Vec<String>>,
|
||||
view: Option<AgentChangesView>,
|
||||
) -> IdeResult<()> {
|
||||
agent::rollback_changes(&state.host, window.label(), task, paths).await
|
||||
agent::rollback_changes(&state.host, window.label(), task, paths, view).await
|
||||
}
|
||||
|
||||
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
|
||||
@@ -154,6 +154,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
agent::agent_rename,
|
||||
agent::agent_delete,
|
||||
agent::agent_changes,
|
||||
agent::agent_grouped_changes,
|
||||
agent::agent_base_text,
|
||||
agent::agent_rollback_changes,
|
||||
agent::agent_accept_changes,
|
||||
|
||||
+44
-3
@@ -214,10 +214,16 @@ export const commands = {
|
||||
agentRename: (task: string, title: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rename", { task, title })),
|
||||
agentDelete: (task: string, removeWorktree: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_delete", { task, removeWorktree })),
|
||||
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
|
||||
/** The task's uncommitted changes and the commits made since it started. */
|
||||
agentGroupedChanges: (task: string) => typedError<AgentChanges, IdeError>(__TAURI_INVOKE("agent_grouped_changes", { task })),
|
||||
/** Content of `path` when the task started (left side of its diff). */
|
||||
agentBaseText: (task: string, path: IpcPath) => typedError<string | null, IdeError>(__TAURI_INVOKE("agent_base_text", { task, path })),
|
||||
/** Rollback: the task's changed files among `paths` (all without) back as they were when it started. */
|
||||
agentRollbackChanges: (task: string, paths: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rollback_changes", { task, paths })),
|
||||
/** Rollback: the task's changed files among `paths` (all without) back as `view` compares them. */
|
||||
agentRollbackChanges: (task: string, paths: string[] | null, view:
|
||||
/** Everything since the commit the task started from. */
|
||||
"sinceStart" |
|
||||
/** The uncommitted files against `HEAD`; commits made since the start stay. */
|
||||
"group" | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rollback_changes", { task, paths, view })),
|
||||
/**
|
||||
* Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
* local checkout, uncommitted; files the checkout changed itself are left alone and reported.
|
||||
@@ -1737,15 +1743,38 @@ export type AgentCapabilities = {
|
||||
backgroundTasks?: boolean,
|
||||
};
|
||||
|
||||
/** A file the task changed, relative to [`AgentTask::base_commit`]. */
|
||||
/**
|
||||
* A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
|
||||
* grouped view.
|
||||
*/
|
||||
export type AgentChange = {
|
||||
path: IpcPath,
|
||||
relPath: string,
|
||||
kind: ChangeKind,
|
||||
added: number,
|
||||
removed: number,
|
||||
/** Path before a rename; the left side of its diff reads this path. */
|
||||
oldPath: string | null,
|
||||
};
|
||||
|
||||
/** A task's uncommitted files and the commits made since it started. */
|
||||
export type AgentChanges = {
|
||||
/** Files changed against `HEAD`, less those changed before the task started and untouched since. */
|
||||
uncommitted: AgentChange[],
|
||||
/**
|
||||
* Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty
|
||||
* for tasks working in the project's own checkout.
|
||||
*/
|
||||
commits: AgentCommit[],
|
||||
};
|
||||
|
||||
/** Which comparison a rollback of the task's changes uses. */
|
||||
export type AgentChangesView =
|
||||
/** Everything since the commit the task started from. */
|
||||
"sinceStart" |
|
||||
/** The uncommitted files against `HEAD`; commits made since the start stay. */
|
||||
"group";
|
||||
|
||||
export type AgentCommand = {
|
||||
/** Without the leading `/`. */
|
||||
name: string,
|
||||
@@ -1760,6 +1789,18 @@ export type AgentCommandReadOnlyReason = "packageRunner" | "registry" | "customA
|
||||
|
||||
export type AgentCommandSource = "command" | "skill";
|
||||
|
||||
/** A commit made while the task ran, with the files it changed against its first parent. */
|
||||
export type AgentCommit = {
|
||||
id: string,
|
||||
shortId: string,
|
||||
summary: string,
|
||||
message: string,
|
||||
timeMs: number | null,
|
||||
/** First parent, or `None` for a root commit. */
|
||||
parent: string | null,
|
||||
files: AgentChange[],
|
||||
};
|
||||
|
||||
/** A setting chosen for a new task, by [`AgentConfigOption::id`]. */
|
||||
export type AgentConfigChoice = {
|
||||
id: string,
|
||||
|
||||
@@ -1102,6 +1102,47 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Group and commit rows of the grouped Changes pane. */
|
||||
.changeGroup {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.changeGroupName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.changeCommit {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.changeCommitHash {
|
||||
flex: none;
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.changeCommitSummary {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.changeCommitTime {
|
||||
flex: none;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
.added {
|
||||
color: var(--vcs-added);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import clsx from "clsx";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type {
|
||||
AgentChange,
|
||||
AgentCommit,
|
||||
AgentEntry,
|
||||
AgentImage,
|
||||
AgentLimit,
|
||||
@@ -57,7 +58,9 @@ import {
|
||||
} from "@/ui";
|
||||
import { useTx, tx } from "@/platform/i18n";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
import { openDiff, withChain, type DiffData } from "@/features/vcs/diffInput";
|
||||
import { commitFileDiffData, openDiff, withChain, type DiffData } from "@/features/vcs/diffInput";
|
||||
import { repositoryPath } from "@/features/vcs/repositoryPaths";
|
||||
import { setChangesOptions, useChangesOptions } from "./changesOptions";
|
||||
import { Composer } from "./Composer";
|
||||
import { selectionIn } from "./selection";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
@@ -112,6 +115,7 @@ import {
|
||||
markVisible,
|
||||
refreshChanges,
|
||||
rollbackChanges,
|
||||
scheduleChanges,
|
||||
editQueued,
|
||||
forkTask,
|
||||
rerunFrom,
|
||||
@@ -136,7 +140,13 @@ export function SessionEditor({ input, docked = false }: { input: EditorInput; d
|
||||
function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
const task = useAgents((s) => s.tasks.get(id));
|
||||
const entries = useAgents((s) => s.entries.get(id));
|
||||
const changes = useAgents((s) => s.changes.get(id));
|
||||
// Grouping needs commits of the task's own: local checkouts keep the flat list.
|
||||
const isolation = useAgents((s) => s.tasks.get(id)?.isolation);
|
||||
const grouped = useChangesOptions((s) => s.groupByCommit) && isolation === "worktree";
|
||||
const sinceStart = useAgents((s) => s.changes.get(id));
|
||||
const review = useAgents((s) => s.groupedChanges.get(id));
|
||||
const changes = grouped ? (review?.uncommitted ?? null) : (sinceStart ?? null);
|
||||
const commits = grouped ? (review?.commits ?? []) : [];
|
||||
const project = useProject((s) => s.project);
|
||||
const head = useLocalHead(
|
||||
task?.isolation === "local" ? task.status : null,
|
||||
@@ -161,6 +171,11 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
if (!useAgents.getState().entries.has(id)) void loadEntries(id).catch(() => undefined);
|
||||
void refreshChanges(id);
|
||||
}, [id, connected]);
|
||||
// A commit in the task's worktree moves its HEAD: pick it up without waiting for the agent.
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
return onIdeEvent("vcsChanged", () => scheduleChanges(id));
|
||||
}, [id, connected]);
|
||||
// A kept-alive tab stays mounted behind the shown one: it isn't read there, nor searched.
|
||||
const shown = useEditorShown();
|
||||
useEffect(() => {
|
||||
@@ -224,6 +239,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
entries={entries ?? null}
|
||||
head={head}
|
||||
changes={changes ?? null}
|
||||
commits={commits}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={() => setChangesOpen(!changesOpen)}
|
||||
onOpenSubagent={openSubagent}
|
||||
@@ -235,6 +251,8 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
<ChangesPane
|
||||
task={task}
|
||||
changes={changes ?? null}
|
||||
commits={commits}
|
||||
grouped={grouped}
|
||||
head={head}
|
||||
docked={docked}
|
||||
onHide={() => setChangesOpen(false)}
|
||||
@@ -2410,6 +2428,7 @@ function Tray({
|
||||
task,
|
||||
entries,
|
||||
changes,
|
||||
commits,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
children,
|
||||
@@ -2417,6 +2436,7 @@ function Tray({
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
changes: AgentChange[] | null;
|
||||
commits: readonly AgentCommit[];
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
children: ReactNode;
|
||||
@@ -2434,7 +2454,7 @@ function Tray({
|
||||
return (
|
||||
<div className={styles.tray}>
|
||||
{pending && <PendingPermission task={task} entry={pending} />}
|
||||
{(planShown || !!changes?.length) && (
|
||||
{(planShown || !!changes?.length || !!commits.length) && (
|
||||
<div className={styles.trayStrip}>
|
||||
{planShown && (
|
||||
<button
|
||||
@@ -2458,6 +2478,12 @@ function Tray({
|
||||
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
|
||||
</button>
|
||||
)}
|
||||
{!!commits.length && (
|
||||
<button type="button" className={styles.trayChip} aria-pressed={changesOpen} onClick={onToggleChanges}>
|
||||
<Icon name="commit" size={13} />
|
||||
{tx(commits.length === 1 ? "1 commit" : "{count} commits").replace("{count}", String(commits.length))}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{planShown && planOpen && (
|
||||
@@ -2571,6 +2597,7 @@ function SessionComposer({
|
||||
entries,
|
||||
head,
|
||||
changes,
|
||||
commits,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
onOpenSubagent,
|
||||
@@ -2579,6 +2606,7 @@ function SessionComposer({
|
||||
entries: AgentEntry[] | null;
|
||||
head: string | null;
|
||||
changes: AgentChange[] | null;
|
||||
commits: readonly AgentCommit[];
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
@@ -2637,7 +2665,14 @@ function SessionComposer({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Tray task={task} entries={entries} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges}>
|
||||
<Tray
|
||||
task={task}
|
||||
entries={entries}
|
||||
changes={changes}
|
||||
commits={commits}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={onToggleChanges}
|
||||
>
|
||||
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
|
||||
<BackgroundTasks task={task} />
|
||||
{task.queue.length > 0 && <Queue task={task} />}
|
||||
@@ -2796,25 +2831,115 @@ function useChangesOpen(
|
||||
return [chosen ?? (changes?.length ?? 0) > 0, set];
|
||||
}
|
||||
|
||||
const NO_SELECTION: ReadonlySet<string> = new Set();
|
||||
|
||||
/** A row of the Changes pane: a group, a commit, or a file. */
|
||||
type ChangeRow =
|
||||
| {
|
||||
key: string;
|
||||
type: "group";
|
||||
label: "Uncommitted" | "Committed";
|
||||
count: number;
|
||||
added: number;
|
||||
removed: number;
|
||||
children: ChangeRow[];
|
||||
}
|
||||
| { key: string; type: "commit"; commit: AgentCommit; children: ChangeRow[] }
|
||||
| { key: string; type: "file"; change: AgentChange; commit?: AgentCommit };
|
||||
|
||||
function statsOf(changes: readonly AgentChange[]) {
|
||||
return changes.reduce((n, c) => ({ added: n.added + c.added, removed: n.removed + c.removed }), {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function changeRows(
|
||||
uncommitted: readonly AgentChange[],
|
||||
commits: readonly AgentCommit[],
|
||||
grouped: boolean,
|
||||
): ChangeRow[] {
|
||||
if (!grouped) return uncommitted.map((change) => ({ key: change.path, type: "file" as const, change }));
|
||||
const roots: ChangeRow[] = [];
|
||||
if (uncommitted.length) {
|
||||
roots.push({
|
||||
key: "group:uncommitted",
|
||||
type: "group",
|
||||
label: "Uncommitted",
|
||||
count: uncommitted.length,
|
||||
...statsOf(uncommitted),
|
||||
children: uncommitted.map((change) => ({ key: `uncommitted:${change.path}`, type: "file", change })),
|
||||
});
|
||||
}
|
||||
if (commits.length) {
|
||||
const files = commits.flatMap((commit) => commit.files);
|
||||
roots.push({
|
||||
key: "group:committed",
|
||||
type: "group",
|
||||
label: "Committed",
|
||||
count: commits.length,
|
||||
...statsOf(files),
|
||||
children: commits.map((commit) => ({
|
||||
key: `commit:${commit.id}`,
|
||||
type: "commit",
|
||||
commit,
|
||||
children: commit.files.map((change) => ({
|
||||
key: `commit:${commit.id}:${change.path}`,
|
||||
type: "file",
|
||||
change,
|
||||
commit,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function ChangesPane({
|
||||
task,
|
||||
changes,
|
||||
commits,
|
||||
grouped,
|
||||
head,
|
||||
docked,
|
||||
onHide,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
changes: AgentChange[] | null;
|
||||
commits: readonly AgentCommit[];
|
||||
grouped: boolean;
|
||||
head: string | null;
|
||||
docked: boolean;
|
||||
onHide: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
// The two views key their rows differently: a selection doesn't carry over.
|
||||
const [selection, setSelection] = useState<{ grouped: boolean; keys: ReadonlySet<string> }>(() => ({
|
||||
grouped,
|
||||
keys: NO_SELECTION,
|
||||
}));
|
||||
const selected = selection.grouped === grouped ? selection.keys : NO_SELECTION;
|
||||
const setSelected = (keys: ReadonlySet<string>) => setSelection({ grouped, keys });
|
||||
const rows = useMemo(() => changeRows(changes ?? [], commits, grouped), [changes, commits, grouped]);
|
||||
// Only the working files roll back; a commit's files are history.
|
||||
const rollbackable = useMemo(() => {
|
||||
const out = new Map<string, AgentChange>();
|
||||
for (const root of rows) {
|
||||
if (root.type === "file") {
|
||||
if (!root.commit) out.set(root.key, root.change);
|
||||
continue;
|
||||
}
|
||||
for (const child of root.children) {
|
||||
if (child.type === "file" && !child.commit) out.set(child.key, child.change);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [rows]);
|
||||
// Selection keys that no longer name a working file (a commit's file, a stale row) don't count.
|
||||
const picked = [...selected].flatMap((key) => rollbackable.get(key) ?? []);
|
||||
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
|
||||
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
|
||||
const rollback = async () => {
|
||||
const chosen = (changes ?? []).filter((c) => selected.has(c.path));
|
||||
const count = chosen.length || changes?.length || 0;
|
||||
const count = picked.length || changes?.length || 0;
|
||||
if (!count) return;
|
||||
const ok = await confirm({
|
||||
title: "Rollback Changes",
|
||||
@@ -2822,15 +2947,17 @@ function ChangesPane({
|
||||
<>
|
||||
<p>
|
||||
{count === 1
|
||||
? tx`Roll back the changes to ${basename((chosen[0] ?? changes![0]!).path)}?`
|
||||
? tx`Roll back the changes to ${basename((picked[0] ?? changes![0]!).path)}?`
|
||||
: tx`Roll back the changes to ${count} files?`}
|
||||
</p>
|
||||
<p>
|
||||
{task.isolation === "worktree"
|
||||
? tx("They go back to how they were when the task started, in the task's worktree.")
|
||||
: tx(
|
||||
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.",
|
||||
)}
|
||||
{grouped
|
||||
? tx("They go back to the last commit; changes made after it are lost.")
|
||||
: task.isolation === "worktree"
|
||||
? tx("They go back to how they were when the task started, in the task's worktree.")
|
||||
: tx(
|
||||
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
@@ -2839,7 +2966,11 @@ function ChangesPane({
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await rollbackChanges(task.id, chosen.length ? chosen.map((c) => c.relPath) : null);
|
||||
await rollbackChanges(
|
||||
task.id,
|
||||
picked.length ? picked.map((c) => c.relPath) : null,
|
||||
grouped ? "group" : "sinceStart",
|
||||
);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
@@ -2850,10 +2981,15 @@ function ChangesPane({
|
||||
}
|
||||
};
|
||||
const accept = async () => {
|
||||
const chosen = (changes ?? []).filter((c) => selected.has(c.path));
|
||||
const count = chosen.length || changes?.length || 0;
|
||||
// Accepting brings over the task's whole work, committed or not: grouping only changes the listing.
|
||||
const all =
|
||||
grouped && !picked.length
|
||||
? await call(commands.agentChanges(task.id)).catch(() => changes ?? [])
|
||||
: (changes ?? []);
|
||||
const targets = picked.length ? picked : all;
|
||||
const count = targets.length;
|
||||
if (!count) return;
|
||||
const name = basename((chosen[0] ?? changes![0]!).path);
|
||||
const name = basename(targets[0]!.path);
|
||||
const ok = await confirm({
|
||||
title: "Accept Changes",
|
||||
message: (
|
||||
@@ -2871,7 +3007,7 @@ function ChangesPane({
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const outcome = await acceptChanges(task.id, chosen.length ? chosen.map((c) => c.relPath) : null);
|
||||
const outcome = await acceptChanges(task.id, picked.length ? picked.map((c) => c.relPath) : null);
|
||||
setSelected(new Set());
|
||||
if (outcome.skipped.length) {
|
||||
const skippedNames = outcome.skipped.map((s) => s.path).join(", ");
|
||||
@@ -2907,23 +3043,44 @@ function ChangesPane({
|
||||
{task.isolation === "worktree" && (
|
||||
<IconButton
|
||||
icon="check"
|
||||
title={selected.size ? "Accept Selected Changes…" : "Accept All Changes…"}
|
||||
disabled={!changes?.length || task.status === "starting" || task.status === "running"}
|
||||
title={picked.length ? "Accept Selected Changes…" : "Accept All Changes…"}
|
||||
disabled={(!changes?.length && !commits.length) || task.status === "starting" || task.status === "running"}
|
||||
onClick={() => void accept()}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon="revert"
|
||||
title={selected.size ? "Rollback Selected Changes…" : "Rollback All Changes…"}
|
||||
title={picked.length ? "Rollback Selected Changes…" : "Rollback All Changes…"}
|
||||
disabled={!changes?.length || task.status === "starting" || task.status === "running"}
|
||||
onClick={() => void rollback()}
|
||||
/>
|
||||
<IconButton icon="refresh" title="Refresh" onClick={() => void refreshChanges(task.id)} />
|
||||
{task.isolation === "worktree" && (
|
||||
<IconButton
|
||||
icon="eye"
|
||||
title="View Options"
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) =>
|
||||
showContextMenu(e, [
|
||||
{ kind: "header", label: "Group By" },
|
||||
{
|
||||
id: "groupByCommit",
|
||||
label: "Commit",
|
||||
checked: grouped,
|
||||
run: () => {
|
||||
setChangesOptions({ groupByCommit: !grouped });
|
||||
void refreshChanges(task.id);
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!docked && <IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />}
|
||||
</div>
|
||||
{(head ?? task.base) && (
|
||||
{(grouped || head || task.base) && (
|
||||
<div className={styles.changesSummary}>
|
||||
<T>vs</T> {head ?? task.base}
|
||||
<T>vs</T> {grouped ? "HEAD" : (head ?? task.base)}
|
||||
{changes && changes.length > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -2936,29 +3093,81 @@ function ChangesPane({
|
||||
{changes === null ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Tree<AgentChange>
|
||||
<Tree<ChangeRow>
|
||||
key={grouped ? "grouped" : "flat"}
|
||||
aria-label="Changed files"
|
||||
roots={changes}
|
||||
getKey={(c) => c.path}
|
||||
getLabel={(c) => basename(c.path)}
|
||||
getChildren={() => undefined}
|
||||
isExpandable={() => false}
|
||||
roots={rows}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) =>
|
||||
n.type === "file" ? basename(n.change.path) : n.type === "commit" ? n.commit.summary : n.label
|
||||
}
|
||||
getChildren={(n) => (n.type === "file" ? undefined : n.children)}
|
||||
isExpandable={(n) => n.type !== "file" && n.children.length > 0}
|
||||
defaultExpanded={grouped ? ["group:uncommitted", "group:committed"] : undefined}
|
||||
activateExpandable={(n) => n.type === "commit"}
|
||||
selected={selected}
|
||||
onSelectionChange={(sel) => setSelected(sel)}
|
||||
onActivate={(c) => void openChangeDiff(task, c.path)}
|
||||
renderNode={(c) => (
|
||||
<span className={styles.change} title={c.relPath}>
|
||||
<Icon name={fileIconName(basename(c.path), c.path)} size={16} />
|
||||
<span className={styles.changeName} style={{ color: KIND_COLOR[c.kind] }}>
|
||||
{basename(c.path)}
|
||||
onSelectionChange={(sel, lead) => {
|
||||
if (lead?.type === "file" && !lead.commit) setSelected(sel);
|
||||
}}
|
||||
onActivate={(n) => {
|
||||
if (n.type === "file") {
|
||||
if (n.commit) void openCommitDiff(task, n.commit, n.change.path);
|
||||
else if (grouped) void openUncommittedDiff(task, n.change.path);
|
||||
else void openChangeDiff(task, n.change.path);
|
||||
} else if (n.type === "commit") {
|
||||
void openCommitDiff(task, n.commit, n.commit.files[0]?.path ?? "");
|
||||
}
|
||||
}}
|
||||
renderNode={(n) => {
|
||||
if (n.type === "group") {
|
||||
return (
|
||||
<span className={styles.changeGroup}>
|
||||
<span className={styles.changeGroupName}>
|
||||
{n.label === "Uncommitted" ? <T>Uncommitted</T> : <T>Committed</T>}
|
||||
</span>
|
||||
<span className={styles.muted}>
|
||||
{n.label === "Committed"
|
||||
? tx(n.count === 1 ? "1 commit" : "{count} commits").replace("{count}", String(n.count))
|
||||
: `${n.count} ${tx(n.count === 1 ? "file" : "files")}`}
|
||||
</span>
|
||||
<span className={styles.changeStat}>
|
||||
{n.added > 0 && <span className={styles.added}>+{n.added}</span>}{" "}
|
||||
{n.removed > 0 && <span className={styles.removed}>−{n.removed}</span>}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (n.type === "commit") {
|
||||
const stats = statsOf(n.commit.files);
|
||||
return (
|
||||
<span className={styles.changeCommit} title={n.commit.message}>
|
||||
<Icon name="commit" size={14} />
|
||||
<span className={styles.changeCommitHash}>{n.commit.shortId}</span>
|
||||
<span className={styles.changeCommitSummary}>{n.commit.summary}</span>
|
||||
{n.commit.timeMs != null && (
|
||||
<span className={styles.changeCommitTime}>{formatSentAt(n.commit.timeMs)}</span>
|
||||
)}
|
||||
<span className={styles.changeStat}>
|
||||
{stats.added > 0 && <span className={styles.added}>+{stats.added}</span>}{" "}
|
||||
{stats.removed > 0 && <span className={styles.removed}>−{stats.removed}</span>}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={styles.change} title={n.change.relPath}>
|
||||
<Icon name={fileIconName(basename(n.change.path), n.change.path)} size={16} />
|
||||
<span className={styles.changeName} style={{ color: KIND_COLOR[n.change.kind] }}>
|
||||
{basename(n.change.path)}
|
||||
</span>
|
||||
<span className={styles.changeDir}>{relativeDir(task.cwd, n.change.path)}</span>
|
||||
<span className={styles.changeStat}>
|
||||
{n.change.added > 0 && <span className={styles.added}>+{n.change.added}</span>}{" "}
|
||||
{n.change.removed > 0 && <span className={styles.removed}>−{n.change.removed}</span>}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.changeDir}>{relativeDir(task.cwd, c.path)}</span>
|
||||
<span className={styles.changeStat}>
|
||||
{c.added > 0 && <span className={styles.added}>+{c.added}</span>}{" "}
|
||||
{c.removed > 0 && <span className={styles.removed}>−{c.removed}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
empty={
|
||||
<div className={styles.changesEmpty}>
|
||||
<T>No changes yet</T>
|
||||
@@ -2990,3 +3199,56 @@ export async function openChangeDiff(task: AgentTask, path: string) {
|
||||
);
|
||||
await openDiff(withChain(diffs, paths.indexOf(path)));
|
||||
}
|
||||
|
||||
/** Diff of `path` in the grouped view: `HEAD` ↔ the working file. */
|
||||
function uncommittedDiffData(task: AgentTask, change: AgentChange): DiffData {
|
||||
const left: DiffData["left"] =
|
||||
change.kind === "added" || change.kind === "untracked"
|
||||
? { type: "text", text: "", label: "(does not exist)", absent: true }
|
||||
: {
|
||||
type: "rev",
|
||||
rev: "HEAD",
|
||||
path: change.oldPath ? repositoryPath(task.cwd, change.oldPath) : undefined,
|
||||
label: "HEAD",
|
||||
};
|
||||
const right: DiffData["right"] =
|
||||
change.kind === "deleted"
|
||||
? { type: "text", text: "", label: "(deleted)", absent: true }
|
||||
: { type: "worktree", label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree" };
|
||||
return { path: change.path, left, right };
|
||||
}
|
||||
|
||||
/** Registration needed before a diff reads revisions inside the task's own worktree. */
|
||||
async function ensureTaskRepo(task: AgentTask) {
|
||||
// A worktree is its own repository, outside the project's: `vcs_file_at` reads it only once
|
||||
// it is registered as an external log for this window.
|
||||
if (task.isolation === "worktree") await call(commands.vcsOpenExternalLog(task.cwd)).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Opens the diff of `path` among the task's uncommitted files (grouped view). */
|
||||
async function openUncommittedDiff(task: AgentTask, path: string) {
|
||||
const changes = useAgents.getState().groupedChanges.get(task.id)?.uncommitted ?? [];
|
||||
const paths = changes.map((change) => change.path);
|
||||
if (!paths.includes(path)) return;
|
||||
await ensureTaskRepo(task);
|
||||
await openDiff(
|
||||
withChain(
|
||||
changes.map((change) => uncommittedDiffData(task, change)),
|
||||
paths.indexOf(path),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens the diff of one commit (`parent ↔ commit`), starting at `path`. */
|
||||
async function openCommitDiff(task: AgentTask, commit: AgentCommit, path: string) {
|
||||
const diffs = commit.files.map((file) =>
|
||||
commitFileDiffData(
|
||||
{ id: commit.id, shortId: commit.shortId, parents: commit.parent ? [commit.parent] : [], repo: task.cwd },
|
||||
{ path: file.relPath, kind: file.kind, oldPath: file.oldPath },
|
||||
),
|
||||
);
|
||||
if (!diffs.length) return;
|
||||
await ensureTaskRepo(task);
|
||||
const index = commit.files.findIndex((file) => file.path === path);
|
||||
await openDiff(withChain(diffs, index < 0 ? 0 : index));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Changes pane view options, persisted across sessions. */
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface ChangesOptions {
|
||||
/** Files grouped by the commit that changed them; otherwise the flat list since the task started. */
|
||||
groupByCommit: boolean;
|
||||
}
|
||||
|
||||
/** IDEA-like default: the review view. */
|
||||
export const DEFAULT_CHANGES_OPTIONS: ChangesOptions = { groupByCommit: true };
|
||||
|
||||
const KEY = "openidea.agents.changesView.v1";
|
||||
|
||||
function load(): ChangesOptions {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
const o = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
return {
|
||||
groupByCommit: typeof o.groupByCommit === "boolean" ? o.groupByCommit : DEFAULT_CHANGES_OPTIONS.groupByCommit,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_CHANGES_OPTIONS };
|
||||
}
|
||||
}
|
||||
|
||||
export const useChangesOptions = create<ChangesOptions>(() => load());
|
||||
|
||||
/** Updates the options and persists them. */
|
||||
export function setChangesOptions(patch: Partial<ChangesOptions>) {
|
||||
useChangesOptions.setState(patch);
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(useChangesOptions.getState()));
|
||||
} catch {
|
||||
// Storage unavailable: the options last for this session.
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
commands: {
|
||||
agentEntries: ipc.entries,
|
||||
agentChanges: async () => ({ status: "ok", data: [] }),
|
||||
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
|
||||
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
commands: {
|
||||
agentEntries: async () => ({ status: "ok", data: entries }),
|
||||
agentChanges: async () => ({ status: "ok", data: [] }),
|
||||
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
|
||||
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
|
||||
agentRollback: ipc.rollback,
|
||||
agentPrompt: ipc.prompt,
|
||||
|
||||
@@ -4,11 +4,12 @@ import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { EditorShownProvider } from "@/workbench/contributions";
|
||||
import { hideContextMenu, useContextMenuStore } from "@/ui";
|
||||
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
|
||||
import type { AgentChange, AgentCommit, AgentEntry, AgentTask } from "@/bindings";
|
||||
import { AgentsView } from "./AgentsView";
|
||||
import { SessionEditor, conversationRows } from "./SessionEditor";
|
||||
import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./model";
|
||||
import { disconnect, useAgents } from "./store";
|
||||
import { useChangesOptions } from "./changesOptions";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
@@ -18,6 +19,8 @@ import tagStyles from "@/ui/InlineTag.module.css";
|
||||
const backend = vi.hoisted(() => ({
|
||||
entries: new Map<string, AgentEntry[]>(),
|
||||
changes: [] as AgentChange[],
|
||||
grouped: { uncommitted: [] as AgentChange[], commits: [] as AgentCommit[] },
|
||||
rolledBack: [] as Array<[string, string[] | null, string | null]>,
|
||||
answers: [] as Array<[string, number, string | null]>,
|
||||
fetched: [] as string[],
|
||||
entryError: null as string | null,
|
||||
@@ -51,7 +54,11 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
return { status: "ok", data: backend.entries.get(key) ?? [] };
|
||||
},
|
||||
agentChanges: async () => ({ status: "ok", data: backend.changes }),
|
||||
agentRollbackChanges: async () => ({ status: "ok", data: null }),
|
||||
agentGroupedChanges: async () => ({ status: "ok", data: backend.grouped }),
|
||||
agentRollbackChanges: async (task: string, paths: string[] | null, view: string | null) => {
|
||||
backend.rolledBack.push([task, paths, view]);
|
||||
return { status: "ok", data: null };
|
||||
},
|
||||
agentAcceptChanges: async (task: string, paths: string[] | null) => {
|
||||
backend.accepted.push([task, paths]);
|
||||
return { status: "ok", data: backend.acceptedOutcome };
|
||||
@@ -156,7 +163,11 @@ beforeEach(() => {
|
||||
disconnect();
|
||||
localStorage.clear();
|
||||
backend.changes = [];
|
||||
backend.grouped = { uncommitted: [], commits: [] };
|
||||
backend.rolledBack = [];
|
||||
backend.external = [];
|
||||
// The flat list unless a test asks for the grouped view, so counts and selections stay as before.
|
||||
useChangesOptions.setState({ groupByCommit: false });
|
||||
backend.imported = [];
|
||||
backend.externalError = null;
|
||||
backend.accepted = [];
|
||||
@@ -1200,6 +1211,7 @@ describe("session state across tasks and hosts", () => {
|
||||
kind: "modified",
|
||||
added: 8,
|
||||
removed: 2,
|
||||
oldPath: null,
|
||||
},
|
||||
];
|
||||
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
|
||||
@@ -1381,6 +1393,7 @@ describe("accept changes", () => {
|
||||
kind: "modified",
|
||||
added: 8,
|
||||
removed: 2,
|
||||
oldPath: null,
|
||||
},
|
||||
];
|
||||
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
|
||||
@@ -1416,6 +1429,7 @@ describe("accept changes", () => {
|
||||
kind: "modified",
|
||||
added: 8,
|
||||
removed: 2,
|
||||
oldPath: null,
|
||||
},
|
||||
];
|
||||
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
|
||||
@@ -1425,3 +1439,80 @@ describe("accept changes", () => {
|
||||
expect(screen.getByRole("button", { name: "Rollback All Changes…" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("grouped changes", () => {
|
||||
const commit = (overrides: Partial<AgentCommit> = {}): AgentCommit => ({
|
||||
id: "a".repeat(40),
|
||||
shortId: "aaaaaaa",
|
||||
summary: "change a",
|
||||
message: "change a\n\nbody",
|
||||
timeMs: 1,
|
||||
parent: "b".repeat(40),
|
||||
files: [],
|
||||
...overrides,
|
||||
});
|
||||
const change = (path: string): AgentChange => ({
|
||||
path: `/project/${path}`,
|
||||
relPath: path,
|
||||
kind: "modified",
|
||||
added: 1,
|
||||
removed: 1,
|
||||
oldPath: null,
|
||||
});
|
||||
const worktreeTask = () => {
|
||||
useChangesOptions.setState({ groupByCommit: true });
|
||||
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree" }]]) });
|
||||
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
|
||||
};
|
||||
|
||||
it("puts committed files under their commit and rolls back only the uncommitted ones", async () => {
|
||||
backend.grouped = { uncommitted: [change("a.ts")], commits: [commit({ files: [change("b.ts")] })] };
|
||||
worktreeTask();
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
|
||||
expect(await screen.findByText("Uncommitted")).toBeInTheDocument();
|
||||
expect(screen.getByText("Committed")).toBeInTheDocument();
|
||||
expect(screen.getByText("aaaaaaa")).toBeInTheDocument();
|
||||
expect(screen.getByText("change a")).toBeInTheDocument();
|
||||
|
||||
// The commit's files show once it is expanded; selecting one is not a rollback selection.
|
||||
const commitRow = screen.getByText("change a").closest("[data-row]")!;
|
||||
fireEvent.mouseDown(commitRow.querySelector("[data-tree-toggle]")!);
|
||||
expect(await screen.findByText("b.ts")).toBeInTheDocument();
|
||||
fireEvent.mouseDown(screen.getByText("b.ts"));
|
||||
expect(screen.getByRole("button", { name: "Rollback All Changes…" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(screen.getByText("a.ts"));
|
||||
expect(screen.getByRole("button", { name: "Rollback Selected Changes…" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rollback Selected Changes…" }));
|
||||
await waitFor(() => expect(backend.confirmed).toEqual(["Rollback Changes"]));
|
||||
expect(backend.rolledBack).toEqual([["A", ["a.ts"], "group"]]);
|
||||
});
|
||||
|
||||
it("counts only uncommitted files in the header and the tray", async () => {
|
||||
backend.grouped = {
|
||||
uncommitted: [change("a.ts")],
|
||||
commits: [commit({ files: [change("b.ts"), change("c.ts")] })],
|
||||
};
|
||||
worktreeTask();
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByRole("complementary", { name: "Changes" });
|
||||
expect(screen.getByRole("button", { name: /1 changed file/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /1 commit/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts the task's committed work too, not only the uncommitted files", async () => {
|
||||
backend.grouped = { uncommitted: [], commits: [commit({ files: [change("b.ts")] })] };
|
||||
backend.changes = [change("b.ts")];
|
||||
worktreeTask();
|
||||
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree", status: "ready" }]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Committed");
|
||||
|
||||
const accept = screen.getByRole("button", { name: "Accept All Changes…" });
|
||||
expect(accept).toBeEnabled();
|
||||
fireEvent.click(accept);
|
||||
await waitFor(() => expect(backend.accepted).toEqual([["A", null]]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { create } from "zustand";
|
||||
import type {
|
||||
AgentAcceptOutcome,
|
||||
AgentChange,
|
||||
AgentChanges,
|
||||
AgentChangesView,
|
||||
AgentCommand,
|
||||
AgentConfigOption,
|
||||
AgentEntry,
|
||||
@@ -16,6 +18,7 @@ import type {
|
||||
ExternalSession,
|
||||
Isolation,
|
||||
} from "@/bindings";
|
||||
import { useChangesOptions } from "./changesOptions";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { defaultTaskProject } from "./project";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
@@ -77,6 +80,8 @@ interface AgentsState {
|
||||
entries: Map<string, AgentEntry[]>;
|
||||
entryLoads: Map<string, { error: string | null }>;
|
||||
changes: Map<string, AgentChange[]>;
|
||||
/** The task's uncommitted changes and commits, when the Changes pane groups by commit. */
|
||||
groupedChanges: Map<string, AgentChanges>;
|
||||
/** Scroll position and follow-output preference by conversation, shared by both hosts. */
|
||||
scrollPositions: Map<string, { top: number; stick: boolean }>;
|
||||
providers: AgentProvider[];
|
||||
@@ -147,6 +152,7 @@ export const useAgents = create<AgentsState>(() => ({
|
||||
entries: new Map(),
|
||||
entryLoads: new Map(),
|
||||
changes: new Map(),
|
||||
groupedChanges: new Map(),
|
||||
providers: [],
|
||||
limits: new Map(),
|
||||
draft: savedDraft(load<Partial<Draft>>(DRAFT_KEY, {})),
|
||||
@@ -312,6 +318,7 @@ export async function connect(): Promise<void> {
|
||||
entries: new Map(),
|
||||
entryLoads: new Map(),
|
||||
changes: new Map(),
|
||||
groupedChanges: new Map(),
|
||||
providers,
|
||||
});
|
||||
notifyTabTitlesChanged();
|
||||
@@ -372,6 +379,7 @@ export function disconnect() {
|
||||
entries: new Map(),
|
||||
entryLoads: new Map(),
|
||||
changes: new Map(),
|
||||
groupedChanges: new Map(),
|
||||
docked: null,
|
||||
lastTask: null,
|
||||
homeOpen: false,
|
||||
@@ -522,6 +530,23 @@ export async function loadEntries(task: string, subagent: string | null = null):
|
||||
}
|
||||
|
||||
export async function refreshChanges(task: string): Promise<void> {
|
||||
// Local tasks have no commits of their own to group: the flat list is their only view.
|
||||
const grouped =
|
||||
useChangesOptions.getState().groupByCommit && useAgents.getState().tasks.get(task)?.isolation === "worktree";
|
||||
if (grouped) {
|
||||
try {
|
||||
const review = await call(commands.agentGroupedChanges(task));
|
||||
useAgents.setState((s) => ({
|
||||
groupedChanges: new Map(s.groupedChanges).set(task, review),
|
||||
}));
|
||||
} catch {
|
||||
// The worktree is gone or not a repository: nothing to list.
|
||||
useAgents.setState((s) => ({
|
||||
groupedChanges: new Map(s.groupedChanges).set(task, { uncommitted: [], commits: [] }),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const changes = await call(commands.agentChanges(task));
|
||||
useAgents.setState((s) => ({
|
||||
@@ -533,10 +558,14 @@ export async function refreshChanges(task: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rollback: the task's changed files `relPaths` (all with `null`) back as they were when it started. */
|
||||
export async function rollbackChanges(task: string, relPaths: string[] | null): Promise<void> {
|
||||
/** Rollback: the task's changed files `relPaths` (all with `null`) back as `view` compares them. */
|
||||
export async function rollbackChanges(
|
||||
task: string,
|
||||
relPaths: string[] | null,
|
||||
view: AgentChangesView = "sinceStart",
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call(commands.agentRollbackChanges(task, relPaths));
|
||||
await call(commands.agentRollbackChanges(task, relPaths, view));
|
||||
} finally {
|
||||
await refreshChanges(task);
|
||||
}
|
||||
@@ -552,7 +581,8 @@ export async function acceptChanges(task: string, relPaths: string[] | null): Pr
|
||||
}
|
||||
|
||||
const pendingChanges = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
function scheduleChanges(task: string) {
|
||||
/** Reloads the task's changes after a short pause (tool diffs, commits landing). */
|
||||
export function scheduleChanges(task: string) {
|
||||
if (pendingChanges.has(task)) return;
|
||||
pendingChanges.set(
|
||||
task,
|
||||
|
||||
@@ -55,6 +55,7 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
},
|
||||
agentProviders: async () => ({ status: "ok", data: [] }),
|
||||
agentChanges: async () => ({ status: "ok", data: [] }),
|
||||
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
|
||||
agentBaseText: async (_task: string, path: string) => ({ status: "ok", data: `base of ${path}` }),
|
||||
agentEntries: async () => ({ status: "ok", data: await backend.entries }),
|
||||
},
|
||||
@@ -413,6 +414,7 @@ describe("the diff of an agent change", () => {
|
||||
kind: "modified" as const,
|
||||
added: 1,
|
||||
removed: 1,
|
||||
oldPath: null,
|
||||
});
|
||||
|
||||
it("lists the task's other changed files, so F7 moves on to the next one", async () => {
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"Roll back the changes to {count} files?": "Roll back the changes to {count} files?",
|
||||
"They go back to how they were when the task started, in the task's worktree.": "They go back to how they were when the task started, in the task's worktree.",
|
||||
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.": "They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.",
|
||||
"They go back to the last commit; changes made after it are lost.": "They go back to the last commit; changes made after it are lost.",
|
||||
"Cannot roll back the changes": "Cannot roll back the changes",
|
||||
"Cannot connect": "Cannot connect",
|
||||
"Rollback Selected Changes…": "Rollback Selected Changes…",
|
||||
@@ -1359,5 +1360,10 @@
|
||||
"Closes the agent task tab in the editor; the task keeps running in the Agents tool window.": "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.",
|
||||
"Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page.",
|
||||
"May be incomplete: plugin indexes are still updating": "May be incomplete: plugin indexes are still updating",
|
||||
"Plugin indexes could not be updated; see the plugin notification": "Plugin indexes could not be updated; see the plugin notification"
|
||||
"Plugin indexes could not be updated; see the plugin notification": "Plugin indexes could not be updated; see the plugin notification",
|
||||
"Uncommitted": "Uncommitted",
|
||||
"Committed": "Committed",
|
||||
"Working tree": "Working tree",
|
||||
"1 commit": "1 commit",
|
||||
"{count} commits": "{count} commits"
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"Roll back the changes to {count} files?": "是否回滚对 {count} 个文件的更改?",
|
||||
"They go back to how they were when the task started, in the task's worktree.": "它们会在任务的工作树中恢复为任务开始时的内容。",
|
||||
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.": "它们会恢复为任务开始时的内容。任务开始后你对它们做的修改也会丢失;任务开始前的修改会保留。",
|
||||
"They go back to the last commit; changes made after it are lost.": "它们会回到最后一次提交的状态;之后的改动会丢失。",
|
||||
"Cannot roll back the changes": "无法回滚更改",
|
||||
"Copy the changes to {name} into the local checkout?": "是否将对 {name} 的更改复制到本地检出?",
|
||||
"Copy the changes to {count} files into the local checkout?": "是否将对 {count} 个文件的更改复制到本地检出?",
|
||||
@@ -868,6 +869,7 @@
|
||||
"Commit message": "提交消息",
|
||||
"Commit message is empty": "提交消息为空",
|
||||
"Commit:": "提交:",
|
||||
"Committed": "已提交",
|
||||
"Commits": "提交",
|
||||
"Commits are applied from top to bottom.": "提交将从上到下依次应用。",
|
||||
"Commits to rebase": "要变基的提交",
|
||||
@@ -2871,6 +2873,7 @@
|
||||
"Uninstalled {name}": "已卸载 {name}",
|
||||
"URL:": "URL:",
|
||||
"The controls show the scheme's actual appearance; changes override it.": "控件显示方案的实际外观;修改即覆盖方案。",
|
||||
"Uncommitted": "未提交",
|
||||
"Uncommitted changes are stashed before the update and restored afterwards.": "更新前会储藏未提交的更改,并在之后恢复。",
|
||||
"Underline": "下划线",
|
||||
"Underscored": "下划线",
|
||||
@@ -2992,6 +2995,7 @@
|
||||
"Words": "单词",
|
||||
"Working": "正在工作",
|
||||
"Working directory": "工作目录",
|
||||
"Working tree": "工作树",
|
||||
"Wrap when typing reaches right margin": "输入到达右边距时换行",
|
||||
"Wrapping": "换行",
|
||||
"Yellow": "黄色",
|
||||
|
||||
Reference in New Issue
Block a user