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

@@ -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">

View File

@@ -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>
)
}

View File

@@ -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">

View File

@@ -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])

View 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>
)
}

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,
}
}

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "تطبيق البعيد"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "التحديثات الفورية غير متاحة",
"degradedHint": "فشل تشغيل المراقب (مثل رفض الإذن). قم بالتحديث يدويًا لرؤية التغييرات.",
"retry": "إعادة المحاولة",
"retrying": "جارٍ إعادة المحاولة..."
},
"common": {
"all": "الكل",
"cancel": "إلغاء",
@@ -1205,6 +1211,8 @@
"workspace": "مساحة العمل",
"retry": "إعادة المحاولة",
"noCommitsFound": "لم يتم العثور على التزامات",
"notAGitRepoTitle": "ليس مستودع Git",
"notAGitRepoHint": "قم بتهيئة Git من قائمة الفروع أعلاه، أو افتح مستودعًا موجودًا.",
"hash": "بصمة الالتزام",
"copyHash": "نسخ الـ hash",
"copyMessage": "نسخ الرسالة",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "مساحة العمل",
"noChanges": "لا توجد تغييرات محلية",
"notAGitRepoTitle": "ليس مستودع Git",
"notAGitRepoHint": "قم بتهيئة Git من قائمة الفروع أعلاه، أو افتح مستودعًا موجودًا.",
"trackedChanges": "التغييرات المتعقبة ({count})",
"untrackedFiles": "الملفات غير المتعقبة ({count})",
"expandTracked": "توسيع التغييرات المتعقبة",

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "Remote anwenden"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "Live-Aktualisierungen nicht verfügbar",
"degradedHint": "Beobachter konnte nicht gestartet werden (z. B. Berechtigung verweigert). Aktualisiere manuell, um Änderungen zu sehen.",
"retry": "Wiederholen",
"retrying": "Wird wiederholt..."
},
"common": {
"all": "Alle",
"cancel": "Abbrechen",
@@ -1205,6 +1211,8 @@
"workspace": "Arbeitsbereich",
"retry": "Erneut versuchen",
"noCommitsFound": "Keine Commits gefunden",
"notAGitRepoTitle": "Kein Git-Repository",
"notAGitRepoHint": "Initialisiere Git über das Branch-Menü oben oder öffne ein bestehendes Repository.",
"hash": "Hash-Wert",
"copyHash": "Hash kopieren",
"copyMessage": "Nachricht kopieren",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "Arbeitsbereich",
"noChanges": "Keine lokalen Änderungen",
"notAGitRepoTitle": "Kein Git-Repository",
"notAGitRepoHint": "Initialisiere Git über das Branch-Menü oben oder öffne ein bestehendes Repository.",
"trackedChanges": "Verfolgte Änderungen ({count})",
"untrackedFiles": "Nicht verfolgte Dateien ({count})",
"expandTracked": "Verfolgte Änderungen ausklappen",

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "Apply Remote"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "Live updates unavailable",
"degradedHint": "Watcher failed to start (e.g. permission denied). Refresh manually to see changes.",
"retry": "Retry",
"retrying": "Retrying..."
},
"common": {
"all": "All",
"cancel": "Cancel",
@@ -1205,6 +1211,8 @@
"workspace": "workspace",
"retry": "Retry",
"noCommitsFound": "No commits found",
"notAGitRepoTitle": "Not a Git repository",
"notAGitRepoHint": "Initialize Git from the branch menu above, or open a folder that is already tracked.",
"hash": "Hash",
"copyHash": "Copy hash",
"copyMessage": "Copy message",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "workspace",
"noChanges": "No local changes",
"notAGitRepoTitle": "Not a Git repository",
"notAGitRepoHint": "Initialize Git from the branch menu above, or open a folder that is already tracked.",
"trackedChanges": "Tracked changes ({count})",
"untrackedFiles": "Untracked files ({count})",
"expandTracked": "Expand tracked changes",

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "Aplicar remoto"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "Actualizaciones en vivo no disponibles",
"degradedHint": "El observador no pudo iniciarse (por ejemplo, permiso denegado). Actualiza manualmente para ver los cambios.",
"retry": "Reintentar",
"retrying": "Reintentando..."
},
"common": {
"all": "Todo",
"cancel": "Cancelar",
@@ -1205,6 +1211,8 @@
"workspace": "espacio de trabajo",
"retry": "Reintentar",
"noCommitsFound": "No se encontraron commits",
"notAGitRepoTitle": "No es un repositorio Git",
"notAGitRepoHint": "Inicializa Git desde el menú de ramas superior, o abre un repositorio existente.",
"hash": "Hash del commit",
"copyHash": "Copiar hash",
"copyMessage": "Copiar mensaje",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "espacio de trabajo",
"noChanges": "No hay cambios locales",
"notAGitRepoTitle": "No es un repositorio Git",
"notAGitRepoHint": "Inicializa Git desde el menú de ramas superior, o abre un repositorio existente.",
"trackedChanges": "Cambios rastreados ({count})",
"untrackedFiles": "Archivos no rastreados ({count})",
"expandTracked": "Expandir cambios rastreados",

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "Appliquer distant"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "Mises à jour en temps réel indisponibles",
"degradedHint": "Lobservateur na pas pu démarrer (par ex. permission refusée). Actualisez manuellement pour voir les modifications.",
"retry": "Réessayer",
"retrying": "Nouvelle tentative..."
},
"common": {
"all": "Tout",
"cancel": "Annuler",
@@ -1205,6 +1211,8 @@
"workspace": "espace de travail",
"retry": "Réessayer",
"noCommitsFound": "Aucun commit trouvé",
"notAGitRepoTitle": "Pas un dépôt Git",
"notAGitRepoHint": "Initialisez Git depuis le menu des branches ci-dessus, ou ouvrez un dépôt existant.",
"hash": "Empreinte",
"copyHash": "Copier le hash",
"copyMessage": "Copier le message",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "espace de travail",
"noChanges": "Aucun changement local",
"notAGitRepoTitle": "Pas un dépôt Git",
"notAGitRepoHint": "Initialisez Git depuis le menu des branches ci-dessus, ou ouvrez un dépôt existant.",
"trackedChanges": "Changements suivis ({count})",
"untrackedFiles": "Fichiers non suivis ({count})",
"expandTracked": "Développer les changements suivis",

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "リモートを適用"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "リアルタイム更新は利用できません",
"degradedHint": "ウォッチャーの起動に失敗しました(アクセス権限エラーなど)。最新の変更を反映するには手動で更新してください。",
"retry": "再試行",
"retrying": "再試行中..."
},
"common": {
"all": "すべて",
"cancel": "キャンセル",
@@ -1205,6 +1211,8 @@
"workspace": "ワークスペース",
"retry": "再試行",
"noCommitsFound": "コミットが見つかりません",
"notAGitRepoTitle": "Git リポジトリではありません",
"notAGitRepoHint": "上のブランチメニューから Git を初期化するか、既存のリポジトリを開いてください。",
"hash": "ハッシュ",
"copyHash": "ハッシュをコピー",
"copyMessage": "メッセージをコピー",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "ワークスペース",
"noChanges": "ローカルの変更はありません",
"notAGitRepoTitle": "Git リポジトリではありません",
"notAGitRepoHint": "上のブランチメニューから Git を初期化するか、既存のリポジトリを開いてください。",
"trackedChanges": "追跡中の変更 ({count})",
"untrackedFiles": "未追跡ファイル ({count})",
"expandTracked": "追跡中の変更を展開",

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "원격 적용"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "실시간 업데이트를 사용할 수 없음",
"degradedHint": "감시자 시작 실패(권한 거부 등). 최신 변경 사항을 보려면 수동으로 새로 고치세요.",
"retry": "다시 시도",
"retrying": "다시 시도 중..."
},
"common": {
"all": "전체",
"cancel": "취소",
@@ -1205,6 +1211,8 @@
"workspace": "작업 공간",
"retry": "다시 시도",
"noCommitsFound": "커밋을 찾을 수 없습니다",
"notAGitRepoTitle": "Git 저장소가 아닙니다",
"notAGitRepoHint": "위의 브랜치 메뉴에서 Git을 초기화하거나 기존 저장소를 여세요.",
"hash": "해시",
"copyHash": "해시 복사",
"copyMessage": "메시지 복사",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "작업 공간",
"noChanges": "로컬 변경 사항이 없습니다",
"notAGitRepoTitle": "Git 저장소가 아닙니다",
"notAGitRepoHint": "위의 브랜치 메뉴에서 Git을 초기화하거나 기존 저장소를 여세요.",
"trackedChanges": "추적된 변경 ({count})",
"untrackedFiles": "추적되지 않은 파일 ({count})",
"expandTracked": "추적된 변경 펼치기",

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "Aplicar remoto"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "Atualizações em tempo real indisponíveis",
"degradedHint": "O observador falhou ao iniciar (por exemplo, permissão negada). Atualize manualmente para ver as mudanças.",
"retry": "Tentar novamente",
"retrying": "Tentando novamente..."
},
"common": {
"all": "Todos",
"cancel": "Cancelar",
@@ -1205,6 +1211,8 @@
"workspace": "espaço de trabalho",
"retry": "Tentar novamente",
"noCommitsFound": "Nenhum commit encontrado",
"notAGitRepoTitle": "Não é um repositório Git",
"notAGitRepoHint": "Inicialize o Git pelo menu de ramificações acima, ou abra um repositório existente.",
"hash": "Hash do commit",
"copyHash": "Copiar hash",
"copyMessage": "Copiar mensagem",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "espaço de trabalho",
"noChanges": "Sem alterações locais",
"notAGitRepoTitle": "Não é um repositório Git",
"notAGitRepoHint": "Inicialize o Git pelo menu de ramificações acima, ou abra um repositório existente.",
"trackedChanges": "Alterações rastreadas ({count})",
"untrackedFiles": "Arquivos não rastreados ({count})",
"expandTracked": "Expandir alterações rastreadas",

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "应用远程"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "实时更新不可用",
"degradedHint": "监听器启动失败(如目录无读取权限)。请手动刷新以获取最新变更。",
"retry": "重试",
"retrying": "重试中..."
},
"common": {
"all": "全部",
"cancel": "取消",
@@ -1205,6 +1211,8 @@
"workspace": "工作区",
"retry": "重试",
"noCommitsFound": "未找到提交记录",
"notAGitRepoTitle": "不是 Git 仓库",
"notAGitRepoHint": "可从上方分支菜单初始化 Git或打开已有的 Git 仓库。",
"hash": "Hash",
"copyHash": "复制哈希",
"copyMessage": "复制提交消息",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "工作区",
"noChanges": "暂无本地改动",
"notAGitRepoTitle": "不是 Git 仓库",
"notAGitRepoHint": "可从上方分支菜单初始化 Git或打开已有的 Git 仓库。",
"trackedChanges": "本地已跟踪改动 ({count})",
"untrackedFiles": "本地未跟踪文件 ({count})",
"expandTracked": "展开已跟踪改动",

View File

@@ -769,6 +769,12 @@
"applyRightNonConflicting": "套用遠端"
},
"Folder": {
"workspaceStatus": {
"degradedTitle": "即時更新不可用",
"degradedHint": "監聽器啟動失敗(如目錄無讀取權限)。請手動重新整理以取得最新變更。",
"retry": "重試",
"retrying": "重試中..."
},
"common": {
"all": "全部",
"cancel": "取消",
@@ -1205,6 +1211,8 @@
"workspace": "工作區",
"retry": "重試",
"noCommitsFound": "未找到提交記錄",
"notAGitRepoTitle": "不是 Git 儲存庫",
"notAGitRepoHint": "可從上方分支選單初始化 Git或開啟已有的 Git 儲存庫。",
"hash": "Hash",
"copyHash": "複製雜湊",
"copyMessage": "複製提交訊息",
@@ -1281,6 +1289,8 @@
"gitChangesTab": {
"workspace": "工作區",
"noChanges": "暫無本地變更",
"notAGitRepoTitle": "不是 Git 儲存庫",
"notAGitRepoHint": "可從上方分支選單初始化 Git或開啟已有的 Git 儲存庫。",
"trackedChanges": "本地已追蹤變更 ({count})",
"untrackedFiles": "本地未追蹤檔案 ({count})",
"expandTracked": "展開已追蹤變更",

View File

@@ -46,6 +46,14 @@ export function extractAppCommandError(error: unknown): AppCommandError | null {
}
}
export function isNotAGitRepoError(error: unknown): boolean {
const appError = extractAppCommandError(error)
const candidates = [appError?.detail, appError?.message]
return candidates.some(
(text) => typeof text === "string" && /not a git repository/i.test(text)
)
}
export function toErrorMessage(error: unknown): string {
const appError = extractAppCommandError(error)
if (appError) {

View File

@@ -897,6 +897,8 @@ export interface WorkspaceSnapshotResponse {
tree_snapshot: FileTreeNode[] | null
git_snapshot: WorkspaceGitEntry[] | null
deltas: WorkspaceDeltaEnvelope[]
degraded: boolean
is_git_repo: boolean
}
export interface GitLogResult {