fix(workspace-state): stop resync loop on non-git folders and allow retry for degraded watcher

Gate git refresh on .git presence so file churn in non-git workspaces no longer produces endless resync_hint events, and silently log tree/git refresh errors during watch flushing instead of flagging requires_resync, which turned transient failures into self-reinforcing loops.

Degrade gracefully when the filesystem watcher fails to attach (e.g. permission denied, inotify quota): keep the initial snapshot, surface a degraded flag, and expose a store-level restart that the banner uses to retry attachment after the root cause is fixed.

Propagate is_git_repo through the snapshot so the git log and changes tabs render a dedicated "Not a Git repository" empty state instead of raw git stderr with a useless retry button.

Stop polling get_git_branch from the title bar once it returns null and re-arm on visibility change.

Add translations for the new banner, empty-state, and retry keys across all ten locales.
This commit is contained in:
xintaofei
2026-04-18 17:18:11 +08:00
parent c5c2bdd331
commit 7ef8d84d44
19 changed files with 380 additions and 74 deletions

View File

@@ -68,6 +68,8 @@ pub struct WorkspaceSnapshotResponse {
pub tree_snapshot: Option<Vec<FileTreeNode>>,
pub git_snapshot: Option<Vec<WorkspaceGitEntry>>,
pub deltas: Vec<WorkspaceDeltaEnvelope>,
pub degraded: bool,
pub is_git_repo: bool,
}
struct WorkspaceStateCore {
@@ -77,6 +79,8 @@ struct WorkspaceStateCore {
git_snapshot: Vec<WorkspaceGitEntry>,
recent_events: VecDeque<WorkspaceDeltaEnvelope>,
recent_capacity: usize,
degraded: bool,
is_git_repo: bool,
}
impl WorkspaceStateCore {
@@ -84,6 +88,7 @@ impl WorkspaceStateCore {
root_path: String,
tree_snapshot: Vec<FileTreeNode>,
git_snapshot: Vec<WorkspaceGitEntry>,
is_git_repo: bool,
) -> Self {
Self {
root_path,
@@ -92,6 +97,8 @@ impl WorkspaceStateCore {
git_snapshot,
recent_events: VecDeque::new(),
recent_capacity: RECENT_EVENT_CAPACITY,
degraded: false,
is_git_repo,
}
}
@@ -143,6 +150,8 @@ impl WorkspaceStateCore {
tree_snapshot: None,
git_snapshot: None,
deltas,
degraded: self.degraded,
is_git_repo: self.is_git_repo,
};
}
}
@@ -155,6 +164,8 @@ impl WorkspaceStateCore {
tree_snapshot: Some(self.tree_snapshot.clone()),
git_snapshot: Some(self.git_snapshot.clone()),
deltas: Vec::new(),
degraded: self.degraded,
is_git_repo: self.is_git_repo,
}
}
@@ -212,8 +223,8 @@ impl WorkspaceStateCore {
struct WorkspaceStreamEntry {
root_canonical: PathBuf,
root_display: String,
watcher: RecommendedWatcher,
task: tokio::task::JoinHandle<()>,
watcher: Option<RecommendedWatcher>,
task: Option<tokio::task::JoinHandle<()>>,
ref_count: usize,
state: Arc<Mutex<WorkspaceStateCore>>,
}
@@ -569,6 +580,10 @@ async fn git_numstat_map(path: &str) -> HashMap<String, (i32, i32)> {
.unwrap_or_default()
}
fn is_git_repo(root: &Path) -> bool {
root.join(".git").exists()
}
async fn collect_git_snapshot(path: &str) -> Result<Vec<WorkspaceGitEntry>, AppCommandError> {
let status_entries = folders::git_status(path.to_string(), Some(true)).await?;
@@ -622,66 +637,65 @@ async fn flush_watch_batch(
};
let should_refresh_tree = batch.overflowed || event_kind_hint != "modify";
let should_refresh_git = if batch.overflowed {
true
} else {
should_refresh_git_status_for_paths(root_display, &changed_paths).await
};
let is_git = is_git_repo(root_canonical);
let should_refresh_git = is_git
&& (batch.overflowed
|| should_refresh_git_status_for_paths(root_display, &changed_paths).await);
let mut payload = Vec::new();
let mut requires_resync = false;
let mut refreshed_tree: Option<Vec<FileTreeNode>> = None;
let mut refreshed_git: Option<Vec<WorkspaceGitEntry>> = None;
// Refresh failures are logged and silently skipped. Emitting a
// `resync_hint` on every failure creates a feedback loop when the
// failure is persistent (e.g. tree enum hits a permission-denied
// subdir, git is unreachable), because the frontend would re-fetch
// the same stored resync_hint event on every watch tick.
if should_refresh_tree {
match folders::get_file_tree(root_display.to_string(), Some(WORKSPACE_TREE_MAX_DEPTH)).await
{
Ok(tree) => refreshed_tree = Some(tree),
Err(_) => requires_resync = true,
Err(err) => eprintln!(
"[workspace-state-watch] tree refresh failed for {}: {}",
root_display, err
),
}
}
if should_refresh_git {
match collect_git_snapshot(root_display).await {
Ok(git_snapshot) => refreshed_git = Some(git_snapshot),
Err(_) => requires_resync = true,
Err(err) => eprintln!(
"[workspace-state-watch] git refresh failed for {}: {}",
root_display, err
),
}
}
if requires_resync {
payload = vec![WorkspaceDelta::Meta {
reason: format!("watch_refresh_failed:{event_kind_hint}"),
}];
}
let event = {
let mut guard = match state.lock() {
Ok(guard) => guard,
Err(_) => return,
};
if !requires_resync {
if let Some(tree) = refreshed_tree {
if tree != guard.tree_snapshot {
payload.push(WorkspaceDelta::TreeReplace { nodes: tree });
}
if let Some(tree) = refreshed_tree {
if tree != guard.tree_snapshot {
payload.push(WorkspaceDelta::TreeReplace { nodes: tree });
}
if let Some(git_snapshot) = refreshed_git {
if git_snapshot != guard.git_snapshot {
payload.push(WorkspaceDelta::GitReplace {
entries: git_snapshot,
});
}
}
if payload.is_empty() {
return;
}
if let Some(git_snapshot) = refreshed_git {
if git_snapshot != guard.git_snapshot {
payload.push(WorkspaceDelta::GitReplace {
entries: git_snapshot,
});
}
}
let kind = if requires_resync {
"resync_hint".to_string()
} else if payload
if payload.is_empty() {
return;
}
let kind = if payload
.iter()
.any(|delta| matches!(delta, WorkspaceDelta::TreeReplace { .. }))
{
@@ -695,7 +709,7 @@ async fn flush_watch_batch(
"meta".to_string()
};
guard.append_event(kind, payload, requires_resync)
guard.append_event(kind, payload, false)
};
emit_event(emitter, "folder://workspace-state-event", event);
@@ -806,12 +820,18 @@ pub async fn start_workspace_state_stream_core(
let initial_tree = folders::get_file_tree(root_path.clone(), Some(WORKSPACE_TREE_MAX_DEPTH))
.await
.unwrap_or_default();
let initial_git = collect_git_snapshot(&root_path).await.unwrap_or_default();
let initial_is_git_repo = is_git_repo(&root_canonical);
let initial_git = if initial_is_git_repo {
collect_git_snapshot(&root_path).await.unwrap_or_default()
} else {
Vec::new()
};
let state = Arc::new(Mutex::new(WorkspaceStateCore::new(
root_path.clone(),
initial_tree,
initial_git,
initial_is_git_repo,
)));
let (event_tx, event_rx) = mpsc::channel::<notify::Event>(WATCH_EVENT_CHANNEL_CAPACITY);
@@ -862,14 +882,26 @@ pub async fn start_workspace_state_stream_core(
})?,
);
watcher
let watch_result = watcher
.as_mut()
.ok_or_else(|| AppCommandError::task_execution_failed("Failed to create watcher"))?
.watch(&root_canonical, RecursiveMode::Recursive)
.map_err(|e| {
AppCommandError::io_error("Failed to start workspace state watcher")
.with_detail(e.to_string())
})?;
.watch(&root_canonical, RecursiveMode::Recursive);
if let Err(err) = watch_result {
eprintln!(
"[workspace-state-watch] degraded (no realtime updates) for {}: {}",
root_path, err
);
if let Some(mut created_watcher) = watcher.take() {
let _ = created_watcher.unwatch(&root_canonical);
}
if let Some(created_task) = task.take() {
created_task.abort();
}
if let Ok(mut guard) = state.lock() {
guard.degraded = true;
}
}
let (should_cleanup_new_stream, start_snapshot) = {
let mut streams = WORKSPACE_STREAMS.lock().map_err(|_| {
@@ -896,16 +928,8 @@ pub async fn start_workspace_state_stream_core(
WorkspaceStreamEntry {
root_canonical: root_canonical.clone(),
root_display: root_path,
watcher: watcher.take().ok_or_else(|| {
AppCommandError::task_execution_failed(
"Failed to initialize workspace state watcher",
)
})?,
task: task.take().ok_or_else(|| {
AppCommandError::task_execution_failed(
"Failed to initialize workspace state task",
)
})?,
watcher: watcher.take(),
task: task.take(),
ref_count: 1,
state: Arc::clone(&state),
},
@@ -963,9 +987,13 @@ pub async fn stop_workspace_state_stream_core(root_path: String) -> Result<(), A
drop(streams);
if let Some(mut entry) = removed_entry.take() {
let _ = entry.watcher.unwatch(&entry.root_canonical);
drop(entry.watcher);
entry.task.abort();
if let Some(mut watcher) = entry.watcher.take() {
let _ = watcher.unwatch(&entry.root_canonical);
drop(watcher);
}
if let Some(task) = entry.task.take() {
task.abort();
}
}
Ok(())
@@ -1014,7 +1042,7 @@ mod tests {
#[test]
fn workspace_state_core_seq_is_monotonic() {
let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new());
let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new(), false);
let e1 = core.append_event(
"meta".to_string(),
@@ -1037,7 +1065,7 @@ mod tests {
#[test]
fn workspace_state_core_snapshot_incremental_when_since_available() {
let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new());
let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new(), false);
let e1 = core.append_event(
"meta".to_string(),
@@ -1064,7 +1092,7 @@ mod tests {
#[test]
fn workspace_state_core_snapshot_full_when_since_too_old() {
let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new());
let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new(), false);
core.recent_capacity = 1;
core.append_event(