diff --git a/src-tauri/src/workspace_state/mod.rs b/src-tauri/src/workspace_state/mod.rs index 18743d4..019538d 100644 --- a/src-tauri/src/workspace_state/mod.rs +++ b/src-tauri/src/workspace_state/mod.rs @@ -68,6 +68,8 @@ pub struct WorkspaceSnapshotResponse { pub tree_snapshot: Option>, pub git_snapshot: Option>, pub deltas: Vec, + pub degraded: bool, + pub is_git_repo: bool, } struct WorkspaceStateCore { @@ -77,6 +79,8 @@ struct WorkspaceStateCore { git_snapshot: Vec, recent_events: VecDeque, recent_capacity: usize, + degraded: bool, + is_git_repo: bool, } impl WorkspaceStateCore { @@ -84,6 +88,7 @@ impl WorkspaceStateCore { root_path: String, tree_snapshot: Vec, git_snapshot: Vec, + 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, + task: Option>, ref_count: usize, state: Arc>, } @@ -569,6 +580,10 @@ async fn git_numstat_map(path: &str) -> HashMap { .unwrap_or_default() } +fn is_git_repo(root: &Path) -> bool { + root.join(".git").exists() +} + async fn collect_git_snapshot(path: &str) -> Result, 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> = None; let mut refreshed_git: Option> = 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::(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( diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index b151c1a..a35ed1e 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -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 (
+ {workspaceState.degraded && ( + + )} diff --git a/src/components/layout/aux-panel-git-changes-tab.tsx b/src/components/layout/aux-panel-git-changes-tab.tsx index 34afdca..3e078fa 100644 --- a/src/components/layout/aux-panel-git-changes-tab.tsx +++ b/src/components/layout/aux-panel-git-changes-tab.tsx @@ -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 ( - <> - +
+ {workspaceState.degraded && ( + + )} + {trackedChanges.length === 0 && untrackedChanges.length === 0 ? ( -
-

- {t("noChanges")} -

-
+ !workspaceState.isGitRepo ? ( +
+ +

{t("notAGitRepoTitle")}

+

+ {t("notAGitRepoHint")} +

+
+ ) : ( +
+

+ {t("noChanges")} +

+
+ ) ) : (
{trackedChanges.length > 0 && ( @@ -1644,6 +1661,6 @@ export function GitChangesTab() { - +
) } diff --git a/src/components/layout/aux-panel-git-log-tab.tsx b/src/components/layout/aux-panel-git-log-tab.tsx index 62f8ea5..7a4224b 100644 --- a/src/components/layout/aux-panel-git-log-tab.tsx +++ b/src/components/layout/aux-panel-git-log-tab.tsx @@ -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(null) + const [notAGitRepo, setNotAGitRepo] = useState(false) const [scrolled, setScrolled] = useState(false) const [openByCommit, setOpenByCommit] = useState>({}) 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 ( + +
+ +

{t("notAGitRepoTitle")}

+

+ {t("notAGitRepoHint")} +

+
+
+ ) + } + if (error) { return ( diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index e33f30c..0cf5ee5 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -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]) diff --git a/src/components/layout/workspace-degraded-banner.tsx b/src/components/layout/workspace-degraded-banner.tsx new file mode 100644 index 0000000..223a826 --- /dev/null +++ b/src/components/layout/workspace-degraded-banner.tsx @@ -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 +} + +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 ( +
+ +
+ {t("degradedTitle")} + {t("degradedHint")} +
+ {onRetry && ( + + )} +
+ ) +} diff --git a/src/hooks/use-workspace-state-store.ts b/src/hooks/use-workspace-state-store.ts index 66fbbc9..718293a 100644 --- a/src/hooks/use-workspace-state-store.ts +++ b/src/hooks/use-workspace-state-store.ts @@ -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 + restart: () => Promise } 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 | null = null private unlisten: (() => void) | null = null private resyncInFlight: Promise | null = null + private restarting: Promise | null = null private lifecycleId = 0 private evictionTimer: ReturnType | null = null private shutdownTimer: ReturnType | null = null @@ -219,6 +229,38 @@ class WorkspaceStateStore { return this.resyncInFlight } + restart = async (): Promise => { + 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, } } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 82d1a07..1e30594 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -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": "توسيع التغييرات المتعقبة", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 95dafcf..4e3897c 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -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", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 93fd548..6b5f2e7 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index f4b808e..b6e4fdb 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -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", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 5fdd9d3..6f8d3cc 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -769,6 +769,12 @@ "applyRightNonConflicting": "Appliquer distant" }, "Folder": { + "workspaceStatus": { + "degradedTitle": "Mises à jour en temps réel indisponibles", + "degradedHint": "L’observateur n’a 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", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 8ec5ae3..e3254e2 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -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": "追跡中の変更を展開", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 7c414b6..178c119 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -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": "추적된 변경 펼치기", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index cfaaa29..a9daba7 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -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", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 9ce6da9..9c3f939 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -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": "展开已跟踪改动", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 2da6fd6..510c024 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -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": "展開已追蹤變更", diff --git a/src/lib/app-error.ts b/src/lib/app-error.ts index 6b85e95..f7a6e3e 100644 --- a/src/lib/app-error.ts +++ b/src/lib/app-error.ts @@ -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) { diff --git a/src/lib/types.ts b/src/lib/types.ts index bfb2a06..25e8973 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 {