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 仓库中的文件以搁置更改" }