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 <[email protected]>
Цей коміт міститься в:
l0ng-aiтаClaude Opus 5.5 authored and GitHub committed 2026-11-03 07:52:55 +08:00
1 джерело 7af2b098cc
коміт d9929bbe44
15 files changed
+327 -44

No files matched your search

+3 -1
Переглянути файл
@@ -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[],
};
+21
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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 仓库中的文件以搁置更改"
}