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 <noreply@anthropic.com>
This commit is contained in:
l0ng-ai
2026-11-03 07:52:55 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7af2b098cc
commit d9929bbe44
15 changed files with 327 additions and 44 deletions
+44 -18
View File
@@ -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<PathBuf> {
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<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
@@ -854,16 +854,23 @@ pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<Path
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
in_primary_repo(&project, &paths)?;
let repo = shelf_repository(&project, &paths)?;
keep_private(&project.root);
project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
project.with_repo_recorded(Some(&repo), Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
})
.await
}
pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList>> {
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<Strin
pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_vcs::shelf::ShelfDiffFile>> {
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<PathBuf>,
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<ChangelistState> {
@@ -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) });
+123
View File
@@ -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;
}
+3
View File
@@ -297,6 +297,9 @@ pub struct ShelvedChangeList {
pub time_ms: f64,
/// Branch checked out when the changes were shelved.
pub branch: Option<String>,
/// Repository owning the patch; absent in legacy shelves.
#[serde(default)]
pub repo: Option<ide_core::IpcPath>,
/// Repository-relative paths.
pub files: Vec<ChangedFile>,
}
+26 -5
View File
@@ -19,6 +19,8 @@ struct Info {
#[serde(default)]
branch: Option<String>,
files: Vec<crate::ChangedFile>,
#[serde(default)]
repo: Option<ide_core::IpcPath>,
}
fn now_ms() -> f64 {
@@ -67,6 +69,7 @@ pub fn list(shelf: &Path) -> IdeResult<Vec<ShelvedChangeList>> {
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<Vec<ShelvedChangeList>> {
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<String> {
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<Option<PathBuf>> {
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<Vec<ShelfDiffFi
}
/// Import into the shelf without applying to the working tree.
pub fn import_patch(shelf: &Path, name: &str, patch: &str) -> 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 })
+3 -3
View File
@@ -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"));
+2 -1
View File
@@ -675,10 +675,11 @@ pub async fn vcs_shelf_diff(
pub async fn vcs_shelf_import(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
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]
+3 -1
View File
@@ -546,7 +546,7 @@ export const commands = {
vcsShelfRename: (id: string, name: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_rename", { id, name })),
vcsShelfPatch: (id: string, path: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_shelf_patch", { id, path })),
vcsShelfDiff: (id: string) => typedError<ShelfDiffFile[], IdeError>(__TAURI_INVOKE("vcs_shelf_diff", { id })),
vcsShelfImport: (name: string, patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_import", { name, patch })),
vcsShelfImport: (repo: string | null, name: string, patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_import", { repo, name, patch })),
vcsChangelistsLoad: () => typedError<ChangelistState, IdeError>(__TAURI_INVOKE("vcs_changelists_load")),
vcsChangelistsSave: (changelists: ChangelistState) => typedError<null, IdeError>(__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[],
};
@@ -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);
});
});
+66 -4
View File
@@ -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<typeof import("../diffInput")>()),
openDiff: mocks.openDiff,
@@ -19,13 +19,16 @@ vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()).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(<VcsDialogHost />);
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);
+2 -1
View File
@@ -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, `<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). */
+2 -1
View File
@@ -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>): 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. */
+3 -2
View File
@@ -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"
+10
View File
@@ -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}`;
}
+16 -7
View File
@@ -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 },
);
}
+3 -1
View File
@@ -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 仓库中的文件以搁置更改"
}