Skip to content

Commit 32f6164

Browse files
Copilottknkaa
andcommitted
Refactor file editing workflow: save File to /tmp, editor on @path, diff on $ diff
Co-authored-by: tknkaa <145080781+tknkaa@users.noreply.github.com>
1 parent bd1a54a commit 32f6164

3 files changed

Lines changed: 132 additions & 69 deletions

File tree

cli/src/client.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,13 @@ pub async fn run(name: String, yes: bool) -> Result<()> {
218218
} else {
219219
println!("Diff not applied.");
220220
}
221+
222+
// Notify the coder whether the diff was accepted
223+
let response_msg = serde_json::to_string(
224+
&WsMessage::DiffResponse { accepted: apply },
225+
)?;
226+
write.send(Message::text(response_msg)).await?;
227+
221228
// Reset spinner to wait for the rest of the answer
222229
first_chunk = true;
223230
spinner.reset();

cli/src/coder.rs

Lines changed: 123 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -75,68 +75,19 @@ async fn session(server_url: &str, room_id: &str) -> Result<()> {
7575
if let Ok(WsMessage::File { path, content }) =
7676
serde_json::from_str::<WsMessage>(&msg)
7777
{
78-
let base = std::path::Path::new("/tmp/coding-human");
79-
let dest = base.join(&path);
80-
// Reject paths that escape the base directory
81-
if !dest.starts_with(base)
82-
|| std::path::Path::new(&path)
83-
.components()
84-
.any(|c| c == std::path::Component::ParentDir)
85-
{
86-
eprintln!("Rejected unsafe file path: {}", path);
87-
continue;
88-
}
89-
if let Some(parent) = dest.parent() {
90-
tokio::fs::create_dir_all(parent).await?;
91-
}
92-
tokio::fs::write(&dest, &content).await?;
93-
println!("Received file: {} -> {}", path, dest.display());
94-
95-
// Open the file in $EDITOR so the coder can edit it
96-
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nvim".to_string());
97-
match tokio::process::Command::new(&editor)
98-
.arg(&dest)
99-
.status()
100-
.await
101-
{
102-
Err(e) => eprintln!("Failed to open editor '{}': {}", editor, e),
103-
Ok(_) => {
104-
// Generate a unified diff between original and edited content
105-
let orig_tmp =
106-
base.join(format!(".orig.{}", path.replace('/', "_")));
107-
if tokio::fs::write(&orig_tmp, &content).await.is_ok() {
108-
if let (Some(orig_str), Some(dest_str)) =
109-
(orig_tmp.to_str(), dest.to_str())
110-
{
111-
let diff_out = tokio::process::Command::new("diff")
112-
.args(["-u", orig_str, dest_str])
113-
.output()
114-
.await;
115-
let _ = tokio::fs::remove_file(&orig_tmp).await;
116-
if let Ok(out) = diff_out {
117-
let diff_text =
118-
String::from_utf8_lossy(&out.stdout).to_string();
119-
if !diff_text.is_empty() {
120-
match serde_json::to_string(&WsMessage::Diff {
121-
path: path.clone(),
122-
diff: diff_text,
123-
}) {
124-
Ok(diff_msg) => {
125-
if let Err(e) = write
126-
.send(Message::text(diff_msg))
127-
.await
128-
{
129-
eprintln!("Failed to send diff: {}", e);
130-
}
131-
}
132-
Err(e) => {
133-
eprintln!("Failed to serialize diff: {}", e)
134-
}
135-
}
136-
}
137-
}
138-
}
78+
match safe_tmp_path(&path) {
79+
None => eprintln!("Rejected unsafe file path: {}", path),
80+
Some(dest) => {
81+
if let Some(parent) = dest.parent() {
82+
tokio::fs::create_dir_all(parent).await?;
83+
}
84+
tokio::fs::write(&dest, &content).await?;
85+
// Save an original snapshot for later diff generation
86+
let orig_snap = orig_snap_path(&path);
87+
if let Err(e) = tokio::fs::write(&orig_snap, &content).await {
88+
eprintln!("Warning: could not save original snapshot: {}", e);
13989
}
90+
println!("Received file: {} -> {}", path, dest.display());
14091
}
14192
}
14293
continue;
@@ -171,21 +122,102 @@ async fn session(server_url: &str, room_id: &str) -> Result<()> {
171122
break;
172123
}
173124
let trimmed = line.trim_end_matches('\n');
174-
if let Some(cmd) = trimmed.strip_prefix('$') {
175-
let command = cmd.trim().to_string();
176-
let msg = serde_json::to_string(&WsMessage::Cmd { command })?;
177-
write.send(Message::text(msg)).await?;
125+
if let Some(file_path) = trimmed.strip_prefix('@') {
126+
// Open the file from /tmp/coding-human/ in $EDITOR
127+
let file_path = file_path.trim();
128+
match safe_tmp_path(file_path) {
129+
None => eprintln!("Rejected unsafe file path: {}", file_path),
130+
Some(dest) => {
131+
let editor =
132+
std::env::var("EDITOR").unwrap_or_else(|_| "nvim".to_string());
133+
if let Err(e) = tokio::process::Command::new(&editor)
134+
.arg(&dest)
135+
.status()
136+
.await
137+
{
138+
eprintln!("Failed to open editor '{}': {}", editor, e);
139+
}
140+
}
141+
}
142+
} else if let Some(rest) = trimmed.strip_prefix('$') {
143+
let rest = rest.trim();
144+
if let Some(diff_path) = rest.strip_prefix("diff ") {
145+
// Generate a unified diff and send it to the client
146+
let diff_path = diff_path.trim();
147+
match safe_tmp_path(diff_path) {
148+
None => eprintln!("Rejected unsafe file path: {}", diff_path),
149+
Some(dest) => {
150+
let orig_snap = orig_snap_path(diff_path);
151+
if let (Some(orig_str), Some(dest_str)) =
152+
(orig_snap.to_str(), dest.to_str())
153+
{
154+
let diff_out = tokio::process::Command::new("diff")
155+
.args(["-u", orig_str, dest_str])
156+
.output()
157+
.await;
158+
match diff_out {
159+
Ok(out) => {
160+
let diff_text =
161+
String::from_utf8_lossy(&out.stdout)
162+
.to_string();
163+
if diff_text.is_empty() {
164+
println!("No changes detected.");
165+
} else {
166+
match serde_json::to_string(&WsMessage::Diff {
167+
path: diff_path.to_string(),
168+
diff: diff_text,
169+
}) {
170+
Ok(diff_msg) => {
171+
if let Err(e) = write
172+
.send(Message::text(diff_msg))
173+
.await
174+
{
175+
eprintln!(
176+
"Failed to send diff: {}",
177+
e
178+
);
179+
} else {
180+
println!("Diff sent, waiting for client response...");
181+
}
182+
}
183+
Err(e) => eprintln!(
184+
"Failed to serialize diff: {}",
185+
e
186+
),
187+
}
188+
}
189+
}
190+
Err(e) => eprintln!("Failed to run diff: {}", e),
191+
}
192+
}
193+
}
194+
}
195+
} else {
196+
let command = rest.to_string();
197+
let msg = serde_json::to_string(&WsMessage::Cmd { command })?;
198+
write.send(Message::text(msg)).await?;
199+
}
178200
} else {
179201
write.send(Message::text(trimmed)).await?;
180202
}
181203
}
182204
ws_msg = read.next() => {
183205
match ws_msg {
184206
Some(Ok(Message::Text(msg))) => {
185-
if let Ok(WsMessage::CmdResult { command, output }) =
186-
serde_json::from_str::<WsMessage>(&msg)
187-
{
188-
println!("\n[cmd result] $ {}\n{}", command, output);
207+
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&msg) {
208+
match ws_msg {
209+
WsMessage::CmdResult { command, output } => {
210+
println!("\n[cmd result] $ {}\n{}", command, output);
211+
}
212+
WsMessage::DiffResponse { accepted } => {
213+
if accepted {
214+
println!("\n[diff] Client accepted and applied the changes.");
215+
} else {
216+
println!("\n[diff] Client rejected the changes.");
217+
}
218+
}
219+
_ => {}
220+
}
189221
}
190222
}
191223
Some(Ok(Message::Close(_))) => {
@@ -214,3 +246,25 @@ fn ws_url(server_url: &str, path: &str) -> String {
214246
.unwrap_or(server_url);
215247
format!("{}://{}{}", scheme, host, path)
216248
}
249+
250+
/// Returns the validated destination path inside `/tmp/coding-human/`, or
251+
/// `None` if the path would escape the base directory.
252+
fn safe_tmp_path(relative_path: &str) -> Option<std::path::PathBuf> {
253+
let base = std::path::Path::new("/tmp/coding-human");
254+
let dest = base.join(relative_path);
255+
if dest.starts_with(base)
256+
&& !std::path::Path::new(relative_path)
257+
.components()
258+
.any(|c| c == std::path::Component::ParentDir)
259+
{
260+
Some(dest)
261+
} else {
262+
None
263+
}
264+
}
265+
266+
/// Returns the path of the `.orig.` snapshot for the given relative file path.
267+
fn orig_snap_path(relative_path: &str) -> std::path::PathBuf {
268+
std::path::Path::new("/tmp/coding-human")
269+
.join(format!(".orig.{}", relative_path.replace('/', "_")))
270+
}

cli/src/protocol.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub enum WsMessage {
3535
File { path: String, content: String },
3636
/// Coder → Client: unified diff of an edited file
3737
Diff { path: String, diff: String },
38+
/// Client → Coder: whether the client accepted and applied a diff
39+
DiffResponse { accepted: bool },
3840
/// Coder → Client: signals end of the current answer stream
3941
Done,
4042
}

0 commit comments

Comments
 (0)