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": "编辑器中没有打开的文件",