Skip to content

Commit a323799

Browse files
committed
Improve watcher debounce and ACP follow behavior
1 parent c760e18 commit a323799

File tree

3 files changed

+110
-47
lines changed

3 files changed

+110
-47
lines changed

anycode-backend/src/handlers/watch_handler.rs

Lines changed: 92 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
use std::path::PathBuf;
22
use std::sync::Arc;
33
use std::collections::HashMap;
4-
use std::time::{Instant, Duration};
5-
use tokio::sync::Mutex;
6-
use tokio::time::sleep;
4+
use std::time::Duration;
5+
use tokio::sync::{Mutex, Notify};
76
use serde_json::json;
87
use tracing::info;
98
use anyhow::Result;
109

10+
const DEBOUNCE: Duration = Duration::from_millis(100);
11+
1112
use crate::app_state::SocketData;
1213
use crate::code::Code;
1314
use crate::diff::compute_text_edits;
@@ -20,7 +21,8 @@ enum FileState {
2021

2122
pub struct FileWatchState {
2223
pub state: FileState,
23-
pub last_event_time: Instant,
24+
pub notify: Arc<Notify>,
25+
pub pending: bool,
2426
}
2527

2628
async fn is_parent_dir_opened(
@@ -67,9 +69,18 @@ async fn handle_create_remove_event(
6769

6870
let relative_path = crate::utils::relative_path(path_str);
6971

72+
// For create events, path.is_file() works because the file exists.
73+
// For remove events, path.is_file() always returns false (file is gone),
74+
// so we use a heuristic: if the path has a file extension, it's a file.
75+
let is_file = match event_kind {
76+
notify::EventKind::Create(_) => path.is_file(),
77+
notify::EventKind::Remove(_) => path.extension().is_some(),
78+
_ => false,
79+
};
80+
7081
let _ = socket.emit(event_name, &json!({
7182
"path": relative_path,
72-
"isFile": path.is_file()
83+
"isFile": is_file
7384
})).await;
7485
}
7586

@@ -98,47 +109,97 @@ pub async fn handle_watch_event(
98109
git_manager: &Arc<Mutex<crate::git::GitManager>>,
99110
) {
100111
let path_str = match path.to_str() {
101-
Some(s) => s,
112+
Some(s) => s.to_string(),
102113
None => return,
103114
};
104115

105-
// Debounce: ignore events if less than 100ms since the last one
106-
{
116+
let should_spawn = {
117+
let mut states = file_states.lock().await;
118+
let entry = states.entry(path_str.clone()).or_insert_with(|| FileWatchState {
119+
state: FileState::DoesNotExist, // never seen = didn't exist for us
120+
notify: Arc::new(Notify::new()),
121+
pending: false,
122+
});
123+
124+
// Signal the existing task to reset its timer
125+
entry.notify.notify_one();
126+
127+
if entry.pending {
128+
// A task is already waiting — it will pick up the new event
129+
false
130+
} else {
131+
entry.pending = true;
132+
true
133+
}
134+
};
135+
136+
if !should_spawn {
137+
return;
138+
}
139+
140+
// Spawn a single debounce task for this file
141+
let path = path.clone();
142+
let socket = socket.clone();
143+
let file2code = file2code.clone();
144+
let socket2data = socket2data.clone();
145+
let file_states = file_states.clone();
146+
let git_manager = git_manager.clone();
147+
148+
// Get the notify handle for this file
149+
let file_notify = {
107150
let states = file_states.lock().await;
108-
if let Some(watch_state) = states.get(path_str) {
109-
if watch_state.last_event_time.elapsed() < Duration::from_millis(100) {
110-
info!("Debouncing event for path: {:?}", path);
111-
return;
151+
states.get(&path_str).unwrap().notify.clone()
152+
};
153+
154+
tokio::spawn(async move {
155+
// Wait until events stop arriving (trailing-edge debounce)
156+
loop {
157+
match tokio::time::timeout(DEBOUNCE, file_notify.notified()).await {
158+
Ok(_) => continue, // new event arrived — reset timer
159+
Err(_) => break, // timeout — silence, time to process
112160
}
113161
}
114-
}
115162

116-
// Wait 100ms before checking the actual state
117-
sleep(Duration::from_millis(100)).await;
163+
process_watch_event(
164+
&path, &path_str, &socket, &file2code, &socket2data, &file_states, &git_manager,
165+
).await;
166+
167+
// Mark as not pending so future events spawn a new task
168+
{
169+
let mut states = file_states.lock().await;
170+
if let Some(state) = states.get_mut(&path_str) {
171+
state.pending = false;
172+
}
173+
}
174+
});
175+
}
118176

177+
async fn process_watch_event(
178+
path: &PathBuf,
179+
path_str: &str,
180+
socket: &Arc<socketioxide::SocketIo>,
181+
file2code: &Arc<Mutex<HashMap<String, Code>>>,
182+
socket2data: &Arc<Mutex<HashMap<String, SocketData>>>,
183+
file_states: &Arc<Mutex<HashMap<String, FileWatchState>>>,
184+
git_manager: &Arc<Mutex<crate::git::GitManager>>,
185+
) {
119186
let current_state = if path.exists() {
120187
FileState::Exists
121188
} else {
122189
FileState::DoesNotExist
123190
};
124191

125192
let last_state = {
126-
let mut states = file_states.lock().await;
127-
let watch_state = states.entry(path_str.to_string()).or_insert(FileWatchState {
128-
state: if path.exists() { FileState::Exists } else { FileState::DoesNotExist },
129-
last_event_time: Instant::now(),
130-
});
131-
let last = watch_state.state.clone();
132-
watch_state.last_event_time = Instant::now();
133-
last
193+
let states = file_states.lock().await;
194+
states.get(path_str)
195+
.map(|s| s.state.clone())
196+
.unwrap_or(FileState::DoesNotExist) // never seen = didn't exist for us
134197
};
135198

136199
info!("File state transition: {:?} -> {:?} for path: {:?}", last_state, current_state, path);
137200

138-
// Only handle when the state changes
139201
match (&last_state, &current_state) {
140202
(&FileState::DoesNotExist, &FileState::Exists) => {
141-
// File created/restored
142203
handle_create_remove_event(
143204
path,
144205
path_str,
@@ -148,7 +209,6 @@ pub async fn handle_watch_event(
148209
).await;
149210
},
150211
(&FileState::Exists, &FileState::DoesNotExist) => {
151-
// File removed
152212
handle_create_remove_event(
153213
path,
154214
path_str,
@@ -158,24 +218,21 @@ pub async fn handle_watch_event(
158218
).await;
159219
},
160220
(&FileState::Exists, &FileState::Exists) => {
161-
// File exists - treat as modify
162221
handle_modify_event(path, path_str, socket, file2code, socket2data).await;
163222
},
164223
_ => {
165224
info!("Ignoring state transition: {:?} -> {:?}", last_state, current_state);
166225
}
167226
}
168227

169-
// Update state
228+
// Update state (or remove if file was deleted to prevent memory leak)
170229
{
171230
let mut states = file_states.lock().await;
172-
states.insert(
173-
path_str.to_string(),
174-
FileWatchState {
175-
state: current_state,
176-
last_event_time: Instant::now(),
177-
},
178-
);
231+
if current_state == FileState::DoesNotExist {
232+
states.remove(path_str);
233+
} else if let Some(watch_state) = states.get_mut(path_str) {
234+
watch_state.state = current_state;
235+
}
179236
}
180237

181238
// Check git status
@@ -191,7 +248,6 @@ pub async fn handle_watch_event(
191248
};
192249

193250
if let Some(status) = new_status {
194-
// Emit to all clients
195251
let _ = socket.emit("git:status-update", &status.to_json()).await;
196252
}
197253
}

anycode/App.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AnycodeEditorReact, AnycodeEditor } from 'anycode-react';
44
import type { Change, Position, Edit } from '../anycode-base/src/code';
55
import { WatcherCreate, WatcherEdits, WatcherRemove,
66
type CursorHistory, type Terminal, type AcpSession,
7-
type AcpMessage, type AcpPromptStateMessage, type AcpToolCallMessage,
7+
type AcpMessage, type AcpPromptStateMessage, type AcpToolCallMessage, type AcpToolResultMessage,
88
type AcpOpenFileMessage, type SearchResult, type SearchEnd, type SearchMatch,
99
type PendingBatch
1010
} from './types';
@@ -1280,16 +1280,23 @@ const App: React.FC = () => {
12801280

12811281
// Handle tool_call, tool_result, tool_update, and permission_request messages
12821282
if (data.item.role === 'tool_call' || data.item.role === 'tool_result' || data.item.role === 'tool_update' || data.item.role === 'permission_request') {
1283-
// Follow mode: open file when tool_call has locations
1284-
if (data.item.role === 'tool_call' && followEnabledRef.current) {
1285-
const toolCall = data.item as AcpToolCallMessage;
1286-
if (toolCall.locations && toolCall.locations.length > 0) {
1287-
const loc = toolCall.locations[0];
1288-
const filePath = loc.path;
1289-
if (loc.line !== undefined) {
1290-
pendingPositions.current.set(filePath, { line: loc.line, column: 0 });
1283+
// Follow mode: open file when tool_result arrives (file is guaranteed to exist at this point)
1284+
if (data.item.role === 'tool_result' && followEnabledRef.current) {
1285+
const toolResult = data.item as AcpToolResultMessage;
1286+
// Find the matching tool_call in session history to get locations
1287+
const session = acpSessionsRef.current.get(data.agent_id);
1288+
if (session) {
1289+
const matchingToolCall = session.messages.find(
1290+
m => m.role === 'tool_call' && (m as AcpToolCallMessage).id === toolResult.id
1291+
) as AcpToolCallMessage | undefined;
1292+
if (matchingToolCall?.locations && matchingToolCall.locations.length > 0) {
1293+
const loc = matchingToolCall.locations[0];
1294+
const filePath = loc.path;
1295+
if (loc.line !== undefined) {
1296+
pendingPositions.current.set(filePath, { line: loc.line, column: 0 });
1297+
}
1298+
handleOpenChangedFile(filePath);
12911299
}
1292-
openFile(filePath);
12931300
}
12941301
}
12951302

anycode/components/TreeNodeComponent.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
.tree-name {
6868
flex: 1;
6969
overflow: hidden;
70-
text-overflow: ellipsis;
70+
/* text-overflow: ellipsis; */
7171
white-space: nowrap;
7272
color: #e0e0e0;
7373
user-select: none;

0 commit comments

Comments
 (0)