дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
7af2b098cc
коміт
d9929bbe44
15 files changed
+327
-44
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Заблокувати користувача