дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(vcs): Add a modal commit mode and fill in Git log, branch popup and settings (#144)
- Settings › Commit › "Use non-modal commit interface": when off, Local Changes, Shelf and Stash become tabs of the Git tool window and ⌘K opens a Commit dialog; switches live. - Git tool window: Worktrees tab; Show History and Show Git Log for a branch open their own closable tabs instead of changing the Log filters. - Log: toolbar and context menu for a commit's changed files, vertical toolbar, Tags node and hiding for the branches pane, Collapse / Expand Linear Branches as separate actions, and a Version Control › Log page. - Branch popup: collapsible groups, branches grouped by prefix, an options menu behind the gear, incoming / outgoing icons and "Pull into … Using Rebase / Merge"; New Branch on ⌥⌘N outside the editor. - Git main menu regrouped with Uncommitted Changes and Current File submenus; rebase / merge controls enabled only while one is in progress. - Settings: Version Control lists its pages; new options for clearing the initial commit message, showing Update options, confirming dropped commits, revealing created patches, large-file and file-name warnings, the Push dialog after Commit and Push, combined stashes and shelves, and removing applied files from the shelf.
Цей коміт міститься в:
1 джерело
66c36a7361
коміт
eb67595e72
54 files changed
+3425
-871
No files matched your search
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const vcsLog = vi.fn();
|
||||
const fsReadText = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
vcsLog: (repo: unknown, query: unknown) => vcsLog(repo, query),
|
||||
vcsConfigGet: async () => ({ status: "ok", data: null }),
|
||||
fsReadText: (path: string) => fsReadText(path),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { gitTabs, shownTab, stashTabOf, stashTabs } from "../gitTabs";
|
||||
import { pushDialogNeeded } from "../commitUi";
|
||||
import { installPreparedMessage, keepMessageAfterCommit, messageAfterCommit, useCommit } from "../commitStore";
|
||||
import { closeLogTab, emptyFilters, openLogTab, selectGitTab, setLogFilters, useGitLog } from "../logStore";
|
||||
import { useVcs } from "../store";
|
||||
|
||||
const ids = (tabs: { id: string }[]) => tabs.map((t) => t.id);
|
||||
|
||||
describe("Git and Commit tool window tabs", () => {
|
||||
const base = { combine: false, shelfCount: 0, stashCount: 0, tab: "log", logTabs: [] };
|
||||
|
||||
it("puts Local Changes, Shelf and Stash into the Git window only with the modal commit interface", () => {
|
||||
expect(ids(gitTabs({ ...base, modal: false }))).toEqual(["log", "console", "worktrees"]);
|
||||
expect(ids(gitTabs({ ...base, modal: true }))).toEqual(["changes", "stashes", "log", "console", "worktrees"]);
|
||||
expect(ids(gitTabs({ ...base, modal: true, shelfCount: 2 }))).toEqual([
|
||||
"changes",
|
||||
"shelf",
|
||||
"stashes",
|
||||
"log",
|
||||
"console",
|
||||
"worktrees",
|
||||
]);
|
||||
});
|
||||
|
||||
it("combines stashes and shelves into one tab", () => {
|
||||
expect(stashTabs({ ...base, combine: true, shelfCount: 2, stashCount: 1 })).toEqual([
|
||||
{ id: "stashes", label: "Stashes and Shelves", count: 3 },
|
||||
]);
|
||||
expect(stashTabOf("shelf", true)).toBe("stashes");
|
||||
expect(stashTabOf("shelf", false)).toBe("shelf");
|
||||
});
|
||||
|
||||
it("lists log tabs as closable after the main Log, and falls back to the Log for a missing tab", () => {
|
||||
const logTabs = [{ id: "history:1", kind: "history" as const, target: "/p/src/a.ts" }];
|
||||
const tabs = gitTabs({ ...base, modal: false, logTabs });
|
||||
expect(tabs.find((t) => t.id === "history:1")).toEqual({ id: "history:1", label: "History: a.ts", closable: true });
|
||||
expect(shownTab("changes", tabs, false)).toBe("log");
|
||||
expect(shownTab("history:1", tabs, false)).toBe("history:1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Commit and Push… push dialog", () => {
|
||||
const git = { showPushDialog: true, pushDialogOnlyProtected: false, protectedBranches: "master;main" };
|
||||
|
||||
it("shows the dialog unless turned off, or only for protected branches", () => {
|
||||
expect(pushDialogNeeded(git, "feature")).toBe(true);
|
||||
expect(pushDialogNeeded({ ...git, showPushDialog: false }, "main")).toBe(false);
|
||||
expect(pushDialogNeeded({ ...git, pushDialogOnlyProtected: true }, "feature")).toBe(false);
|
||||
expect(pushDialogNeeded({ ...git, pushDialogOnlyProtected: true }, "main")).toBe(true);
|
||||
// Nothing to tell the target from (detached HEAD): always the dialog.
|
||||
expect(pushDialogNeeded({ ...git, showPushDialog: false }, null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit message after a commit", () => {
|
||||
beforeEach(() => fsReadText.mockReset());
|
||||
|
||||
it("keeps the committed message unless Clear initial commit message is on", () => {
|
||||
expect(messageAfterCommit(" fix: thing \n", false)).toBe("fix: thing");
|
||||
expect(messageAfterCommit("fix: thing", true)).toBe("");
|
||||
});
|
||||
|
||||
it("lets a prepared merge message replace a kept one, but not a typed one", async () => {
|
||||
useVcs.setState({ root: "/p", branch: null });
|
||||
const dispose = installPreparedMessage();
|
||||
keepMessageAfterCommit("fix: thing", false);
|
||||
fsReadText.mockResolvedValue({ status: "ok", data: "Merge branch 'x'\n# Conflicts:\n" });
|
||||
useVcs.setState({ branch: { operation: "merge" } as never });
|
||||
await vi.waitFor(() => expect(useCommit.getState().message).toBe("Merge branch 'x'"));
|
||||
useVcs.setState({ branch: null });
|
||||
|
||||
useCommit.setState({ message: "typed" });
|
||||
useVcs.setState({ branch: { operation: "merge" } as never });
|
||||
await Promise.resolve();
|
||||
expect(useCommit.getState().message).toBe("typed");
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("log tabs of their own", () => {
|
||||
beforeEach(() => {
|
||||
vcsLog.mockReset().mockResolvedValue({ status: "ok", data: [] });
|
||||
useVcs.setState({ root: "/p", repo: "/p", roots: ["/p"] });
|
||||
useGitLog.setState({ tab: "log", logTabs: [], shownLog: "log", filters: emptyFilters() });
|
||||
});
|
||||
|
||||
it("opens Show History in a closable tab and leaves the main Log's filters alone", () => {
|
||||
setLogFilters({ authors: ["bob"] });
|
||||
const id = openLogTab("history", "/p/src/a.ts");
|
||||
expect(useGitLog.getState()).toMatchObject({ tab: id, shownLog: id });
|
||||
expect(useGitLog.getState().filters).toMatchObject({ path: "/p/src/a.ts", authors: [] });
|
||||
// The same file again: the same tab.
|
||||
selectGitTab("log");
|
||||
expect(useGitLog.getState().filters.authors).toEqual(["bob"]);
|
||||
expect(openLogTab("history", "/p/src/a.ts")).toBe(id);
|
||||
expect(useGitLog.getState().logTabs).toHaveLength(1);
|
||||
|
||||
closeLogTab(id);
|
||||
expect(useGitLog.getState()).toMatchObject({ tab: "log", shownLog: "log", logTabs: [] });
|
||||
expect(useGitLog.getState().filters).toMatchObject({ authors: ["bob"], path: null });
|
||||
});
|
||||
|
||||
it("keeps a log tab's view while another content tab is shown", () => {
|
||||
const id = openLogTab("branch", "feature");
|
||||
selectGitTab("console");
|
||||
expect(useGitLog.getState()).toMatchObject({ tab: "console", shownLog: id });
|
||||
selectGitTab(id);
|
||||
expect(useGitLog.getState().filters.branches).toEqual(["feature"]);
|
||||
closeLogTab(id);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача