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

@@ -26,10 +26,13 @@ export interface WorkspaceStateView {
tree: FileTreeNode[]
git: WorkspaceGitEntry[]
error: string | null
degraded: boolean
isGitRepo: boolean
}
export interface WorkspaceStateResult extends WorkspaceStateView {
requestResync: (reason?: string) => Promise<void>
restart: () => Promise<void>
}
const WORKSPACE_PROTOCOL_VERSION = 1
@@ -44,6 +47,8 @@ const EMPTY_STATE: WorkspaceStateView = {
tree: [],
git: [],
error: null,
degraded: false,
isGitRepo: true,
}
function normalizeComparePath(path: string): string {
@@ -102,6 +107,8 @@ function applySnapshot(
tree: snapshot.tree_snapshot ?? [],
git: snapshot.git_snapshot ?? [],
error: null,
degraded: snapshot.degraded,
isGitRepo: snapshot.is_git_repo,
}
}
@@ -124,6 +131,8 @@ function applySnapshot(
version: snapshot.version,
health: "healthy",
error: null,
degraded: snapshot.degraded,
isGitRepo: snapshot.is_git_repo,
}
}
@@ -138,6 +147,7 @@ class WorkspaceStateStore {
private stopping: Promise<void> | null = null
private unlisten: (() => void) | null = null
private resyncInFlight: Promise<void> | null = null
private restarting: Promise<void> | null = null
private lifecycleId = 0
private evictionTimer: ReturnType<typeof setTimeout> | null = null
private shutdownTimer: ReturnType<typeof setTimeout> | null = null
@@ -219,6 +229,38 @@ class WorkspaceStateStore {
return this.resyncInFlight
}
restart = async (): Promise<void> => {
if (this.restarting) return this.restarting
const run = async () => {
const prevLifecycleId = this.lifecycleId
this.cancelPendingShutdown()
this.cancelEviction()
this.patchState((prev) => ({
...prev,
health: "resyncing",
}))
await this.shutdown(prevLifecycleId)
this.lifecycleId += 1
const nextLifecycleId = this.lifecycleId
this.hasBaselineSnapshot = false
this.resyncInFlight = null
if (this.refCount > 0) {
await this.ensureStarted(nextLifecycleId)
}
}
this.restarting = run().finally(() => {
this.restarting = null
})
return this.restarting
}
private ensureStarted = async (lifecycleId: number) => {
if (this.started) return
if (this.starting) {
@@ -462,15 +504,22 @@ export function useWorkspaceStateStore(
[store]
)
const restart = useCallback(async () => {
if (!store) return
await store.restart()
}, [store])
if (!rootPath) {
return {
...EMPTY_STATE,
requestResync,
restart,
}
}
return {
...snapshot,
requestResync,
restart,
}
}