From b8f422d4512753166eec2bf4e55b4d85d5180150 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:47:42 +0800 Subject: [PATCH] fix(vcs): Route a nested repository's own entry to the enclosing one and harden multi-root Review fixes for several repositories in one project: - A path equal to a nested repository's root (a submodule's entry, an untracked clone) belongs to the enclosing repository, in the backend and the Commit view. It was routed to the nested repository as ".", so Rollback ran `git restore` and `git clean` over the whole submodule and Commit staged everything in it. - Repository-scoped commands take the repository root exactly and fail when it is gone instead of running in an enclosing repository; path-based ones resolve the file's repository (Project::with_repo_of). - The rescan on a repository appearing or going watches `.git/HEAD`: the watcher never reports a `.git` path itself, so the old check never fired. - Status skips a repository that cannot be read; refresh falls back to the primary repository when the selected one is gone instead of failing every time. - Commit and Compare-with-Local diffs resolve repository-relative paths against the selected repository's root, not the project root. - Update Project and Fetch go on after a failing repository and report the failures together; Update skips repositories with a detached HEAD or no upstream. - The Push dialog leaves out a repository that cannot be read. - Shelve and patches fail clearly for changes of a nested repository instead of leaving them out silently (they still support the main repository only). - Fix the repository discovery test on Windows (path separators); Chinese text for the new strings. --- crates/ide-vcs/src/roots.rs | 80 ++++++++++++++++------ src-tauri/src/commands/vcs.rs | 28 ++++++-- src-tauri/src/state.rs | 56 ++++++++++----- src/features/vcs/__tests__/changes.test.ts | 9 +++ src/features/vcs/diffInput.ts | 8 ++- src/features/vcs/model.ts | 10 ++- src/features/vcs/operations.tsx | 10 +-- src/features/vcs/ops.ts | 22 +++++- src/features/vcs/remoteUi.tsx | 26 ++++--- src/features/vcs/store.ts | 26 ++++++- src/platform/locales/text/zh.json | 4 ++ 11 files changed, 211 insertions(+), 68 deletions(-) diff --git a/crates/ide-vcs/src/roots.rs b/crates/ide-vcs/src/roots.rs index d71abe72..ff3a71c5 100644 --- a/crates/ide-vcs/src/roots.rs +++ b/crates/ide-vcs/src/roots.rs @@ -69,19 +69,11 @@ impl Repos { fn new(mut repos: Vec, project_root: &Path) -> Self { repos.sort_by(|a, b| a.root().cmp(b.root())); let repos: Vec> = repos.into_iter().map(Arc::new).collect(); - let primary = Self::innermost(&repos, project_root).or((!repos.is_empty()).then_some(0)); + let roots: Vec<&Path> = repos.iter().map(|r| r.root()).collect(); + let primary = innermost(&roots, project_root, false).or((!repos.is_empty()).then_some(0)); Self { repos, primary } } - fn innermost(repos: &[Arc], path: &Path) -> Option { - repos - .iter() - .enumerate() - .filter(|(_, r)| path.starts_with(r.root())) - .max_by_key(|(_, r)| r.root().as_os_str().len()) - .map(|(i, _)| i) - } - pub fn is_empty(&self) -> bool { self.repos.is_empty() } @@ -95,15 +87,27 @@ impl Repos { self.primary.map(|i| &self.repos[i]) } - /// The innermost repository containing `path` (a file, folder or repository root). + /// The innermost repository containing `path` (a file or folder). A nested repository's + /// root is an entry of the enclosing repository (a submodule or untracked folder there), so + /// it belongs to that one; to the repository itself only when nothing encloses it. pub fn for_path(&self, path: &Path) -> Option<&Arc> { - Self::innermost(&self.repos, path).map(|i| &self.repos[i]).or_else(|| { + let roots: Vec<&Path> = self.repos.iter().map(|r| r.root()).collect(); + let found = innermost(&roots, path, true).or_else(|| { // Another spelling of the same location (symlinks, /var vs /private/var). let canon = dunce::canonicalize(path).ok()?; - self.repos - .iter() - .filter(|r| dunce::canonicalize(r.root()).is_ok_and(|root| canon.starts_with(root))) - .max_by_key(|r| r.root().as_os_str().len()) + let roots: Vec = + roots.iter().map(|r| dunce::canonicalize(r).unwrap_or_else(|_| r.to_path_buf())).collect(); + innermost(&roots.iter().map(PathBuf::as_path).collect::>(), &canon, true) + }); + found.map(|i| &self.repos[i]) + } + + /// The repository whose worktree root is `root` (the repository a repository-scoped + /// command was given). + pub fn at_root(&self, root: &Path) -> Option<&Arc> { + self.repos.iter().find(|r| r.root() == root).or_else(|| { + let canon = dunce::canonicalize(root).ok()?; + self.repos.iter().find(|r| dunce::canonicalize(r.root()).is_ok_and(|r| r == canon)) }) } @@ -121,9 +125,12 @@ impl Repos { } /// Changes of every repository. A nested repository reports its own files, so the - /// enclosing one's "untracked folder" entry for it is dropped. + /// enclosing one's "untracked folder" entry for it is dropped. A repository that cannot + /// be read (e.g. deleted since the last scan) is left out; fails only when all fail. pub fn status(&self) -> IdeResult> { let mut out = Vec::new(); + let mut first_error = None; + let mut read = 0; for repo in &self.repos { let nested: Vec<&Path> = self .repos @@ -131,16 +138,44 @@ impl Repos { .map(|r| r.root()) .filter(|r| *r != repo.root() && r.starts_with(repo.root())) .collect(); - out.extend(repo.status()?.into_iter().filter(|f| { + let status = match repo.status() { + Ok(status) => { + read += 1; + status + } + Err(e) => { + tracing::warn!("git status of {}: {e}", repo.root().display()); + first_error.get_or_insert(e); + continue; + } + }; + out.extend(status.into_iter().filter(|f| { let untracked_root = f.unstaged == Some(crate::ChangeKind::Untracked) && nested.iter().any(|n| f.path.0 == *n); !untracked_root })); } - Ok(out) + match first_error { + Some(e) if read == 0 => Err(e), + _ => Ok(out), + } } } +/// Index of the innermost of `roots` containing `path`. With `strict`, a root equal to +/// `path` counts only when no other root contains it. +fn innermost(roots: &[&Path], path: &Path, strict: bool) -> Option { + let best = |strict: bool| { + roots + .iter() + .enumerate() + .filter(|(_, r)| path.starts_with(r) && !(strict && **r == path)) + .max_by_key(|(_, r)| r.as_os_str().len()) + .map(|(i, _)| i) + }; + best(strict).or_else(|| best(false)) +} + #[cfg(test)] mod tests { use super::*; @@ -170,10 +205,15 @@ mod tests { assert_eq!(repos.primary().unwrap().root(), root); assert_eq!(repos.for_path(&root.join("services/api/main.rs")).unwrap().root(), root.join("services/api")); assert_eq!(repos.for_path(&root.join("services/other.txt")).unwrap().root(), root); + // A nested root is the enclosing repository's entry (submodule / untracked folder). + assert_eq!(repos.for_path(&root.join("services/api")).unwrap().root(), root); + assert_eq!(repos.for_path(&root).unwrap().root(), root); + assert_eq!(repos.at_root(&root.join("services/api")).unwrap().root(), root.join("services/api")); + assert!(repos.at_root(&root.join("services")).is_none()); let status = repos.status().unwrap(); let paths: Vec = - status.iter().map(|f| f.path.0.strip_prefix(&root).unwrap().to_string_lossy().into_owned()).collect(); + status.iter().map(|f| f.path.0.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/")).collect(); assert!(paths.contains(&"README.md".to_owned()), "{paths:?}"); assert!(paths.contains(&"services/api/main.rs".to_owned()), "{paths:?}"); assert!( diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 8fd0da6f..42ec9c4e 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -74,7 +74,7 @@ pub async fn vcs_branch_info( pub fn vcs_line_hunks(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { let project = state.project(window.label())?; let text = project.text_of(&path)?; - project.with_repo_at(Some(&path), |r| r.line_hunks(&path, &text, false)) + project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false)) } #[tauri::command] @@ -86,7 +86,7 @@ pub async fn vcs_file_at( rev: Option, ) -> IdeResult> { let project = state.project(window.label())?; - blocking(move || project.with_repo_at(Some(&path), |r| r.file_at(&path, rev.as_deref()))).await + blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await } #[tauri::command] @@ -94,7 +94,7 @@ pub async fn vcs_file_at( pub async fn vcs_blame(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { let project = state.project(window.label())?; let text = project.documents.text_of(&path); - blocking(move || project.with_repo_at(Some(&path), |r| r.blame(&path, text.as_deref()))).await + blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await } #[tauri::command] @@ -401,7 +401,7 @@ pub async fn vcs_conflict_versions( ) -> IdeResult { let project = state.project(window.label())?; blocking(move || { - project.with_repo_at(Some(&path), |r| { + project.with_repo_of(&path, |r| { r.conflict_versions(&path).map(|(base, ours, theirs)| ConflictVersions { base, ours, theirs }) }) }) @@ -417,7 +417,7 @@ pub async fn vcs_diff_text( staged: bool, ) -> IdeResult { let project = state.project(window.label())?; - blocking(move || project.with_repo_at(Some(&path), |r| r.diff_text(&path, staged))).await + blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await } /// Line hunks between two arbitrary texts (diff viewer, commit dialog). @@ -434,7 +434,7 @@ pub fn vcs_diff_hunks(old_text: String, new_text: String) -> Vec { pub fn vcs_stage_hunks(window: Window, state: State<'_, AppState>, path: PathBuf, hunks: Vec) -> IdeResult<()> { let project = state.project(window.label())?; let current = ide_vfs::load_text(&path)?.text; - project.with_repo_at(Some(&path), |r| { + project.with_repo_of(&path, |r| { let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default(); let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/"); let selected: Vec = hunks.iter().map(|&h| h as usize).collect(); @@ -639,6 +639,20 @@ fn per_repo( Ok(()) } +/// Shelf and patches work in the primary repository only: changes of a nested one +/// fail instead of being left out silently. +fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> { + let repos = project.repos(); + let primary = repos.primary().map(|r| r.root()); + match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) { + Some(p) => Err(ide_core::IdeError::invalid(format!( + "{} is in another git repository; shelving and patches support the project's main repository only", + p.display() + ))), + None => Ok(()), + } +} + async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { tauri::async_runtime::spawn_blocking(f).await.map_err(|e| ide_core::IdeError::internal(e.to_string()))? } @@ -662,6 +676,7 @@ pub async fn vcs_create_patch(window: Window, state: State<'_, AppState>, source project.with_repo(|r| match source { PatchSource::Changes { paths, reverse } => { let paths: Vec = paths.into_iter().map(Into::into).collect(); + in_primary_repo(&project, &paths)?; r.create_patch(&paths, reverse) } PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse), @@ -709,6 +724,7 @@ pub async fn vcs_shelve( ) -> IdeResult { let project = state.project(window.label())?; blocking(move || { + in_primary_repo(&project, &paths)?; keep_private(&project.root); project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths)) }) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 2cfce65e..be5a3d1e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -310,9 +310,16 @@ impl AppState { sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() }); } } - // A repository was cloned / initialised / removed below the project. - let git_dirs = - changed.iter().chain(&batch.removed).any(|p| p.file_name().is_some_and(|n| n == ".git")); + // A repository was cloned / initialised / removed below the project: its + // `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went. + let known = repos.read().roots(); + let git_dirs = changed.iter().chain(&batch.removed).any(|p| { + let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten() + else { + return false; + }; + known.iter().any(|r| r == dir) != p.is_file() + }); if git_dirs { scan_repositories(&root, &repos, &settings, &sink); } @@ -429,23 +436,38 @@ impl Project { self.repos.read().clone() } - /// Runs `f` with the repository containing `at` (a path in it or its root), the - /// project's primary repository when `None`; fails when there is none. + /// Runs `f` with the repository whose root is `at`, the project's primary repository + /// when `None`; fails when there is none (a repository-scoped command never falls back + /// to another repository). pub fn with_repo_at(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + let repos = self.repos(); + match at { + Some(root) => match repos.at_root(root) { + Some(repo) => self.run_in(repo, f), + None => { + Err(IdeError::unavailable(format!("{} is not a git repository of the project", root.display()))) + } + }, + None => match repos.primary() { + Some(repo) => self.run_in(repo, f), + None => Err(IdeError::unavailable("project is not a git repository")), + }, + } + } + + /// Runs `f` with the repository `path` (a file or folder) belongs to. + pub fn with_repo_of(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + match self.repos().for_path(path) { + Some(repo) => self.run_in(repo, f), + None => Err(IdeError::unavailable(format!("{} is not in a git repository", path.display()))), + } + } + + fn run_in(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { let settings = self.settings.effective(); crate::commands::vcs::apply_git_executable(&settings); - let repos = self.repos(); - let repo = match at { - Some(path) => repos.for_path(path), - None => repos.primary(), - }; - match repo { - Some(repo) => { - repo.set_ignored_names(settings.ignored_names()); - f(repo) - } - None => Err(IdeError::unavailable("project is not a git repository")), - } + repo.set_ignored_names(settings.ignored_names()); + f(repo) } /// Runs `f` with the project's primary git repository, or fails when the project is not under git. diff --git a/src/features/vcs/__tests__/changes.test.ts b/src/features/vcs/__tests__/changes.test.ts index d30500ae..73abd0e9 100644 --- a/src/features/vcs/__tests__/changes.test.ts +++ b/src/features/vcs/__tests__/changes.test.ts @@ -9,6 +9,7 @@ import { type CheckState, type ChangeNode, } from "../changes"; +import { rootOf } from "../model"; const fs = (rel: string, staged: FileStatus["staged"], unstaged: FileStatus["unstaged"]): FileStatus => ({ path: `/r/${rel}`, @@ -102,6 +103,14 @@ describe("several repositories", () => { expect(commitRepos(included.slice(0, 1), repos, "/r/libs/core")).toEqual(["/r"]); expect(commitRepos([], repos, "/r/libs/core")).toEqual(["/r/libs/core"]); }); + + it("routes a nested repository's own entry (submodule) to the enclosing repository", () => { + expect(rootOf("/r/libs/core/Lib.kt", repos)).toBe("/r/libs/core"); + expect(rootOf("/r/libs/core", repos)).toBe("/r"); + expect(rootOf("/r/libs/core/", repos)).toBe("/r"); + expect(rootOf("/r", repos)).toBe("/r"); + expect(rootOf("/other", repos)).toBeNull(); + }); }); describe("commit inclusion", () => { diff --git a/src/features/vcs/diffInput.ts b/src/features/vcs/diffInput.ts index 2efabbf2..c3daa5ce 100644 --- a/src/features/vcs/diffInput.ts +++ b/src/features/vcs/diffInput.ts @@ -4,7 +4,8 @@ import { call, commands } from "@/platform/ipc"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { baseName, effectiveKind } from "./model"; import { readWorkingText } from "./ops"; -import { statusOf, useVcs } from "./store"; +import { renamedFromPath } from "./changes"; +import { currentRepoRoot, statusOf } from "./store"; export type DiffSide = /** File content at a git revision (`HEAD`, `:0` = index, a commit id, `^`). */ @@ -104,15 +105,16 @@ export function workingDiffData(path: string, line?: number): DiffData { kind === "untracked" || kind === "added" ? { type: "text", text: "", label: "(does not exist)" } : st?.renamedFrom - ? { type: "rev", rev: "HEAD", path: joinRoot(st.renamedFrom), label: `HEAD (${st.renamedFrom})` } + ? { type: "rev", rev: "HEAD", path: renamedFromPath(st) ?? undefined, label: `HEAD (${st.renamedFrom})` } : { type: "rev", rev: "HEAD" }; const right: DiffSide = kind === "deleted" ? { type: "text", text: "", label: "(deleted)" } : { type: "worktree" }; return { path, left, right, line }; } +/** A path of the selected repository's log (repository-relative) made absolute. */ function joinRoot(rel: string): string { - const root = useVcs.getState().root; + const root = currentRepoRoot(); return root ? `${root}/${rel}` : rel; } diff --git a/src/features/vcs/model.ts b/src/features/vcs/model.ts index 8b9cbad1..d3a5f767 100644 --- a/src/features/vcs/model.ts +++ b/src/features/vcs/model.ts @@ -92,12 +92,16 @@ export function relTo(root: string, path: string): string { return path.startsWith(root + "/") ? path.slice(root.length + 1) : path; } -/** The innermost of `roots` (repository roots) containing `path`. */ +/** + * The innermost of `roots` (repository roots) containing `path`. A nested repository's root + * is an entry of the enclosing one (submodule, untracked folder), so it belongs there; to + * itself only when no root encloses it (as the backend routes it). + */ export function rootOf(path: string, roots: readonly string[]): string | null { const p = stripSlash(path); let best: string | null = null; - for (const r of roots) if ((p === r || p.startsWith(`${r}/`)) && (!best || r.length > best.length)) best = r; - return best; + for (const r of roots) if (p.startsWith(`${r}/`) && (!best || r.length > best.length)) best = r; + return best ?? (roots.includes(p) ? p : null); } /** Repository name for display: its folder relative to the project, else its folder name. */ diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index 1ca9bcf3..fa967e28 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -14,7 +14,7 @@ import { openDialog, promptText } from "./dialogs"; import { KIND_COLOR, baseName, dirName } from "./model"; import { runGitOperation, runOp } from "./ops"; import { reloadLog, useGitLog } from "./logStore"; -import { conflictedFiles, currentRepo, refreshNow, repoOf, useVcs } from "./store"; +import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoOf, useVcs } from "./store"; import styles from "./common.module.css"; // --------------------------------------------------------------------------- @@ -416,12 +416,14 @@ function CompareWithLocalDialog({ rev, label, onClose }: { rev: string; label: s alive = false; }; }, [rev]); - const open = (f: ChangedFile) => + const open = (f: ChangedFile) => { + const repoRoot = currentRepoRoot() ?? root; void openDiff({ - path: `${root}/${f.path}`, - left: { type: "rev", rev, label, path: f.oldPath ? `${root}/${f.oldPath}` : undefined }, + path: `${repoRoot}/${f.path}`, + left: { type: "rev", rev, label, path: f.oldPath ? `${repoRoot}/${f.oldPath}` : undefined }, right: { type: "worktree" }, }); + }; const current = files?.[active]; return ( 1 && repo) logConsole("info", repoName(repo)); - await call(commands.vcsRemote(repo, request, op, channel)); + const name = repos.length > 1 && repo ? repoName(repo) : null; + if (name) logConsole("info", name); + if (op.op === "pull" && repos.length > 1) { + const info = await call(commands.vcsBranchInfo(repo)).catch(() => null); + if (info && (!info.head || !info.upstream)) { + logConsole("info", info.head ? "No upstream branch, skipped" : "Detached HEAD, skipped"); + continue; + } + } + try { + await call(commands.vcsRemote(repo, request, op, channel)); + } catch (e) { + if (isCancelled(e) || !name) throw e; + logConsole("err", errorMessage(e)); + failures.push(`${name}: ${errorMessage(e)}`); + } } + if (failures.length) throw new Error(failures.join("\n")); lastRemoteError = null; notifications().notify({ group: "vcs", diff --git a/src/features/vcs/remoteUi.tsx b/src/features/vcs/remoteUi.tsx index 7a968df7..91c24e60 100644 --- a/src/features/vcs/remoteUi.tsx +++ b/src/features/vcs/remoteUi.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import type { BranchInfo, Commit, PushTags, RemoteOp } from "@/bindings"; import { call, commands } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; import { workflowSettings } from "@/platform/workflowSettings"; import { notifications } from "@/workbench/contributions"; import { Checkbox, Dialog, List, Select, Spinner, T } from "@/ui"; @@ -129,21 +130,26 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { useEffect(() => { let alive = true; - Promise.all( + // A repository that cannot be read (e.g. its upstream branch was deleted) is left out. + Promise.allSettled( roots.map(async (root) => { const branch = await call(commands.vcsBranchInfo(root)); const q = outgoingQuery(branch.upstream); const commits = branch.head ? await logOf(q.branch, q.limit, q.range, root) : []; return { root, branch, commits }; }), - ) - .then((all) => { - if (!alive) return; - const list = pushCandidates(all); - setRepos(list); - setChecked(defaultPushChecked(list, currentRepo())); - }) - .catch((e) => alive && setError(e instanceof Error ? e.message : String(e))); + ).then((results) => { + if (!alive) return; + const loaded = results.flatMap((r) => (r.status === "fulfilled" ? [r.value] : [])); + const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); + if (!loaded.length && failed) { + setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason)); + return; + } + const list = pushCandidates(loaded); + setRepos(list); + setChecked(defaultPushChecked(list, currentRepo())); + }); return () => { alive = false; }; @@ -240,7 +246,7 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { label={repoName(r.repo.root)} /> - {r.repo.branch.head} → {r.repo.branch.upstream ?? "new remote branch"} + {r.repo.branch.head} → {r.repo.branch.upstream ?? tx("new remote branch")} ) : ( diff --git a/src/features/vcs/store.ts b/src/features/vcs/store.ts index 3e71f0e3..6cf5104b 100644 --- a/src/features/vcs/store.ts +++ b/src/features/vcs/store.ts @@ -72,6 +72,12 @@ export function currentRepo(): string | null { return useVcs.getState().repo; } +/** Root of the selected repository, which its log's and diffs' repository-relative paths are relative to. */ +export function currentRepoRoot(): string | null { + const s = useVcs.getState(); + return s.repo ?? s.roots[0] ?? s.root; +} + /** Root of the innermost repository containing `path`. */ export function repoOf(path: string, roots: readonly string[] = useVcs.getState().roots): string | null { return rootOf(path, roots); @@ -148,14 +154,28 @@ export function refreshNow(): Promise { useVcs.setState({ loading: true }); try { const s = useVcs.getState(); - const [vcsRoots, status, branch, branches, stashes] = await Promise.all([ - call(commands.vcsRoots()), - call(commands.vcsStatus()), + const scoped = Promise.all([ call(commands.vcsBranchInfo(s.repo)), s.branchesLoaded ? call(commands.vcsBranches(s.repo)) : Promise.resolve(s.branches), s.stashesLoaded ? call(commands.vcsStashes(s.repo)) : Promise.resolve(s.stashes), + ]).then( + (values) => ({ values }), + (error: unknown) => ({ error }), + ); + const [vcsRoots, status, selected] = await Promise.all([ + call(commands.vcsRoots()), + call(commands.vcsStatus()), + scoped, ]); const roots = vcsRoots.map((r) => r.root); + if ("error" in selected) { + // The selected repository is gone (removed since the last scan): fall back to the primary one. + if (!s.repo || roots.includes(s.repo)) throw selected.error; + useVcs.setState({ roots, repo: null, branchesLoaded: false, stashesLoaded: false }); + void refreshNow(); + return; + } + const [branch, branches, stashes] = selected.values; const operations = Object.fromEntries(vcsRoots.filter((r) => r.operation).map((r) => [r.root, r.operation!])); // `null` (before the first refresh) is the primary repository, listed first. const shown = s.repo && roots.includes(s.repo) ? s.repo : (roots[0] ?? null); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2fc3ddfd..3e866045 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -630,6 +630,7 @@ "Evaluate expression (↩) or add a watch (⇧⌘↩)": "计算表达式 (↩) 或添加监视 (⇧⌘↩)", "Evaluate in the debug console": "在调试控制台中计算", "Evaluation result": "计算结果", + "Everything is up to date": "一切都是最新的", "Exception Breakpoints": "异常断点", "Exception breakpoints": "异常断点", "Exclude": "排除", @@ -1436,6 +1437,7 @@ "Purple": "紫色", "Push": "推送", "Push All up to Here…": "推送到此处为止的所有提交…", + "Push Commits": "推送提交", "Push rejected": "推送被拒绝", "Push tags:": "推送标签:", "Push…": "推送…", @@ -1564,6 +1566,7 @@ "Replacements": "替换项", "Replacing…": "正在替换…", "Reports errors and warnings from language servers.": "报告来自语言服务器的错误和警告。", + "Repository": "仓库", "Request": "请求", "Request Changes…": "请求更改…", "Require": "必需", @@ -2256,6 +2259,7 @@ "match expression": "match 表达式", "match one node,": "匹配一个节点,", "new column definition": "新列定义", + "new remote branch": "新远程分支", "new table definition": "新表定义", "new view definition": "新视图定义", "no file is open in the editor": "编辑器中没有打开的文件",