Файли
openidea/src/features/vcs/index.ts
T
l0ng-ai 2dc078b93b feat(plugins): commit workflow API for plugins
Plugin API 1.3 adds what commit-message generators and before-commit checks need:

- `menu = "commit.message"`: a plugin command, with the plugin's icon, in the toolbar above the commit message of the Commit tool window and the Commit dialog.
- `commit.pending-commit()` (`vcs.read`): what Commit would commit right now, per file its repository, path, status and a `git diff` of only the included files and changed blocks against HEAD (1 MiB in all), plus the message, Amend and a revision. The diff is made by the same selection the commit uses, in a copy of the index, so the index and the working tree stay as they are; unsaved editors of the included files are saved first, as Commit does. Only in trusted projects.
- `commit.set-commit-message(revision, message)` (`vcs.write`): replaces the message only while the draft is still at that revision, so text the user typed meanwhile is never overwritten (`stale`).
- `[[contributes.commitChecks]]` and the optional `commit-checker` export (worlds `plugin-commit-checks` / `commit-checks`): checks listed in Commit Options (on by default, `vcs.commit.disabledPluginChecks` turns them off) and run after the IDE's own; their problems, optionally with a location, stop the commit until Commit Anyway.

The window answers the host through `CommitRequested` / `plugin_answer_commit`, also for remote windows. The Rust SDK gains `host::pending_commit`, `host::set_commit_message`, `openidea_plugin::commit` and their mock; a `commit` test fixture exercises them. All components are rebuilt for the WIT change.

Refs #1650
2026-11-07 16:12:10 +08:00

