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:
@@ -19,6 +19,7 @@ import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useTerminalContext } from "@/contexts/terminal-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
|
||||
import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner"
|
||||
import {
|
||||
createFileTreeEntry,
|
||||
deleteFileTreeEntry,
|
||||
@@ -2001,6 +2002,9 @@ export function FileTreeTab() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{workspaceState.degraded && (
|
||||
<WorkspaceDegradedBanner onRetry={workspaceState.restart} />
|
||||
)}
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<ScrollArea className="flex-1 min-h-0 pb-1" x="scroll">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"
|
||||
import { ChevronsDownUp, ChevronsUpDown, GitBranch } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
@@ -38,6 +38,7 @@ import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
|
||||
import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner"
|
||||
import {
|
||||
deleteFileTreeEntry,
|
||||
gitAddFiles,
|
||||
@@ -1185,14 +1186,30 @@ export function GitChangesTab() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollArea className="h-full min-h-0" x="scroll">
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{workspaceState.degraded && (
|
||||
<WorkspaceDegradedBanner onRetry={workspaceState.restart} />
|
||||
)}
|
||||
<ScrollArea className="flex-1 min-h-0" x="scroll">
|
||||
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("noChanges")}
|
||||
</p>
|
||||
</div>
|
||||
!workspaceState.isGitRepo ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-1 p-6 text-center">
|
||||
<GitBranch
|
||||
className="size-5 text-muted-foreground/60"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-sm font-medium">{t("notAGitRepoTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("notAGitRepoHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("noChanges")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-2 pb-2">
|
||||
{trackedChanges.length > 0 && (
|
||||
@@ -1644,6 +1661,6 @@ export function GitChangesTab() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ import type {
|
||||
GitResetMode,
|
||||
} from "@/lib/types"
|
||||
import { toast } from "sonner"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import { isNotAGitRepoError, toErrorMessage } from "@/lib/app-error"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
const emitEvent = async (event: string, payload?: unknown) => {
|
||||
@@ -696,6 +696,7 @@ export function GitLogTab() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notAGitRepo, setNotAGitRepo] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [openByCommit, setOpenByCommit] = useState<Record<string, boolean>>({})
|
||||
const [branchesByCommit, setBranchesByCommit] = useState<
|
||||
@@ -808,6 +809,7 @@ export function GitLogTab() {
|
||||
setBranchesError({})
|
||||
}
|
||||
setError(null)
|
||||
setNotAGitRepo(false)
|
||||
try {
|
||||
const result = await gitLog(folder.path, 100, branch ?? undefined)
|
||||
setEntries(result.entries)
|
||||
@@ -829,7 +831,12 @@ export function GitLogTab() {
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(toErrorMessage(e))
|
||||
if (isNotAGitRepoError(e)) {
|
||||
setNotAGitRepo(true)
|
||||
setEntries([])
|
||||
} else {
|
||||
setError(toErrorMessage(e))
|
||||
}
|
||||
} finally {
|
||||
if (inline) {
|
||||
setRefreshing(false)
|
||||
@@ -1019,6 +1026,20 @@ export function GitLogTab() {
|
||||
)
|
||||
}
|
||||
|
||||
if (notAGitRepo) {
|
||||
return (
|
||||
<ScrollArea className="h-full px-3 py-3">
|
||||
<div className="flex flex-col items-center justify-center min-h-full gap-1 p-6 text-center">
|
||||
<GitBranch className="size-5 text-muted-foreground/60" aria-hidden />
|
||||
<p className="text-sm font-medium">{t("notAGitRepoTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("notAGitRepoHint")}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ScrollArea className="h-full px-3 py-3">
|
||||
|
||||
@@ -116,14 +116,35 @@ export function FolderTitleBar() {
|
||||
if (!folderPath) return
|
||||
let cancelled = false
|
||||
|
||||
const clearPoll = () => {
|
||||
if (intervalRef.current !== undefined) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const armPoll = () => {
|
||||
if (intervalRef.current !== undefined) return
|
||||
intervalRef.current = setInterval(() => {
|
||||
void doFetch()
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
async function doFetch() {
|
||||
if (document.visibilityState !== "visible") return
|
||||
|
||||
try {
|
||||
const b = await getGitBranch(folderPath)
|
||||
if (!cancelled) setBranch(b)
|
||||
if (cancelled) return
|
||||
setBranch(b)
|
||||
if (b === null) {
|
||||
clearPoll()
|
||||
} else {
|
||||
armPoll()
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setBranch(null)
|
||||
clearPoll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,14 +155,11 @@ export function FolderTitleBar() {
|
||||
}
|
||||
|
||||
void doFetch()
|
||||
intervalRef.current = setInterval(() => {
|
||||
void doFetch()
|
||||
}, 10_000)
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(intervalRef.current)
|
||||
clearPoll()
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
}
|
||||
}, [folderPath])
|
||||
|
||||
59
src/components/layout/workspace-degraded-banner.tsx
Normal file
59
src/components/layout/workspace-degraded-banner.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { RefreshCw, TriangleAlert } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface WorkspaceDegradedBannerProps {
|
||||
onRetry?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function WorkspaceDegradedBanner({
|
||||
onRetry,
|
||||
}: WorkspaceDegradedBannerProps) {
|
||||
const t = useTranslations("Folder.workspaceStatus")
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!onRetry || retrying) return
|
||||
setRetrying(true)
|
||||
try {
|
||||
await onRetry()
|
||||
} finally {
|
||||
setRetrying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2 px-2 py-1.5 text-[11px] text-amber-700 dark:text-amber-400 bg-amber-100/60 dark:bg-amber-900/20 border-b border-amber-300/50 dark:border-amber-800/50"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
title={t("degradedHint")}
|
||||
>
|
||||
<TriangleAlert className="size-3.5 mt-0.5 shrink-0" aria-hidden />
|
||||
<div className="leading-snug flex-1">
|
||||
<span className="font-medium">{t("degradedTitle")}</span>
|
||||
<span className="ml-1 text-muted-foreground">{t("degradedHint")}</span>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-5 px-1.5 -my-0.5 shrink-0 text-amber-700 dark:text-amber-400 hover:bg-amber-200/60 dark:hover:bg-amber-900/40"
|
||||
onClick={() => {
|
||||
void handleRetry()
|
||||
}}
|
||||
disabled={retrying}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${retrying ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="ml-1">{retrying ? t("retrying") : t("retry")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user