diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 437dac00..72f61e73 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -21,7 +21,7 @@ use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes}; use parking_lot::{Mutex, RwLock}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::collections::{BTreeSet, HashMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Weak}; @@ -152,6 +152,9 @@ struct TaskFile { /// Settings the user chose, re-applied to every new or reloaded session. #[serde(default)] wanted: Vec, + /// In the project's own checkout: files already changed when the task started. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + before: BTreeMap, } struct TaskState { @@ -159,6 +162,7 @@ struct TaskState { session_id: Option, timeline: Timeline, wanted: Vec, + before: Arc>, /// A turn is running or about to (prompts arriving now are queued). busy: bool, } @@ -310,18 +314,20 @@ impl AgentManager { let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?; let title = title_of(&prompt); let root = self.shared.paths.project_root.clone(); - let (cwd, branch, base, base_commit) = match req.isolation { + let (cwd, branch, base, base_commit, before) = match req.isolation { Isolation::Worktree => { let dir = self.shared.paths.worktrees_dir.clone(); let (t, b) = (title.clone(), req.base.clone()); let c = blocking(move || worktree::create(&root, &dir, &t, b.as_deref())).await?; - (c.path, Some(c.branch), Some(c.base), Some(c.base_commit)) + (c.path, Some(c.branch), Some(c.base), Some(c.base_commit), BTreeMap::new()) } Isolation::Local => { let r = root.clone(); - let head = blocking(move || worktree::head_of(&r)).await.ok(); + let (head, before) = + blocking(move || Ok((worktree::head_of(&r).ok(), worktree::snapshot(&r).unwrap_or_default()))) + .await?; let (base, commit) = head.unzip(); - (root, None, base, commit) + (root, None, base, commit, before) } }; // Until the session reports its own, show what the agent offered last time with the choices applied. @@ -351,7 +357,7 @@ impl AgentManager { usage: None, queue: Vec::new(), }; - let file = TaskFile { task: task.clone(), session_id: None, entries: Vec::new(), wanted: req.config }; + let file = TaskFile { task: task.clone(), session_id: None, entries: Vec::new(), wanted: req.config, before }; let handle = Task::restore(file, self.shared.clone()); handle.state.lock().busy = true; self.tasks.write().insert(task.id.clone(), handle.clone()); @@ -529,8 +535,10 @@ impl AgentManager { /// Files the task changed since it started. pub async fn changes(&self, id: &str) -> IdeResult> { - let (cwd, base) = self.task(id)?.cwd_and_base(); - blocking(move || worktree::changes(&cwd, &base)).await + let task = self.task(id)?; + let (cwd, base) = task.cwd_and_base(); + let before = task.state.lock().before.clone(); + blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await } /// Content of `path` (in the task's directory) when the task started. @@ -560,6 +568,7 @@ impl Task { session_id: file.session_id, timeline, wanted: file.wanted, + before: Arc::new(file.before), busy: false, }), live: Mutex::new(None), @@ -616,6 +625,7 @@ impl Task { session_id: s.session_id.clone(), entries: s.timeline.entries.clone(), wanted: s.wanted.clone(), + before: (*s.before).clone(), }) }; let path = self.shared.paths.store_dir.join(format!("{}.json", self.id)); diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs index 5563cf04..7f385afc 100644 --- a/crates/ide-agent/src/worktree.rs +++ b/crates/ide-agent/src/worktree.rs @@ -3,7 +3,9 @@ use crate::model::AgentChange; use ide_core::{IdeError, IdeResult, IpcPath}; use ide_vcs::{ChangeKind, Repo}; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; pub(crate) struct Created { pub path: PathBuf, @@ -74,6 +76,25 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult> { Ok(out) } +/// Size and modification time of a file, `None` once it is gone. +pub(crate) type Stamp = Option<(u64, u128)>; + +fn stamp(path: &Path) -> Stamp { + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos(); + Some((meta.len(), modified)) +} + +/// The files already changed in `cwd` against `HEAD`, stamped so later edits to them show. +pub(crate) fn snapshot(cwd: &Path) -> IdeResult> { + Ok(changes(cwd, "HEAD")?.into_iter().map(|c| (c.rel_path, stamp(&c.path))).collect()) +} + +/// Drops the changes that were there before the task and haven't been touched since. +pub(crate) fn without_untouched(changes: Vec, before: &BTreeMap) -> Vec { + changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect() +} + pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult> { repo_at(cwd)?.file_at(path, Some(rev)) } @@ -148,6 +169,17 @@ mod tests { let base_text = file_at(&created.path, &created.path.join("a.txt"), &created.base_commit).unwrap(); assert_eq!(base_text.as_deref(), Some("one\ntwo\n")); + // Changes already in the checkout drop out until they are edited again. + let before = snapshot(&created.path).unwrap(); + std::fs::write(created.path.join("b.txt"), "b\n").unwrap(); + let rels = |c: Vec| c.into_iter().map(|c| c.rel_path).collect::>(); + let after = changes(&created.path, "HEAD").unwrap(); + assert_eq!(rels(without_untouched(after, &before)), ["b.txt"]); + std::thread::sleep(std::time::Duration::from_millis(10)); + std::fs::write(created.path.join("new.txt"), "x\ny\nz\n").unwrap(); + let after = changes(&created.path, "HEAD").unwrap(); + assert_eq!(rels(without_untouched(after, &before)), ["b.txt", "new.txt"]); + // A second task with the same title gets its own worktree. assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2"); remove(&repo, &created.path).unwrap(); diff --git a/src/features/agents/Home.module.css b/src/features/agents/Home.module.css index 24d8d173..f8622115 100644 --- a/src/features/agents/Home.module.css +++ b/src/features/agents/Home.module.css @@ -34,6 +34,15 @@ padding: 0 2px; font-size: var(--fs-small); } +/* The current branch, read-only: same height and colour as the pickers beside it. */ +.branch { + display: inline-flex; + align-items: center; + gap: 4px; + height: var(--h-control); + padding: 0 6px; + color: var(--fg-secondary); +} .project { margin-right: 6px; color: var(--fg-muted); diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index 598e4210..ab65f9ce 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -4,7 +4,7 @@ import type { Branch } from "@/bindings"; import { call, commands } from "@/platform/ipc"; import { useTx, useTxTemplate } from "@/platform/i18n"; import { useProject } from "@/platform/project"; -import { Button, Select, T, type SelectOption } from "@/ui"; +import { Button, Icon, Select, T, type SelectOption } from "@/ui"; import { Composer } from "./Composer"; import { canSend, emptyDraft } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; @@ -107,6 +107,17 @@ export function HomeEditor() { ]} onChange={(isolation) => updateDraft({ isolation })} /> + {draft.isolation === "local" && current && ( + <> + + on + + + + {current} + + + )} {draft.isolation === "worktree" && branches.length > 0 && ( <> diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e4adbc1a..5a97da4a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3216,6 +3216,7 @@ "away from your changes": "不影响你的改动", "edits your files directly": "直接修改你的文件", "from": "基于", + "on": "位于", "Models and modes appear after this agent's first task.": "这个 agent 跑完第一个任务后会显示模型和模式。", "Queue a message for when the agent is done (⌘↵ sends it now)": "排队一条消息,agent 做完后发送(⌘↵ 立即发送)", "Couldn't send the message": "消息发送失败",