329 рядки
12 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* Version control (Git) feature: status decorations, title bar branch widget,
* editor change markers / annotations, Commit and Git tool windows, diff,
* merge and Local History editors, remote operations.
*/
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { editorsFor, registerEditorContribution } from "@/editor/api";
import {
notifications,
registerEditorKind,
registerMenuGroup,
registerOverlay,
registerSettingsPage,
registerTitleItem,
registerToolWindow,
} from "@/workbench/contributions";
import { executeAction } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { lazyComponent } from "@/ui";
import { registerVcsActions } from "./actions";
import { installChangelists } from "./changelists";
import { registerHistoryActions, trackOperationContext } from "./historyActions";
import { registerLogPaneActions } from "./logPaneActions";
import { controlLabel, controlOperation, inProgress } from "./operations";
import { annotationsShown, blameSlot } from "./blame";
import { inlineBlame } from "./inlineBlame";
import { BranchWidget, useBranchesPopup } from "./BranchWidget";
import { showConflictsDialog, takeContinueWhenResolved } from "./conflictsUi";
import { registerCompareActions } from "./compare";
import { DIFF_KIND, diffTitle, type DiffData } from "./diffInput";
import { VcsDialogHost } from "./dialogs";
import { changeMarkers } from "./gutter";
import { installHunkPopup } from "./HunkPopup";
import { LOCAL_HISTORY_KIND } from "./localHistoryKind";
import { SELECTION_HISTORY_KIND, type SelectionHistoryData } from "./selectionHistoryKind";
import { registerLocalHistoryActions } from "./localHistoryActions";
import { MERGE_KIND, type MergeData } from "./mergeKind";
import { registerMergeActions } from "./mergeActions";
import { baseName } from "./model";
import { installDiagnosticsTracking } from "./commitChecks";
import { installPreparedMessage, useCommit } from "./commitStore";
import { installDraftRevision, provideCommit } from "./commitSelection";
import { activate as activateConflictBanner } from "./conflictBanner";
import { activate as activateConflictMarkers } from "./conflictMarkers";
import { MergeRebaseWidget } from "./MergeRebaseWidget";
import { installFileConfirmations } from "./confirmations";
import { installIncomingCheck } from "./incoming";
import { closeLogTab, installGitLog, selectGitTab, useGitLog } from "./logStore";
import { installVcsStore, onConflictsAppeared, onConflictsCleared, useVcs } from "./store";
import { tx } from "@/platform/i18n";
/** The Resolve/Continue/Abort buttons exist in the title bar only while there is something to resolve. */
function trackMergeRebaseItem(): () => void {
let dispose: (() => void) | null = null;
const sync = () => {
const s = useVcs.getState();
const needed =
s.available && (!!inProgress(s.branch?.operation) || s.status.some((f) => f.unstaged === "conflicted"));
if (needed && !dispose) {
dispose = registerTitleItem({
id: "vcs.mergeRebase",
title: "Merge/Rebase Widget",
align: "left",
priority: 11,
component: MergeRebaseWidget,
});
} else if (!needed && dispose) {
dispose();
dispose = null;
}
};
sync();
const unsub = useVcs.subscribe(sync);
return () => {
unsub();
dispose?.();
};
}
function trackTitleItem(): () => void {
let dispose: (() => void) | null = null;
const sync = (available: boolean) => {
if (available && !dispose) {
dispose = registerTitleItem({
id: "vcs.branch",
title: "VCS Widget",
align: "left",
priority: 10,
component: BranchWidget,
});
} else if (!available && dispose) {
dispose();
dispose = null;
useBranchesPopup.setState({ open: false });
}
};
sync(useVcs.getState().available);
const unsub = useVcs.subscribe((s, p) => {
if (s.available !== p.available) sync(s.available);
});
return () => {
unsub();
dispose?.();
};
}
/**
* The Commit tool window exists with the non-modal commit interface only; with the
* modal one its tabs move to the Git tool window (and Commit… opens a dialog).
*/
function trackCommitWindow(): () => void {
let dispose: (() => void) | null = null;
const sync = (nonModal: boolean) => {
if (nonModal && !dispose) {
dispose = registerToolWindow({
id: "commit",
title: "Commit",
icon: "toolwindow-commit",
anchor: "left",
order: 1,
component: lazyComponent(() => import("./CommitView").then((m) => m.CommitToolWindow)),
headerContent: lazyComponent(() => import("./CommitView").then((m) => m.CommitTabs)),
tabsReplaceTitle: true,
useBadge: () => {
const n = useVcs((s) => s.status.length);
return n || null;
},
});
// The Git window's Local Changes / Shelf / Stash tabs are gone: back to its Log.
if (["changes", "shelf", "stashes"].includes(useGitLog.getState().tab)) selectGitTab("log");
} else if (!nonModal && dispose) {
dispose();
dispose = null;
}
};
sync(workflowSettings().vcs.commit.nonModal);
const unsub = onWorkflowSettingsChange((w) => w.vcs.commit.nonModal, sync);
return () => {
unsub();
dispose?.();
};
}
function conflictsBody(names: string[]): string {
const list = names.slice(0, 3).join(", ") + (names.length > 3 ? "…" : "");
return names.length === 1
? tx`${names.length} file has conflicts: ${list}`
: tx`${names.length} files have conflicts: ${list}`;
}
/** Settings › Version Control › Log (also used by the Settings window). */
export { activateComparisonWindow, ComparisonWindow } from "./ComparisonWindow";
/** The diff viewer (editor kind `diff`), also for windows that show diffs without Git (deployment). */
export function activateDiffViewer(): () => void {
return registerEditorKind({
kind: DIFF_KIND,
standalone: true,
component: lazyComponent(() => import("./diff-editor-tab").then((m) => m.DiffEditorTab)),
title: (input) => diffTitle(input.data as DiffData),
icon: () => "diff",
});
}
export function activateSettingsPages(): () => void {
return registerSettingsPage({
id: "vcs.log",
title: "Log",
order: 6,
keywords: ["git log", "references", "tags", "columns", "commit time"],
component: lazyComponent(() => import("./LogSettingsPage").then((m) => m.LogSettingsPage)),
});
}
export function activate(): () => void {
onConflictsAppeared((files) =>
notifications().notify({
group: "vcs",
level: "warning",
key: "vcs.conflicts",
title: "Merge conflicts",
body: conflictsBody(files.map((f) => baseName(f.path))),
sticky: true,
actions: [{ label: "Resolve…", run: showConflictsDialog }],
}),
);
onConflictsCleared(() => {
notifications().expire?.("vcs.conflicts");
const op = inProgress();
// Resolved from the Conflicts dialog: the stopped rebase / cherry-pick goes on by itself.
if (op && takeContinueWhenResolved() === op) {
void controlOperation("continue", op);
return;
}
// Otherwise, as IDEA does once the last conflict is resolved: offer to continue it.
if (op)
notifications().notify({
group: "vcs",
level: "info",
key: "vcs.conflicts",
title: "All conflicts resolved",
sticky: true,
actions: [
op === "merge"
? { label: "Commit…", run: () => void executeAction("vcs.commit") }
: { label: controlLabel(op, "continue"), run: () => void controlOperation("continue", op) },
{ label: controlLabel(op, "abort"), run: () => void controlOperation("abort", op) },
],
});
});
useCommit.setState({ signOff: workflowSettings().vcs.git.signOff });
const disposers = [
installVcsStore(),
installGitLog(),
installDiagnosticsTracking(),
installPreparedMessage(),
installDraftRevision(),
provideCommit(),
installIncomingCheck(),
installFileConfirmations(),
// Settings › Git › Sign-off commit: the default of Commit Options › Sign-off Commit.
onWorkflowSettingsChange(
(w) => w.vcs.git.signOff,
(signOff) => useCommit.setState({ signOff }),
),
registerVcsActions(),
registerHistoryActions(),
registerLogPaneActions(),
activateSettingsPages(),
registerLocalHistoryActions(),
installChangelists(),
trackOperationContext(),
registerCompareActions(),
registerMergeActions(),
trackTitleItem(),
trackMergeRebaseItem(),
installHunkPopup(),
registerOverlay({ id: "vcs.dialogs", component: VcsDialogHost }),
activateConflictBanner(),
activateConflictMarkers(),
trackCommitWindow(),
registerToolWindow({
id: "git",
title: "Git",
icon: "toolwindow-git",
anchor: "bottom",
order: 5,
component: lazyComponent(() => import("./GitLog").then((m) => m.GitToolWindow)),
headerContent: lazyComponent(() => import("./GitLog").then((m) => m.GitTabs)),
}),
registerEditorKind({
kind: "git-revision",
standalone: true,
component: lazyComponent(() => import("./RevisionBrowser").then((m) => m.GitRevisionEditor)),
title: (input) => {
const data = input.data as { path: string; revision: string };
return `${baseName(data.path)} (${data.revision.slice(0, 8)})`;
},
icon: () => "file",
restorable: true,
}),
registerEditorKind({
kind: "git-log",
standalone: true,
component: lazyComponent(() => import("./LogScope").then((m) => m.GitLogEditor)),
title: (input) => (input.data as { label: string }).label,
icon: () => "toolwindow-git",
restorable: true,
onClose: (input) => closeLogTab((input.data as { id: string }).id),
}),
registerEditorKind({
kind: "git-merge-diff",
standalone: true,
component: lazyComponent(() => import("./GitMergeDiff").then((m) => m.GitMergeDiff)),
title: (input) => (input.data as { path: string }).path,
icon: () => "diff",
restorable: true,
}),
activateDiffViewer(),
registerEditorKind({
kind: MERGE_KIND,
component: lazyComponent(() => import("./MergeEditor").then((m) => m.MergeEditor)),
title: (input) => `Merge: ${baseName((input.data as MergeData).path)}`,
icon: () => "branch",
}),
registerEditorKind({
kind: LOCAL_HISTORY_KIND,
component: lazyComponent(() => import("./LocalHistory").then((m) => m.LocalHistoryEditor)),
title: (input) => `Local History: ${baseName((input.data as { path: string }).path)}`,
icon: () => "clock",
}),
registerEditorKind({
kind: SELECTION_HISTORY_KIND,
component: lazyComponent(() => import("./SelectionHistory").then((m) => m.SelectionHistoryEditor)),
title: (input) => {
const { path, lines } = input.data as SelectionHistoryData;
return `History: ${baseName(path)}:${lines[0] + 1}-${lines[1] + 1}`;
},
icon: () => "history",
}),
registerEditorContribution({
id: "vcs.changeMarkers",
order: 60,
extensions: (ctx) => [changeMarkers(ctx), blameSlot(), inlineBlame(ctx)],
}),
registerMenuGroup({
id: "vcs.gutter",
menus: ["editorGutter"],
order: 10,
items: (_menu, { paths: [path] }) => {
if (!path || !getContext().vcsAvailable) return [];
const shown = editorsFor(path).some((h) => annotationsShown(h.view));
return [
{
label: shown ? "Close Annotations" : "Annotate with Git Blame",
run: () => void executeAction("vcs.annotate", path),
},
];
},
}),
];
return () => {
onConflictsAppeared(() => undefined);
disposers.reverse().forEach((d) => d());
};
}