From d9929bbe44ce86b354cfb4c355b50dbcdb8ff11d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:52:55 +0800 Subject: [PATCH] fix(vcs): resolve shelf files against their owning repository (#919) Root cause: shelved file paths are relative to the repository they were shelved from, but Compare with Local, Show Diff, navigation and Unshelve joined them to the selected VCS root. In a project folder that is not itself a repository (or holds several), the local file was not found and Compare with Local stayed disabled. Shelves now record their repository (older shelves fall back to the primary repository), imported patches record the current one, and every shelf action resolves paths against it, with Windows separators handled. Stash and Apply Patch already kept their repository. Fixes #875 Co-Authored-By: Claude Opus 5.5 --- crates/ide-host/src/ops/vcs/commands.rs | 62 ++++++--- crates/ide-host/tests/vcs_shelf_roots.rs | 123 ++++++++++++++++++ crates/ide-vcs/src/lib.rs | 3 + crates/ide-vcs/src/shelf.rs | 31 ++++- crates/ide-vcs/tests/history_ops.rs | 6 +- src-tauri/src/commands/vcs.rs | 3 +- src/bindings.ts | 4 +- .../vcs/__tests__/repositoryPaths.test.ts | 21 +++ src/features/vcs/__tests__/shelf.test.tsx | 70 +++++++++- src/features/vcs/diffInput.ts | 3 +- src/features/vcs/logChanges.ts | 3 +- src/features/vcs/operations.tsx | 5 +- src/features/vcs/repositoryPaths.ts | 10 ++ src/features/vcs/shelf.tsx | 23 +++- src/platform/locales/text/zh.json | 4 +- 15 files changed, 327 insertions(+), 44 deletions(-) create mode 100644 crates/ide-host/tests/vcs_shelf_roots.rs create mode 100644 src/features/vcs/__tests__/repositoryPaths.test.ts create mode 100644 src/features/vcs/repositoryPaths.ts diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 64d47e42..eae55b6b 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -123,18 +123,18 @@ 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<()> { +/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files. +fn shelf_repository(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(()), + let root = paths + .first() + .and_then(|p| repos.for_path(p)) + .map(|r| r.root()) + .ok_or_else(|| ide_core::IdeError::invalid("select files in a git repository to shelve"))?; + if paths.iter().any(|p| repos.for_path(p).map(|r| r.root()) != Some(root)) { + return Err(ide_core::IdeError::invalid("select files from a single git repository to shelve")); } + Ok(root.to_path_buf()) } async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { @@ -854,16 +854,23 @@ pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec IdeResult> { let project = host.project(window)?; - ide_vcs::shelf::list(&shelf_dir(&project.root)) + let mut shelves = ide_vcs::shelf::list(&shelf_dir(&project.root))?; + let primary = project.repos().primary().map(|r| ide_core::IpcPath::from(r.root().to_path_buf())); + for entry in &mut shelves { + if entry.repo.is_none() { + entry.repo = primary.clone(); + } + } + Ok(shelves) } /// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the @@ -878,7 +885,8 @@ pub async fn vcs_unshelve( host.require_trust(window)?; let project = host.project(window)?; blocking(move || { - project.with_repo_recorded(None, Some("Unshelve"), |r| { + let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?; + project.with_repo_recorded(repo.as_deref(), Some("Unshelve"), |r| { ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref()) }) }) @@ -902,12 +910,30 @@ pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option IdeResult> { let project = host.project(window)?; - blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await + blocking(move || { + let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?; + project.with_repo_at(repo.as_deref(), |r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id)) + }) + .await } -pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> { +pub async fn vcs_shelf_import( + host: &Host, + window: &str, + repo: Option, + name: String, + patch: String, +) -> IdeResult<()> { let project = host.project(window)?; - blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await + blocking(move || { + if repo.is_none() && project.repos().is_empty() { + return ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, None); + } + project.with_repo_at(repo.as_deref(), |r| { + ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, Some(r.root())) + }) + }) + .await } pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult { @@ -1107,7 +1133,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) }); d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await }); d.add("vcs_shelf_import", |c| async move { - vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await + vcs_shelf_import(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("patch")?).await }); d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) }); d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) }); diff --git a/crates/ide-host/tests/vcs_shelf_roots.rs b/crates/ide-host/tests/vcs_shelf_roots.rs new file mode 100644 index 00000000..cb716546 --- /dev/null +++ b/crates/ide-host/tests/vcs_shelf_roots.rs @@ -0,0 +1,123 @@ +//! Shelves retain their repository even when the project root is above or below it. +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops::vcs}; +use std::{path::Path, process::Command, sync::Arc, time::Duration}; + +fn git(root: &Path, args: &[&str]) { + let output = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +async fn check_shelves(layout: &str) { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let project_root = base.join("project"); + let roots = match layout { + "same" => vec![project_root.clone()], + "below" => vec![base.clone()], + "two" => vec![project_root.join("项目A"), project_root.join("repoB")], + _ => vec![project_root.join("项目A")], + }; + std::fs::create_dir_all(project_root.join("dirB")).unwrap(); + for (i, root) in roots.iter().enumerate() { + std::fs::create_dir_all(root.join("src")).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["config", "user.name", "Test"]); + git(root, &["config", "user.email", "test@example.com"]); + std::fs::write(root.join("src/same.txt"), format!("base {i}\n")).unwrap(); + git(root, &["add", "src"]); + git(root, &["commit", "-m", "initial"]); + std::fs::write(root.join("src/same.txt"), format!("shelved {i}\n")).unwrap(); + } + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&project_root, true, false).unwrap(); + let project = host.open_project("main", &project_root).unwrap(); + for _ in 0..500 { + if project.repos().all().len() == roots.len() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(project.repos().all().len(), roots.len()); + let primary = project.repos().primary().unwrap().root().to_path_buf(); + if roots.len() == 2 { + assert!( + vcs::vcs_shelve(&host, "main", "mixed".into(), roots.iter().map(|r| r.join("src/same.txt")).collect()) + .await + .is_err() + ); + assert!(vcs::vcs_shelves(&host, "main").unwrap().is_empty()); + } + for (i, root) in roots.iter().enumerate() { + let entry = vcs::vcs_shelve(&host, "main", "work".into(), vec![root.join("src/same.txt")]).await.unwrap(); + assert_eq!(entry.repo.as_deref(), Some(root.as_path())); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n")); + let listed = vcs::vcs_shelves(&host, "main").unwrap(); + assert_eq!(listed.iter().find(|s| s.id == entry.id).unwrap().repo, entry.repo); + let diffs = vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.unwrap(); + assert_eq!(diffs[0].before.as_deref(), Some(format!("base {i}\n").as_str())); + assert_eq!(diffs[0].after.as_deref(), Some(format!("shelved {i}\n").as_str())); + vcs::vcs_shelf_rename(&host, "main", entry.id.clone(), "renamed".into()).unwrap(); + let patch = vcs::vcs_shelf_patch(&host, "main", entry.id.clone(), None).unwrap(); + vcs::vcs_shelf_import(&host, "main", Some(root.clone()), "imported".into(), patch).await.unwrap(); + let imported = vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.name == "imported").unwrap(); + assert_eq!(imported.repo, entry.repo); + vcs::vcs_shelf_delete(&host, "main", imported.id, None).unwrap(); + if root != &primary { + let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json"); + let original = std::fs::read(&info).unwrap(); + let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap(); + metadata["repo"] = serde_json::json!(base.join("missing-repository")); + std::fs::write(&info, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert!(vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.is_err()); + assert!(vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, None).await.is_err()); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n")); + std::fs::write(info, original).unwrap(); + } + vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, Some("src/same.txt".into())).await.unwrap(); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("shelved {i}\n")); + // Old shelves were always made in the primary repository; missing ownership keeps that meaning. + if root == &primary { + let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json"); + let mut metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(&info).unwrap()).unwrap(); + metadata.as_object_mut().unwrap().remove("repo"); + std::fs::write(info, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert_eq!( + vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.id == entry.id).unwrap().repo, + entry.repo + ); + assert_eq!( + vcs::vcs_shelf_diff(&host, "main", entry.id).await.unwrap()[0].after.as_deref(), + Some(format!("shelved {i}\n").as_str()) + ); + } + } + host.close_project("main"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_at_repo_root() { + check_shelves("same").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_in_non_git_project() { + check_shelves("above").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelves_in_two_repositories() { + check_shelves("two").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_in_project_below_repository() { + check_shelves("below").await; +} diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index cca5669a..7e8cd352 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -297,6 +297,9 @@ pub struct ShelvedChangeList { pub time_ms: f64, /// Branch checked out when the changes were shelved. pub branch: Option, + /// Repository owning the patch; absent in legacy shelves. + #[serde(default)] + pub repo: Option, /// Repository-relative paths. pub files: Vec, } diff --git a/crates/ide-vcs/src/shelf.rs b/crates/ide-vcs/src/shelf.rs index 50286713..cdd5f882 100644 --- a/crates/ide-vcs/src/shelf.rs +++ b/crates/ide-vcs/src/shelf.rs @@ -19,6 +19,8 @@ struct Info { #[serde(default)] branch: Option, files: Vec, + #[serde(default)] + repo: Option, } fn now_ms() -> f64 { @@ -67,6 +69,7 @@ pub fn list(shelf: &Path) -> IdeResult> { name: id.clone(), time_ms: 0.0, branch: None, + repo: None, files: std::fs::read_to_string(dir.join(PATCH)) .map(|p| { crate::patch::parse_patch(&p) @@ -81,6 +84,7 @@ pub fn list(shelf: &Path) -> IdeResult> { name: info.name, time_ms: info.time_ms, branch: info.branch, + repo: info.repo, files: crate::patch::parse_patch(&std::fs::read_to_string(dir.join(PATCH))?) .into_iter() .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) @@ -96,6 +100,11 @@ pub fn patch_of(shelf: &Path, id: &str) -> IdeResult { Ok(std::fs::read_to_string(entry_dir(shelf, id)?.join(PATCH))?) } +/// The repository captured when the shelf was made; legacy shelves used the primary repository. +pub fn repository(shelf: &Path, id: &str) -> IdeResult> { + Ok(read_info(&entry_dir(shelf, id)?).and_then(|info| info.repo).map(|repo| repo.into_path_buf())) +} + pub fn delete(shelf: &Path, id: &str) -> IdeResult<()> { std::fs::remove_dir_all(entry_dir(shelf, id)?)?; Ok(()) @@ -107,8 +116,13 @@ pub fn rename(shelf: &Path, id: &str, name: &str) -> IdeResult<()> { return Err(IdeError::invalid("name must not be empty")); } let dir = entry_dir(shelf, id)?; - let mut info = - read_info(&dir).unwrap_or(Info { name: String::new(), time_ms: now_ms(), branch: None, files: vec![] }); + let mut info = read_info(&dir).unwrap_or(Info { + name: String::new(), + time_ms: now_ms(), + branch: None, + repo: None, + files: vec![], + }); info.name = name.to_string(); std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?; Ok(()) @@ -134,7 +148,13 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) - } let dir = shelf.join(&id); std::fs::create_dir_all(&dir)?; - let info = Info { name: name.to_string(), time_ms: now_ms(), branch: r.branch_info()?.head, files: files.clone() }; + let info = Info { + name: name.to_string(), + time_ms: now_ms(), + branch: r.branch_info()?.head, + files: files.clone(), + repo: Some(r.root().to_path_buf().into()), + }; let write = || -> IdeResult<()> { std::fs::write(dir.join(PATCH), patch.as_bytes())?; std::fs::write( @@ -148,7 +168,7 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) - return Err(e); } revert_files(r, &files)?; - Ok(ShelvedChangeList { id, name: info.name, time_ms: info.time_ms, branch: info.branch, files }) + Ok(ShelvedChangeList { id, name: info.name, time_ms: info.time_ms, branch: info.branch, repo: info.repo, files }) } /// Restores `files` to HEAD in both index and working tree; files absent from HEAD are removed. @@ -318,7 +338,7 @@ pub fn diff_files(r: &Repo, shelf: &Path, id: &str) -> IdeResult IdeResult<()> { +pub fn import_patch(shelf: &Path, name: &str, patch: &str, repo: Option<&Path>) -> IdeResult<()> { let files = crate::patch::parse_patch(patch); if files.is_empty() || sections(patch).len() != files.len() { return Err(IdeError::invalid("no file changes found in the patch")); @@ -334,6 +354,7 @@ pub fn import_patch(shelf: &Path, name: &str, patch: &str) -> IdeResult<()> { name: name.to_string(), time_ms: now_ms(), branch: None, + repo: repo.map(|r| r.to_path_buf().into()), files: files .into_iter() .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index dd854c94..2d86ebc4 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -614,7 +614,7 @@ fn shelf_diff_and_scoped_operations_preserve_siblings() { assert!(!shelf::list(&shelf).unwrap()[0].files.iter().any(|f| f.path == "b.txt")); shelf::delete_path(&shelf, &entry.id, Some(QUOTED)).unwrap(); assert!(shelf::selected_patch(&shelf, &entry.id, Some("a.txt")).is_ok()); - shelf::import_patch(&shelf, "imported", &patch).unwrap(); + shelf::import_patch(&shelf, "imported", &patch, None).unwrap(); assert_eq!(shelf::list(&shelf).unwrap().len(), 2); assert_eq!(fx.read("a.txt"), "local edits\n"); } @@ -629,7 +629,7 @@ fn shelf_imported_rename_diff_and_last_file_delete() { fx.git(&["reset", "--hard", "HEAD"]); let repo = fx.repo(); let shelf = fx._tmp.path().join("shelf"); - shelf::import_patch(&shelf, "rename", &patch).unwrap(); + shelf::import_patch(&shelf, "rename", &patch, None).unwrap(); let id = &shelf::list(&shelf).unwrap()[0].id; let diffs = shelf::diff_files(&repo, &shelf, id).unwrap(); assert_eq!(diffs.len(), 1); @@ -655,7 +655,7 @@ fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() { assert_eq!(diffs.iter().find(|f| f.path == "old.txt").unwrap().before.as_deref(), Some("renamed content\n")); assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().after.as_deref(), Some("renamed content\n")); let plain = "--- a/one.txt\n+++ b/one.txt\n@@ -1 +1 @@\n--- removed\n+++ added\n--- a/two.txt\n+++ b/two.txt\n@@ -1 +1 @@\n-old\n+new\n"; - shelf::import_patch(&shelf, "plain", plain).unwrap(); + shelf::import_patch(&shelf, "plain", plain, None).unwrap(); let selected = shelf::selected_patch(&shelf, "plain", Some("one.txt")).unwrap(); assert!(selected.contains("--- removed\n+++ added")); assert!(!selected.contains("two.txt")); diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 9f3aaf18..305fd0e8 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -675,10 +675,11 @@ pub async fn vcs_shelf_diff( pub async fn vcs_shelf_import( window: Window, state: State<'_, AppState>, + repo: Option, name: String, patch: String, ) -> IdeResult<()> { - ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), name, patch).await + ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), repo, name, patch).await } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 6d71d1a3..22f176d2 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -546,7 +546,7 @@ export const commands = { vcsShelfRename: (id: string, name: string) => typedError(__TAURI_INVOKE("vcs_shelf_rename", { id, name })), vcsShelfPatch: (id: string, path: string | null) => typedError(__TAURI_INVOKE("vcs_shelf_patch", { id, path })), vcsShelfDiff: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_diff", { id })), - vcsShelfImport: (name: string, patch: string) => typedError(__TAURI_INVOKE("vcs_shelf_import", { name, patch })), + vcsShelfImport: (repo: string | null, name: string, patch: string) => typedError(__TAURI_INVOKE("vcs_shelf_import", { repo, name, patch })), vcsChangelistsLoad: () => typedError(__TAURI_INVOKE("vcs_changelists_load")), vcsChangelistsSave: (changelists: ChangelistState) => typedError(__TAURI_INVOKE("vcs_changelists_save", { changelists })), /** Create Patch › Save to file. */ @@ -5783,6 +5783,8 @@ export type ShelvedChangeList = { timeMs: number | null, /** Branch checked out when the changes were shelved. */ branch: string | null, + /** Repository owning the patch; absent in legacy shelves. */ + repo?: IpcPath | null, /** Repository-relative paths. */ files: ChangedFile[], }; diff --git a/src/features/vcs/__tests__/repositoryPaths.test.ts b/src/features/vcs/__tests__/repositoryPaths.test.ts new file mode 100644 index 00000000..e1451849 --- /dev/null +++ b/src/features/vcs/__tests__/repositoryPaths.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { repositoryPath } from "../repositoryPaths"; + +describe("repository-relative paths", () => { + it.each([ + ["/repo", "src/a.txt", "/repo/src/a.txt"], + ["/project/项目A", "src/a.txt", "/project/项目A/src/a.txt"], + ["/project/repoB/", "src/a.txt", "/project/repoB/src/a.txt"], + ["/repo", "subproject/a.txt", "/repo/subproject/a.txt"], + ["C:\\project\\项目A", "src\\a.txt", "C:/project/项目A/src/a.txt"], + ["C:/project/repoB", "src\\a.txt", "C:/project/repoB/src/a.txt"], + ["C:\\", "a.txt", "C:/a.txt"], + ["\\\\server\\share\\repo", "src\\a.txt", "//server/share/repo/src/a.txt"], + ["\\\\?\\C:\\repo", "src\\a.txt", "C:/repo/src/a.txt"], + ["/", "a.txt", "/a.txt"], + ["/repo", "src/a\\b.txt", "/repo/src/a\\b.txt"], + [null, "src/a.txt", "src/a.txt"], + ])("%s + %s → %s", (root, path, expected) => { + expect(repositoryPath(root, path)).toBe(expected); + }); +}); diff --git a/src/features/vcs/__tests__/shelf.test.tsx b/src/features/vcs/__tests__/shelf.test.tsx index 69ce6d02..3d962364 100644 --- a/src/features/vcs/__tests__/shelf.test.tsx +++ b/src/features/vcs/__tests__/shelf.test.tsx @@ -3,7 +3,7 @@ import { act, fireEvent, render, screen, waitFor, cleanup } from "@testing-libra import { afterEach } from "vitest"; import type { ShelvedChangeList } from "@/bindings"; -const mocks = vi.hoisted(() => ({ openDiff: vi.fn(), execute: vi.fn(), ipc: vi.fn(), patch: vi.fn() })); +const mocks = vi.hoisted(() => ({ openDiff: vi.fn(), execute: vi.fn(), ipc: vi.fn(), patch: vi.fn(), probe: vi.fn() })); vi.mock("../diffInput", async (original) => ({ ...(await original()), openDiff: mocks.openDiff, @@ -19,13 +19,16 @@ vi.mock("@/platform/ipc", async (original) => ({ ...(await original()).commands, vcsShelves: () => Promise.resolve(null), vcsShelfDiff: () => Promise.resolve(null), - fsProbe: () => Promise.resolve(null), + fsProbe: mocks.probe, vcsShelfPatch: mocks.patch, + vcsUnshelve: () => Promise.resolve(null), }, })); -import { ShelfPanel, copyShelfPatch, shelfDiffs, shelfTarget, showShelfDiff, useShelf } from "../shelf"; +import { ShelfPanel, copyShelfPatch, shelfDiffs, shelfTarget, showShelfDiff, unshelve, useShelf } from "../shelf"; import { useCommit } from "../commitStore"; import { useVcs } from "../store"; +import { useChangelists } from "../changelists"; +import { VcsDialogHost } from "../dialogs"; import { ContextMenuHost } from "@/ui"; import { registerVcsActions } from "../actions"; import { getAction, isActionKeyEnabled } from "@/platform/actions"; @@ -37,6 +40,7 @@ const entry: ShelvedChangeList = { name: "Work", timeMs: 0, branch: null, + repo: null, files: [ { path: "a.txt", kind: "modified", oldPath: null }, { path: "b.txt", kind: "added", oldPath: null }, @@ -51,7 +55,7 @@ const diffs = [ beforeEach(() => { vi.clearAllMocks(); mocks.patch.mockResolvedValue(null); - useVcs.setState({ root: "/repo" }); + useVcs.setState({ root: "/repo", roots: [], repo: null, available: false, status: [] }); useCommit.setState({ selectedShelf: "work\u0000a.txt" }); useShelf.setState({ shelves: [entry], loaded: true, diffs: { work: diffs } }); mocks.ipc.mockResolvedValueOnce([entry]).mockResolvedValue(diffs); @@ -103,6 +107,64 @@ describe("shelf scope and diffs", () => { await showShelfDiff({ id: "work", path: "a.txt" }, true); expect(mocks.openDiff).toHaveBeenCalledTimes(1); }); + it.each([ + ["same root", "/repo", ["/repo"], "/repo", "src/a.txt", "/repo/src/a.txt"], + ["项目A under a non-git project", "/project", ["/project/项目A"], null, "src/a.txt", "/project/项目A/src/a.txt"], + [ + "second repo with the same file name", + "/project", + ["/project/A", "/project/B"], + "/project/B", + "src/a.txt", + "/project/B/src/a.txt", + ], + ["project below the repo", "/repo/subproject", ["/repo"], null, "src/a.txt", "/repo/src/a.txt"], + [ + "Windows stored separators", + "C:/project", + ["C:/project/项目A"], + "C:/project/项目A", + "src\\a.txt", + "C:/project/项目A/src/a.txt", + ], + ])("maps saved and local diffs to the owning repo: %s", async (_name, root, roots, repo, path, expected) => { + useVcs.setState({ root, roots, repo: roots[0] ?? null }); + useShelf.setState({ + shelves: [{ ...entry, repo }], + diffs: { work: [{ path, before: "base", after: "shelved", patch: "patch" }] }, + }); + expect(shelfDiffs({ id: "work" })[0]?.path).toBe(expected); + mocks.ipc.mockReset().mockImplementation(async () => { + if (mocks.probe.mock.lastCall?.[0] !== expected) throw new Error("wrong local file"); + return { binary: false }; + }); + await showShelfDiff({ id: "work" }, true); + expect(mocks.probe).toHaveBeenLastCalledWith(expected); + expect(mocks.openDiff).toHaveBeenCalledWith(expect.objectContaining({ path: expected }), { preview: true }); + }); + it("keeps legacy shelves on the primary repo after selecting another repo", () => { + useVcs.setState({ root: "/project", roots: ["/project/A", "/project/B"], repo: "/project/B" }); + expect(shelfDiffs({ id: "work", path: "a.txt" }, true)[0]?.path).toBe("/project/A/a.txt"); + }); + it.each([ + ["/project", "/project/项目A", "src/a.txt", "项目A/src/a.txt"], + ["/repo/subproject", "/repo", "src/a.txt", "/repo/src/a.txt"], + ["C:/project", "C:/project/项目A", "src\\a.txt", "项目A/src/a.txt"], + ])("assigns unshelved paths to the project changelist from %s", async (root, repo, path, expected) => { + useVcs.setState({ root, roots: [repo] }); + useChangelists.setState({ + loaded: false, + state: { lists: [{ id: "target", name: "Target", comment: "" }], active: "target", files: {} }, + }); + mocks.ipc + .mockReset() + .mockResolvedValueOnce({ applied: [path], conflicts: [] }) + .mockResolvedValue([]); + render(); + act(() => unshelve({ ...entry, repo })); + fireEvent.click(screen.getByRole("button", { name: "Unshelve" })); + await waitFor(() => expect(useChangelists.getState().state.files).toEqual({ [expected]: "target" })); + }); it("copies only the selected file patch through the scoped patch command", async () => { const previous = Object.getOwnPropertyDescriptor(navigator, "clipboard"); const writeText = vi.fn().mockResolvedValue(undefined); diff --git a/src/features/vcs/diffInput.ts b/src/features/vcs/diffInput.ts index 6d7cf6ec..583c8bb9 100644 --- a/src/features/vcs/diffInput.ts +++ b/src/features/vcs/diffInput.ts @@ -6,6 +6,7 @@ import { baseName, effectiveKind } from "./model"; import { readWorkingText } from "./ops"; import { renamedFromPath } from "./changes"; import { currentRepoRoot, statusOf } from "./store"; +import { repositoryPath } from "./repositoryPaths"; export type DiffSide = /** File content at a git revision (`HEAD`, `:0` = index, a commit id, `^`). */ @@ -144,7 +145,7 @@ export function workingDiffData(path: string, line?: number): DiffData { /** A path of the selected repository's log (repository-relative) made absolute. */ function joinRoot(rel: string, root = currentRepoRoot()): string { - return root ? `${root}/${rel}` : rel; + return repositoryPath(root, rel); } /** Diff of one file changed by `commit` (first parent ↔ commit). */ diff --git a/src/features/vcs/logChanges.ts b/src/features/vcs/logChanges.ts index 12dfe3a6..87e1b0d2 100644 --- a/src/features/vcs/logChanges.ts +++ b/src/features/vcs/logChanges.ts @@ -14,6 +14,7 @@ import { baseName } from "./model"; import { readWorkingText, runOp, saveAll, writeFileText } from "./ops"; import { reportApply } from "./shelf"; import { currentRepoRoot } from "./store"; +import { repositoryPath } from "./repositoryPaths"; import { confirmDialog } from "./dialogs"; /** A file of the pane, with the revisions holding its content before and after the change. */ @@ -109,7 +110,7 @@ export function filterPatch(patch: string, paths: ReadonlySet): string { /** Absolute path of a repository-relative one. */ export function absolutePath(rel: string, root = currentRepoRoot()): string { - return root ? `${root}/${rel}` : rel; + return repositoryPath(root, rel); } /** Pane state the changed-files actions work on when they get no argument. */ diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index b18a7ae1..b57298b4 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -21,6 +21,7 @@ import { ComparisonTree } from "./ComparisonTree"; import { buildFileTree, type FileTreeNode } from "./fileTree"; import { tx } from "@/platform/i18n"; import { displayPath } from "./compareData"; +import { repositoryPath } from "./repositoryPaths"; // --------------------------------------------------------------------------- // Operation in progress @@ -580,7 +581,7 @@ export function ChangesBetweenDialog({ const diffFor = (f: ComparisonFile): DiffData => { const missing = (label: string): DiffSide => ({ type: "text", text: "", label }); return { - path: `${f.root}/${f.path}`, + path: repositoryPath(f.root, f.path), left: f.kind === "added" || f.kind === "untracked" ? missing("(does not exist)") @@ -588,7 +589,7 @@ export function ChangesBetweenDialog({ type: "rev", rev: f.revision, label: from.label, - path: f.oldPath ? `${f.root}/${f.oldPath}` : undefined, + path: f.oldPath ? repositoryPath(f.root, f.oldPath) : undefined, }, right: f.kind === "deleted" diff --git a/src/features/vcs/repositoryPaths.ts b/src/features/vcs/repositoryPaths.ts new file mode 100644 index 00000000..686a9b5d --- /dev/null +++ b/src/features/vcs/repositoryPaths.ts @@ -0,0 +1,10 @@ +/** Stored Git paths are relative to their repository, independently of the open project. */ +import { ipcPath } from "@/platform/paths"; + +export function repositoryPath(root: string | null | undefined, path: string): string { + if (!root) return path; + const base = ipcPath(root).replace(/\/$/, ""); + // Backslashes are separators on Windows, but can be literal filename characters on POSIX. + const relative = /^[A-Za-z]:\//.test(`${base}/`) || base.startsWith("//") ? path.replace(/\\/g, "/") : path; + return `${base}/${relative}`; +} diff --git a/src/features/vcs/shelf.tsx b/src/features/vcs/shelf.tsx index 0979e2b4..9e808b91 100644 --- a/src/features/vcs/shelf.tsx +++ b/src/features/vcs/shelf.tsx @@ -22,7 +22,8 @@ import { showLocalChanges } from "./commitUi"; import { confirmDialog, openDialog, promptText } from "./dialogs"; import { KIND_COLOR, baseName, dirName, effectiveKind, fileCount, formatTime, relTo, stripSlash } from "./model"; import { errorMessage, logConsole, runOp, saveAll } from "./ops"; -import { conflictedFiles, refreshNow, useVcs } from "./store"; +import { conflictedFiles, currentRepo, refreshNow, useVcs } from "./store"; +import { repositoryPath } from "./repositoryPaths"; import styles from "./common.module.css"; import viewStyles from "./CommitView.module.css"; import { tx } from "@/platform/i18n"; @@ -212,7 +213,7 @@ function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; pa const lists = useChangelists((s) => s.state); const [target, setTarget] = useState(lists.active); const [remove, setRemove] = useState(() => workflowSettings().vcs.shelf.removeApplied); - const root = useVcs((s) => s.root) ?? ""; + const repo = shelfRepository(entry); const run = async () => { onClose(); await saveAll(); @@ -225,7 +226,7 @@ function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; pa ); if (!outcome) return; await refreshNow(); - const rels = [...outcome.applied, ...outcome.conflicts].map((p) => relOf(`${root}/${p}`)); + const rels = [...outcome.applied, ...outcome.conflicts].map((p) => relOf(repositoryPath(repo, p))); updateChangelists((s) => moveFiles(s, rels, target)); void loadShelves().catch(() => undefined); await reportApply(outcome, `Unshelved “${entry.name}”`); @@ -325,13 +326,20 @@ export function shelfTarget(arg?: unknown): { entry: ShelvedChangeList; path?: s return { entry, path: target?.path }; } +/** Legacy shelves were created in the primary repository, regardless of the selected one. */ +function shelfRepository(entry?: ShelvedChangeList): string | null { + const state = useVcs.getState(); + return entry?.repo ?? state.roots[0] ?? state.root; +} + export function shelfDiffs(target: ShelfTarget, local = false): DiffData[] { - const root = useVcs.getState().root; + const state = useShelf.getState(); + const root = shelfRepository(state.shelves.find((entry) => entry.id === target.id)); if (!root) return []; - return (useShelf.getState().diffs[target.id] ?? []) + return (state.diffs[target.id] ?? []) .filter((f) => (!target.path || f.path === target.path) && f.before !== null && f.after !== null) .map((f) => ({ - path: `${root}/${f.path}`, + path: repositoryPath(root, f.path), left: { type: "text", text: (local ? f.after : f.before)!, @@ -371,6 +379,7 @@ export async function showShelfDiff(arg?: unknown, local = false, newTab = false } export async function importShelfPatches(paths?: string[]) { + const repo = currentRepo(); const picked = paths ?? (await import("@tauri-apps/plugin-dialog").then(({ open }) => @@ -380,7 +389,7 @@ export async function importShelfPatches(paths?: string[]) { for (const path of typeof picked === "string" ? [picked] : picked) { await runOp( "Import patches", - async () => call(commands.vcsShelfImport(baseName(path), await call(commands.fsReadText(path)))), + async () => call(commands.vcsShelfImport(repo, baseName(path), await call(commands.fsReadText(path)))), { refresh: false }, ); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index be3187ac..5d0f755f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5515,5 +5515,7 @@ "App labels, modules or dotted test paths; empty runs every test": "应用标签、模块或以点分隔的测试路径;留空则运行全部测试", "Settings module": "设置模块", "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值", - "e.g. --keepdb --parallel": "例如 --keepdb --parallel" + "e.g. --keepdb --parallel": "例如 --keepdb --parallel", + "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", + "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改" }