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:
@@ -68,6 +68,8 @@ pub struct WorkspaceSnapshotResponse {
|
|||||||
pub tree_snapshot: Option<Vec<FileTreeNode>>,
|
pub tree_snapshot: Option<Vec<FileTreeNode>>,
|
||||||
pub git_snapshot: Option<Vec<WorkspaceGitEntry>>,
|
pub git_snapshot: Option<Vec<WorkspaceGitEntry>>,
|
||||||
pub deltas: Vec<WorkspaceDeltaEnvelope>,
|
pub deltas: Vec<WorkspaceDeltaEnvelope>,
|
||||||
|
pub degraded: bool,
|
||||||
|
pub is_git_repo: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WorkspaceStateCore {
|
struct WorkspaceStateCore {
|
||||||
@@ -77,6 +79,8 @@ struct WorkspaceStateCore {
|
|||||||
git_snapshot: Vec<WorkspaceGitEntry>,
|
git_snapshot: Vec<WorkspaceGitEntry>,
|
||||||
recent_events: VecDeque<WorkspaceDeltaEnvelope>,
|
recent_events: VecDeque<WorkspaceDeltaEnvelope>,
|
||||||
recent_capacity: usize,
|
recent_capacity: usize,
|
||||||
|
degraded: bool,
|
||||||
|
is_git_repo: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceStateCore {
|
impl WorkspaceStateCore {
|
||||||
@@ -84,6 +88,7 @@ impl WorkspaceStateCore {
|
|||||||
root_path: String,
|
root_path: String,
|
||||||
tree_snapshot: Vec<FileTreeNode>,
|
tree_snapshot: Vec<FileTreeNode>,
|
||||||
git_snapshot: Vec<WorkspaceGitEntry>,
|
git_snapshot: Vec<WorkspaceGitEntry>,
|
||||||
|
is_git_repo: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
root_path,
|
root_path,
|
||||||
@@ -92,6 +97,8 @@ impl WorkspaceStateCore {
|
|||||||
git_snapshot,
|
git_snapshot,
|
||||||
recent_events: VecDeque::new(),
|
recent_events: VecDeque::new(),
|
||||||
recent_capacity: RECENT_EVENT_CAPACITY,
|
recent_capacity: RECENT_EVENT_CAPACITY,
|
||||||
|
degraded: false,
|
||||||
|
is_git_repo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +150,8 @@ impl WorkspaceStateCore {
|
|||||||
tree_snapshot: None,
|
tree_snapshot: None,
|
||||||
git_snapshot: None,
|
git_snapshot: None,
|
||||||
deltas,
|
deltas,
|
||||||
|
degraded: self.degraded,
|
||||||
|
is_git_repo: self.is_git_repo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +164,8 @@ impl WorkspaceStateCore {
|
|||||||
tree_snapshot: Some(self.tree_snapshot.clone()),
|
tree_snapshot: Some(self.tree_snapshot.clone()),
|
||||||
git_snapshot: Some(self.git_snapshot.clone()),
|
git_snapshot: Some(self.git_snapshot.clone()),
|
||||||
deltas: Vec::new(),
|
deltas: Vec::new(),
|
||||||
|
degraded: self.degraded,
|
||||||
|
is_git_repo: self.is_git_repo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,8 +223,8 @@ impl WorkspaceStateCore {
|
|||||||
struct WorkspaceStreamEntry {
|
struct WorkspaceStreamEntry {
|
||||||
root_canonical: PathBuf,
|
root_canonical: PathBuf,
|
||||||
root_display: String,
|
root_display: String,
|
||||||
watcher: RecommendedWatcher,
|
watcher: Option<RecommendedWatcher>,
|
||||||
task: tokio::task::JoinHandle<()>,
|
task: Option<tokio::task::JoinHandle<()>>,
|
||||||
ref_count: usize,
|
ref_count: usize,
|
||||||
state: Arc<Mutex<WorkspaceStateCore>>,
|
state: Arc<Mutex<WorkspaceStateCore>>,
|
||||||
}
|
}
|
||||||
@@ -569,6 +580,10 @@ async fn git_numstat_map(path: &str) -> HashMap<String, (i32, i32)> {
|
|||||||
.unwrap_or_default()
|
.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> {
|
async fn collect_git_snapshot(path: &str) -> Result<Vec<WorkspaceGitEntry>, AppCommandError> {
|
||||||
let status_entries = folders::git_status(path.to_string(), Some(true)).await?;
|
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_tree = batch.overflowed || event_kind_hint != "modify";
|
||||||
let should_refresh_git = if batch.overflowed {
|
let is_git = is_git_repo(root_canonical);
|
||||||
true
|
let should_refresh_git = is_git
|
||||||
} else {
|
&& (batch.overflowed
|
||||||
should_refresh_git_status_for_paths(root_display, &changed_paths).await
|
|| should_refresh_git_status_for_paths(root_display, &changed_paths).await);
|
||||||
};
|
|
||||||
|
|
||||||
let mut payload = Vec::new();
|
let mut payload = Vec::new();
|
||||||
let mut requires_resync = false;
|
|
||||||
let mut refreshed_tree: Option<Vec<FileTreeNode>> = None;
|
let mut refreshed_tree: Option<Vec<FileTreeNode>> = None;
|
||||||
let mut refreshed_git: Option<Vec<WorkspaceGitEntry>> = 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 {
|
if should_refresh_tree {
|
||||||
match folders::get_file_tree(root_display.to_string(), Some(WORKSPACE_TREE_MAX_DEPTH)).await
|
match folders::get_file_tree(root_display.to_string(), Some(WORKSPACE_TREE_MAX_DEPTH)).await
|
||||||
{
|
{
|
||||||
Ok(tree) => refreshed_tree = Some(tree),
|
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 {
|
if should_refresh_git {
|
||||||
match collect_git_snapshot(root_display).await {
|
match collect_git_snapshot(root_display).await {
|
||||||
Ok(git_snapshot) => refreshed_git = Some(git_snapshot),
|
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 event = {
|
||||||
let mut guard = match state.lock() {
|
let mut guard = match state.lock() {
|
||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !requires_resync {
|
if let Some(tree) = refreshed_tree {
|
||||||
if let Some(tree) = refreshed_tree {
|
if tree != guard.tree_snapshot {
|
||||||
if tree != guard.tree_snapshot {
|
payload.push(WorkspaceDelta::TreeReplace { nodes: tree });
|
||||||
payload.push(WorkspaceDelta::TreeReplace { nodes: tree });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(git_snapshot) = refreshed_git {
|
}
|
||||||
if git_snapshot != guard.git_snapshot {
|
if let Some(git_snapshot) = refreshed_git {
|
||||||
payload.push(WorkspaceDelta::GitReplace {
|
if git_snapshot != guard.git_snapshot {
|
||||||
entries: git_snapshot,
|
payload.push(WorkspaceDelta::GitReplace {
|
||||||
});
|
entries: git_snapshot,
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if payload.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let kind = if requires_resync {
|
if payload.is_empty() {
|
||||||
"resync_hint".to_string()
|
return;
|
||||||
} else if payload
|
}
|
||||||
|
|
||||||
|
let kind = if payload
|
||||||
.iter()
|
.iter()
|
||||||
.any(|delta| matches!(delta, WorkspaceDelta::TreeReplace { .. }))
|
.any(|delta| matches!(delta, WorkspaceDelta::TreeReplace { .. }))
|
||||||
{
|
{
|
||||||
@@ -695,7 +709,7 @@ async fn flush_watch_batch(
|
|||||||
"meta".to_string()
|
"meta".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
guard.append_event(kind, payload, requires_resync)
|
guard.append_event(kind, payload, false)
|
||||||
};
|
};
|
||||||
|
|
||||||
emit_event(emitter, "folder://workspace-state-event", event);
|
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))
|
let initial_tree = folders::get_file_tree(root_path.clone(), Some(WORKSPACE_TREE_MAX_DEPTH))
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.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(
|
let state = Arc::new(Mutex::new(WorkspaceStateCore::new(
|
||||||
root_path.clone(),
|
root_path.clone(),
|
||||||
initial_tree,
|
initial_tree,
|
||||||
initial_git,
|
initial_git,
|
||||||
|
initial_is_git_repo,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
let (event_tx, event_rx) = mpsc::channel::<notify::Event>(WATCH_EVENT_CHANNEL_CAPACITY);
|
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()
|
.as_mut()
|
||||||
.ok_or_else(|| AppCommandError::task_execution_failed("Failed to create watcher"))?
|
.ok_or_else(|| AppCommandError::task_execution_failed("Failed to create watcher"))?
|
||||||
.watch(&root_canonical, RecursiveMode::Recursive)
|
.watch(&root_canonical, RecursiveMode::Recursive);
|
||||||
.map_err(|e| {
|
|
||||||
AppCommandError::io_error("Failed to start workspace state watcher")
|
if let Err(err) = watch_result {
|
||||||
.with_detail(e.to_string())
|
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 (should_cleanup_new_stream, start_snapshot) = {
|
||||||
let mut streams = WORKSPACE_STREAMS.lock().map_err(|_| {
|
let mut streams = WORKSPACE_STREAMS.lock().map_err(|_| {
|
||||||
@@ -896,16 +928,8 @@ pub async fn start_workspace_state_stream_core(
|
|||||||
WorkspaceStreamEntry {
|
WorkspaceStreamEntry {
|
||||||
root_canonical: root_canonical.clone(),
|
root_canonical: root_canonical.clone(),
|
||||||
root_display: root_path,
|
root_display: root_path,
|
||||||
watcher: watcher.take().ok_or_else(|| {
|
watcher: watcher.take(),
|
||||||
AppCommandError::task_execution_failed(
|
task: task.take(),
|
||||||
"Failed to initialize workspace state watcher",
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
task: task.take().ok_or_else(|| {
|
|
||||||
AppCommandError::task_execution_failed(
|
|
||||||
"Failed to initialize workspace state task",
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
ref_count: 1,
|
ref_count: 1,
|
||||||
state: Arc::clone(&state),
|
state: Arc::clone(&state),
|
||||||
},
|
},
|
||||||
@@ -963,9 +987,13 @@ pub async fn stop_workspace_state_stream_core(root_path: String) -> Result<(), A
|
|||||||
drop(streams);
|
drop(streams);
|
||||||
|
|
||||||
if let Some(mut entry) = removed_entry.take() {
|
if let Some(mut entry) = removed_entry.take() {
|
||||||
let _ = entry.watcher.unwatch(&entry.root_canonical);
|
if let Some(mut watcher) = entry.watcher.take() {
|
||||||
drop(entry.watcher);
|
let _ = watcher.unwatch(&entry.root_canonical);
|
||||||
entry.task.abort();
|
drop(watcher);
|
||||||
|
}
|
||||||
|
if let Some(task) = entry.task.take() {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1014,7 +1042,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_state_core_seq_is_monotonic() {
|
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(
|
let e1 = core.append_event(
|
||||||
"meta".to_string(),
|
"meta".to_string(),
|
||||||
@@ -1037,7 +1065,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_state_core_snapshot_incremental_when_since_available() {
|
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(
|
let e1 = core.append_event(
|
||||||
"meta".to_string(),
|
"meta".to_string(),
|
||||||
@@ -1064,7 +1092,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_state_core_snapshot_full_when_since_too_old() {
|
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.recent_capacity = 1;
|
||||||
|
|
||||||
core.append_event(
|
core.append_event(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useTabContext } from "@/contexts/tab-context"
|
|||||||
import { useTerminalContext } from "@/contexts/terminal-context"
|
import { useTerminalContext } from "@/contexts/terminal-context"
|
||||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||||
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
|
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
|
||||||
|
import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner"
|
||||||
import {
|
import {
|
||||||
createFileTreeEntry,
|
createFileTreeEntry,
|
||||||
deleteFileTreeEntry,
|
deleteFileTreeEntry,
|
||||||
@@ -2001,6 +2002,9 @@ export function FileTreeTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
{workspaceState.degraded && (
|
||||||
|
<WorkspaceDegradedBanner onRetry={workspaceState.restart} />
|
||||||
|
)}
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<ScrollArea className="flex-1 min-h-0 pb-1" x="scroll">
|
<ScrollArea className="flex-1 min-h-0 pb-1" x="scroll">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"
|
import { ChevronsDownUp, ChevronsUpDown, GitBranch } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +38,7 @@ import { useFolderContext } from "@/contexts/folder-context"
|
|||||||
import { useTabContext } from "@/contexts/tab-context"
|
import { useTabContext } from "@/contexts/tab-context"
|
||||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||||
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
|
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
|
||||||
|
import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner"
|
||||||
import {
|
import {
|
||||||
deleteFileTreeEntry,
|
deleteFileTreeEntry,
|
||||||
gitAddFiles,
|
gitAddFiles,
|
||||||
@@ -1185,14 +1186,30 @@ export function GitChangesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<ScrollArea className="h-full min-h-0" x="scroll">
|
{workspaceState.degraded && (
|
||||||
|
<WorkspaceDegradedBanner onRetry={workspaceState.restart} />
|
||||||
|
)}
|
||||||
|
<ScrollArea className="flex-1 min-h-0" x="scroll">
|
||||||
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (
|
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full p-4">
|
!workspaceState.isGitRepo ? (
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<div className="flex flex-col items-center justify-center h-full gap-1 p-6 text-center">
|
||||||
{t("noChanges")}
|
<GitBranch
|
||||||
</p>
|
className="size-5 text-muted-foreground/60"
|
||||||
</div>
|
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">
|
<div className="space-y-2 pb-2">
|
||||||
{trackedChanges.length > 0 && (
|
{trackedChanges.length > 0 && (
|
||||||
@@ -1644,6 +1661,6 @@ export function GitChangesTab() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ import type {
|
|||||||
GitResetMode,
|
GitResetMode,
|
||||||
} from "@/lib/types"
|
} from "@/lib/types"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { isNotAGitRepoError, toErrorMessage } from "@/lib/app-error"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
const emitEvent = async (event: string, payload?: unknown) => {
|
const emitEvent = async (event: string, payload?: unknown) => {
|
||||||
@@ -696,6 +696,7 @@ export function GitLogTab() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [notAGitRepo, setNotAGitRepo] = useState(false)
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const [openByCommit, setOpenByCommit] = useState<Record<string, boolean>>({})
|
const [openByCommit, setOpenByCommit] = useState<Record<string, boolean>>({})
|
||||||
const [branchesByCommit, setBranchesByCommit] = useState<
|
const [branchesByCommit, setBranchesByCommit] = useState<
|
||||||
@@ -808,6 +809,7 @@ export function GitLogTab() {
|
|||||||
setBranchesError({})
|
setBranchesError({})
|
||||||
}
|
}
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setNotAGitRepo(false)
|
||||||
try {
|
try {
|
||||||
const result = await gitLog(folder.path, 100, branch ?? undefined)
|
const result = await gitLog(folder.path, 100, branch ?? undefined)
|
||||||
setEntries(result.entries)
|
setEntries(result.entries)
|
||||||
@@ -829,7 +831,12 @@ export function GitLogTab() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(toErrorMessage(e))
|
if (isNotAGitRepoError(e)) {
|
||||||
|
setNotAGitRepo(true)
|
||||||
|
setEntries([])
|
||||||
|
} else {
|
||||||
|
setError(toErrorMessage(e))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (inline) {
|
if (inline) {
|
||||||
setRefreshing(false)
|
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full px-3 py-3">
|
<ScrollArea className="h-full px-3 py-3">
|
||||||
|
|||||||
@@ -116,14 +116,35 @@ export function FolderTitleBar() {
|
|||||||
if (!folderPath) return
|
if (!folderPath) return
|
||||||
let cancelled = false
|
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() {
|
async function doFetch() {
|
||||||
if (document.visibilityState !== "visible") return
|
if (document.visibilityState !== "visible") return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const b = await getGitBranch(folderPath)
|
const b = await getGitBranch(folderPath)
|
||||||
if (!cancelled) setBranch(b)
|
if (cancelled) return
|
||||||
|
setBranch(b)
|
||||||
|
if (b === null) {
|
||||||
|
clearPoll()
|
||||||
|
} else {
|
||||||
|
armPoll()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setBranch(null)
|
if (!cancelled) setBranch(null)
|
||||||
|
clearPoll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,14 +155,11 @@ export function FolderTitleBar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void doFetch()
|
void doFetch()
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
void doFetch()
|
|
||||||
}, 10_000)
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
clearInterval(intervalRef.current)
|
clearPoll()
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||||
}
|
}
|
||||||
}, [folderPath])
|
}, [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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,10 +26,13 @@ export interface WorkspaceStateView {
|
|||||||
tree: FileTreeNode[]
|
tree: FileTreeNode[]
|
||||||
git: WorkspaceGitEntry[]
|
git: WorkspaceGitEntry[]
|
||||||
error: string | null
|
error: string | null
|
||||||
|
degraded: boolean
|
||||||
|
isGitRepo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceStateResult extends WorkspaceStateView {
|
export interface WorkspaceStateResult extends WorkspaceStateView {
|
||||||
requestResync: (reason?: string) => Promise<void>
|
requestResync: (reason?: string) => Promise<void>
|
||||||
|
restart: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORKSPACE_PROTOCOL_VERSION = 1
|
const WORKSPACE_PROTOCOL_VERSION = 1
|
||||||
@@ -44,6 +47,8 @@ const EMPTY_STATE: WorkspaceStateView = {
|
|||||||
tree: [],
|
tree: [],
|
||||||
git: [],
|
git: [],
|
||||||
error: null,
|
error: null,
|
||||||
|
degraded: false,
|
||||||
|
isGitRepo: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeComparePath(path: string): string {
|
function normalizeComparePath(path: string): string {
|
||||||
@@ -102,6 +107,8 @@ function applySnapshot(
|
|||||||
tree: snapshot.tree_snapshot ?? [],
|
tree: snapshot.tree_snapshot ?? [],
|
||||||
git: snapshot.git_snapshot ?? [],
|
git: snapshot.git_snapshot ?? [],
|
||||||
error: null,
|
error: null,
|
||||||
|
degraded: snapshot.degraded,
|
||||||
|
isGitRepo: snapshot.is_git_repo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +131,8 @@ function applySnapshot(
|
|||||||
version: snapshot.version,
|
version: snapshot.version,
|
||||||
health: "healthy",
|
health: "healthy",
|
||||||
error: null,
|
error: null,
|
||||||
|
degraded: snapshot.degraded,
|
||||||
|
isGitRepo: snapshot.is_git_repo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +147,7 @@ class WorkspaceStateStore {
|
|||||||
private stopping: Promise<void> | null = null
|
private stopping: Promise<void> | null = null
|
||||||
private unlisten: (() => void) | null = null
|
private unlisten: (() => void) | null = null
|
||||||
private resyncInFlight: Promise<void> | null = null
|
private resyncInFlight: Promise<void> | null = null
|
||||||
|
private restarting: Promise<void> | null = null
|
||||||
private lifecycleId = 0
|
private lifecycleId = 0
|
||||||
private evictionTimer: ReturnType<typeof setTimeout> | null = null
|
private evictionTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null
|
private shutdownTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -219,6 +229,38 @@ class WorkspaceStateStore {
|
|||||||
return this.resyncInFlight
|
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) => {
|
private ensureStarted = async (lifecycleId: number) => {
|
||||||
if (this.started) return
|
if (this.started) return
|
||||||
if (this.starting) {
|
if (this.starting) {
|
||||||
@@ -462,15 +504,22 @@ export function useWorkspaceStateStore(
|
|||||||
[store]
|
[store]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const restart = useCallback(async () => {
|
||||||
|
if (!store) return
|
||||||
|
await store.restart()
|
||||||
|
}, [store])
|
||||||
|
|
||||||
if (!rootPath) {
|
if (!rootPath) {
|
||||||
return {
|
return {
|
||||||
...EMPTY_STATE,
|
...EMPTY_STATE,
|
||||||
requestResync,
|
requestResync,
|
||||||
|
restart,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
requestResync,
|
requestResync,
|
||||||
|
restart,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "تطبيق البعيد"
|
"applyRightNonConflicting": "تطبيق البعيد"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"Folder": {
|
||||||
|
"workspaceStatus": {
|
||||||
|
"degradedTitle": "التحديثات الفورية غير متاحة",
|
||||||
|
"degradedHint": "فشل تشغيل المراقب (مثل رفض الإذن). قم بالتحديث يدويًا لرؤية التغييرات.",
|
||||||
|
"retry": "إعادة المحاولة",
|
||||||
|
"retrying": "جارٍ إعادة المحاولة..."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"all": "الكل",
|
"all": "الكل",
|
||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "مساحة العمل",
|
"workspace": "مساحة العمل",
|
||||||
"retry": "إعادة المحاولة",
|
"retry": "إعادة المحاولة",
|
||||||
"noCommitsFound": "لم يتم العثور على التزامات",
|
"noCommitsFound": "لم يتم العثور على التزامات",
|
||||||
|
"notAGitRepoTitle": "ليس مستودع Git",
|
||||||
|
"notAGitRepoHint": "قم بتهيئة Git من قائمة الفروع أعلاه، أو افتح مستودعًا موجودًا.",
|
||||||
"hash": "بصمة الالتزام",
|
"hash": "بصمة الالتزام",
|
||||||
"copyHash": "نسخ الـ hash",
|
"copyHash": "نسخ الـ hash",
|
||||||
"copyMessage": "نسخ الرسالة",
|
"copyMessage": "نسخ الرسالة",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "مساحة العمل",
|
"workspace": "مساحة العمل",
|
||||||
"noChanges": "لا توجد تغييرات محلية",
|
"noChanges": "لا توجد تغييرات محلية",
|
||||||
|
"notAGitRepoTitle": "ليس مستودع Git",
|
||||||
|
"notAGitRepoHint": "قم بتهيئة Git من قائمة الفروع أعلاه، أو افتح مستودعًا موجودًا.",
|
||||||
"trackedChanges": "التغييرات المتعقبة ({count})",
|
"trackedChanges": "التغييرات المتعقبة ({count})",
|
||||||
"untrackedFiles": "الملفات غير المتعقبة ({count})",
|
"untrackedFiles": "الملفات غير المتعقبة ({count})",
|
||||||
"expandTracked": "توسيع التغييرات المتعقبة",
|
"expandTracked": "توسيع التغييرات المتعقبة",
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "Remote anwenden"
|
"applyRightNonConflicting": "Remote anwenden"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"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": {
|
"common": {
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "Arbeitsbereich",
|
"workspace": "Arbeitsbereich",
|
||||||
"retry": "Erneut versuchen",
|
"retry": "Erneut versuchen",
|
||||||
"noCommitsFound": "Keine Commits gefunden",
|
"noCommitsFound": "Keine Commits gefunden",
|
||||||
|
"notAGitRepoTitle": "Kein Git-Repository",
|
||||||
|
"notAGitRepoHint": "Initialisiere Git über das Branch-Menü oben oder öffne ein bestehendes Repository.",
|
||||||
"hash": "Hash-Wert",
|
"hash": "Hash-Wert",
|
||||||
"copyHash": "Hash kopieren",
|
"copyHash": "Hash kopieren",
|
||||||
"copyMessage": "Nachricht kopieren",
|
"copyMessage": "Nachricht kopieren",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "Arbeitsbereich",
|
"workspace": "Arbeitsbereich",
|
||||||
"noChanges": "Keine lokalen Änderungen",
|
"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})",
|
"trackedChanges": "Verfolgte Änderungen ({count})",
|
||||||
"untrackedFiles": "Nicht verfolgte Dateien ({count})",
|
"untrackedFiles": "Nicht verfolgte Dateien ({count})",
|
||||||
"expandTracked": "Verfolgte Änderungen ausklappen",
|
"expandTracked": "Verfolgte Änderungen ausklappen",
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "Apply Remote"
|
"applyRightNonConflicting": "Apply Remote"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"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": {
|
"common": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "workspace",
|
"workspace": "workspace",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"noCommitsFound": "No commits found",
|
"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",
|
"hash": "Hash",
|
||||||
"copyHash": "Copy hash",
|
"copyHash": "Copy hash",
|
||||||
"copyMessage": "Copy message",
|
"copyMessage": "Copy message",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "workspace",
|
"workspace": "workspace",
|
||||||
"noChanges": "No local changes",
|
"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})",
|
"trackedChanges": "Tracked changes ({count})",
|
||||||
"untrackedFiles": "Untracked files ({count})",
|
"untrackedFiles": "Untracked files ({count})",
|
||||||
"expandTracked": "Expand tracked changes",
|
"expandTracked": "Expand tracked changes",
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "Aplicar remoto"
|
"applyRightNonConflicting": "Aplicar remoto"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"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": {
|
"common": {
|
||||||
"all": "Todo",
|
"all": "Todo",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "espacio de trabajo",
|
"workspace": "espacio de trabajo",
|
||||||
"retry": "Reintentar",
|
"retry": "Reintentar",
|
||||||
"noCommitsFound": "No se encontraron commits",
|
"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",
|
"hash": "Hash del commit",
|
||||||
"copyHash": "Copiar hash",
|
"copyHash": "Copiar hash",
|
||||||
"copyMessage": "Copiar mensaje",
|
"copyMessage": "Copiar mensaje",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "espacio de trabajo",
|
"workspace": "espacio de trabajo",
|
||||||
"noChanges": "No hay cambios locales",
|
"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})",
|
"trackedChanges": "Cambios rastreados ({count})",
|
||||||
"untrackedFiles": "Archivos no rastreados ({count})",
|
"untrackedFiles": "Archivos no rastreados ({count})",
|
||||||
"expandTracked": "Expandir cambios rastreados",
|
"expandTracked": "Expandir cambios rastreados",
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "Appliquer distant"
|
"applyRightNonConflicting": "Appliquer distant"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"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": {
|
"common": {
|
||||||
"all": "Tout",
|
"all": "Tout",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "espace de travail",
|
"workspace": "espace de travail",
|
||||||
"retry": "Réessayer",
|
"retry": "Réessayer",
|
||||||
"noCommitsFound": "Aucun commit trouvé",
|
"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",
|
"hash": "Empreinte",
|
||||||
"copyHash": "Copier le hash",
|
"copyHash": "Copier le hash",
|
||||||
"copyMessage": "Copier le message",
|
"copyMessage": "Copier le message",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "espace de travail",
|
"workspace": "espace de travail",
|
||||||
"noChanges": "Aucun changement local",
|
"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})",
|
"trackedChanges": "Changements suivis ({count})",
|
||||||
"untrackedFiles": "Fichiers non suivis ({count})",
|
"untrackedFiles": "Fichiers non suivis ({count})",
|
||||||
"expandTracked": "Développer les changements suivis",
|
"expandTracked": "Développer les changements suivis",
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "リモートを適用"
|
"applyRightNonConflicting": "リモートを適用"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"Folder": {
|
||||||
|
"workspaceStatus": {
|
||||||
|
"degradedTitle": "リアルタイム更新は利用できません",
|
||||||
|
"degradedHint": "ウォッチャーの起動に失敗しました(アクセス権限エラーなど)。最新の変更を反映するには手動で更新してください。",
|
||||||
|
"retry": "再試行",
|
||||||
|
"retrying": "再試行中..."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"all": "すべて",
|
"all": "すべて",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "ワークスペース",
|
"workspace": "ワークスペース",
|
||||||
"retry": "再試行",
|
"retry": "再試行",
|
||||||
"noCommitsFound": "コミットが見つかりません",
|
"noCommitsFound": "コミットが見つかりません",
|
||||||
|
"notAGitRepoTitle": "Git リポジトリではありません",
|
||||||
|
"notAGitRepoHint": "上のブランチメニューから Git を初期化するか、既存のリポジトリを開いてください。",
|
||||||
"hash": "ハッシュ",
|
"hash": "ハッシュ",
|
||||||
"copyHash": "ハッシュをコピー",
|
"copyHash": "ハッシュをコピー",
|
||||||
"copyMessage": "メッセージをコピー",
|
"copyMessage": "メッセージをコピー",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "ワークスペース",
|
"workspace": "ワークスペース",
|
||||||
"noChanges": "ローカルの変更はありません",
|
"noChanges": "ローカルの変更はありません",
|
||||||
|
"notAGitRepoTitle": "Git リポジトリではありません",
|
||||||
|
"notAGitRepoHint": "上のブランチメニューから Git を初期化するか、既存のリポジトリを開いてください。",
|
||||||
"trackedChanges": "追跡中の変更 ({count})",
|
"trackedChanges": "追跡中の変更 ({count})",
|
||||||
"untrackedFiles": "未追跡ファイル ({count})",
|
"untrackedFiles": "未追跡ファイル ({count})",
|
||||||
"expandTracked": "追跡中の変更を展開",
|
"expandTracked": "追跡中の変更を展開",
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "원격 적용"
|
"applyRightNonConflicting": "원격 적용"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"Folder": {
|
||||||
|
"workspaceStatus": {
|
||||||
|
"degradedTitle": "실시간 업데이트를 사용할 수 없음",
|
||||||
|
"degradedHint": "감시자 시작 실패(권한 거부 등). 최신 변경 사항을 보려면 수동으로 새로 고치세요.",
|
||||||
|
"retry": "다시 시도",
|
||||||
|
"retrying": "다시 시도 중..."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"all": "전체",
|
"all": "전체",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "작업 공간",
|
"workspace": "작업 공간",
|
||||||
"retry": "다시 시도",
|
"retry": "다시 시도",
|
||||||
"noCommitsFound": "커밋을 찾을 수 없습니다",
|
"noCommitsFound": "커밋을 찾을 수 없습니다",
|
||||||
|
"notAGitRepoTitle": "Git 저장소가 아닙니다",
|
||||||
|
"notAGitRepoHint": "위의 브랜치 메뉴에서 Git을 초기화하거나 기존 저장소를 여세요.",
|
||||||
"hash": "해시",
|
"hash": "해시",
|
||||||
"copyHash": "해시 복사",
|
"copyHash": "해시 복사",
|
||||||
"copyMessage": "메시지 복사",
|
"copyMessage": "메시지 복사",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "작업 공간",
|
"workspace": "작업 공간",
|
||||||
"noChanges": "로컬 변경 사항이 없습니다",
|
"noChanges": "로컬 변경 사항이 없습니다",
|
||||||
|
"notAGitRepoTitle": "Git 저장소가 아닙니다",
|
||||||
|
"notAGitRepoHint": "위의 브랜치 메뉴에서 Git을 초기화하거나 기존 저장소를 여세요.",
|
||||||
"trackedChanges": "추적된 변경 ({count})",
|
"trackedChanges": "추적된 변경 ({count})",
|
||||||
"untrackedFiles": "추적되지 않은 파일 ({count})",
|
"untrackedFiles": "추적되지 않은 파일 ({count})",
|
||||||
"expandTracked": "추적된 변경 펼치기",
|
"expandTracked": "추적된 변경 펼치기",
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "Aplicar remoto"
|
"applyRightNonConflicting": "Aplicar remoto"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"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": {
|
"common": {
|
||||||
"all": "Todos",
|
"all": "Todos",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "espaço de trabalho",
|
"workspace": "espaço de trabalho",
|
||||||
"retry": "Tentar novamente",
|
"retry": "Tentar novamente",
|
||||||
"noCommitsFound": "Nenhum commit encontrado",
|
"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",
|
"hash": "Hash do commit",
|
||||||
"copyHash": "Copiar hash",
|
"copyHash": "Copiar hash",
|
||||||
"copyMessage": "Copiar mensagem",
|
"copyMessage": "Copiar mensagem",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "espaço de trabalho",
|
"workspace": "espaço de trabalho",
|
||||||
"noChanges": "Sem alterações locais",
|
"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})",
|
"trackedChanges": "Alterações rastreadas ({count})",
|
||||||
"untrackedFiles": "Arquivos não rastreados ({count})",
|
"untrackedFiles": "Arquivos não rastreados ({count})",
|
||||||
"expandTracked": "Expandir alterações rastreadas",
|
"expandTracked": "Expandir alterações rastreadas",
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "应用远程"
|
"applyRightNonConflicting": "应用远程"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"Folder": {
|
||||||
|
"workspaceStatus": {
|
||||||
|
"degradedTitle": "实时更新不可用",
|
||||||
|
"degradedHint": "监听器启动失败(如目录无读取权限)。请手动刷新以获取最新变更。",
|
||||||
|
"retry": "重试",
|
||||||
|
"retrying": "重试中..."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "工作区",
|
"workspace": "工作区",
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"noCommitsFound": "未找到提交记录",
|
"noCommitsFound": "未找到提交记录",
|
||||||
|
"notAGitRepoTitle": "不是 Git 仓库",
|
||||||
|
"notAGitRepoHint": "可从上方分支菜单初始化 Git,或打开已有的 Git 仓库。",
|
||||||
"hash": "Hash",
|
"hash": "Hash",
|
||||||
"copyHash": "复制哈希",
|
"copyHash": "复制哈希",
|
||||||
"copyMessage": "复制提交消息",
|
"copyMessage": "复制提交消息",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "工作区",
|
"workspace": "工作区",
|
||||||
"noChanges": "暂无本地改动",
|
"noChanges": "暂无本地改动",
|
||||||
|
"notAGitRepoTitle": "不是 Git 仓库",
|
||||||
|
"notAGitRepoHint": "可从上方分支菜单初始化 Git,或打开已有的 Git 仓库。",
|
||||||
"trackedChanges": "本地已跟踪改动 ({count})",
|
"trackedChanges": "本地已跟踪改动 ({count})",
|
||||||
"untrackedFiles": "本地未跟踪文件 ({count})",
|
"untrackedFiles": "本地未跟踪文件 ({count})",
|
||||||
"expandTracked": "展开已跟踪改动",
|
"expandTracked": "展开已跟踪改动",
|
||||||
|
|||||||
@@ -769,6 +769,12 @@
|
|||||||
"applyRightNonConflicting": "套用遠端"
|
"applyRightNonConflicting": "套用遠端"
|
||||||
},
|
},
|
||||||
"Folder": {
|
"Folder": {
|
||||||
|
"workspaceStatus": {
|
||||||
|
"degradedTitle": "即時更新不可用",
|
||||||
|
"degradedHint": "監聽器啟動失敗(如目錄無讀取權限)。請手動重新整理以取得最新變更。",
|
||||||
|
"retry": "重試",
|
||||||
|
"retrying": "重試中..."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
@@ -1205,6 +1211,8 @@
|
|||||||
"workspace": "工作區",
|
"workspace": "工作區",
|
||||||
"retry": "重試",
|
"retry": "重試",
|
||||||
"noCommitsFound": "未找到提交記錄",
|
"noCommitsFound": "未找到提交記錄",
|
||||||
|
"notAGitRepoTitle": "不是 Git 儲存庫",
|
||||||
|
"notAGitRepoHint": "可從上方分支選單初始化 Git,或開啟已有的 Git 儲存庫。",
|
||||||
"hash": "Hash",
|
"hash": "Hash",
|
||||||
"copyHash": "複製雜湊",
|
"copyHash": "複製雜湊",
|
||||||
"copyMessage": "複製提交訊息",
|
"copyMessage": "複製提交訊息",
|
||||||
@@ -1281,6 +1289,8 @@
|
|||||||
"gitChangesTab": {
|
"gitChangesTab": {
|
||||||
"workspace": "工作區",
|
"workspace": "工作區",
|
||||||
"noChanges": "暫無本地變更",
|
"noChanges": "暫無本地變更",
|
||||||
|
"notAGitRepoTitle": "不是 Git 儲存庫",
|
||||||
|
"notAGitRepoHint": "可從上方分支選單初始化 Git,或開啟已有的 Git 儲存庫。",
|
||||||
"trackedChanges": "本地已追蹤變更 ({count})",
|
"trackedChanges": "本地已追蹤變更 ({count})",
|
||||||
"untrackedFiles": "本地未追蹤檔案 ({count})",
|
"untrackedFiles": "本地未追蹤檔案 ({count})",
|
||||||
"expandTracked": "展開已追蹤變更",
|
"expandTracked": "展開已追蹤變更",
|
||||||
|
|||||||
@@ -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 {
|
export function toErrorMessage(error: unknown): string {
|
||||||
const appError = extractAppCommandError(error)
|
const appError = extractAppCommandError(error)
|
||||||
if (appError) {
|
if (appError) {
|
||||||
|
|||||||
@@ -897,6 +897,8 @@ export interface WorkspaceSnapshotResponse {
|
|||||||
tree_snapshot: FileTreeNode[] | null
|
tree_snapshot: FileTreeNode[] | null
|
||||||
git_snapshot: WorkspaceGitEntry[] | null
|
git_snapshot: WorkspaceGitEntry[] | null
|
||||||
deltas: WorkspaceDeltaEnvelope[]
|
deltas: WorkspaceDeltaEnvelope[]
|
||||||
|
degraded: boolean
|
||||||
|
is_git_repo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitLogResult {
|
export interface GitLogResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user