Файли
openidea/src/features/vcs/ops.ts
T
l0ng-ai 1e00ea5ee2 feat: Third pass aligning core experience with IntelliJ IDEA
Project & files
- Project view modes (Project Files / Open Files / All Changed Files /
  Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go,
  Maven/Gradle jars; read-only jar/zip browsing
- ⌘N New popup with type search and more file kinds; New Project, Rename
  Project, Reload All from Disk, Export/Import Settings, Power Save Mode
- File type associations, extra content roots and exclude patterns (indexed
  and watched without reopening the project)

Editor & navigation
- Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline,
  history; Find in Files: Module scope, custom pattern scopes, multiline
- Search Everywhere filters, Assign Shortcut, settings results; named bookmark
  lists, file/folder bookmarks; Structure filters and Show Inherited; Recent
  Locations timestamps and breadcrumbs; Code Vision author
- Completion options, editor tab placement/layout, inline completion (LSP 3.18)
- Spell checking (Typo inspection, ide-spell) and Inspections settings with
  custom profiles; Code Style schemes, formatter markers, Do not format

Run, VCS, services, database
- Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker,
  Compound), Modify options, Before launch, env dialog, Current File,
  Emulate terminal (PTY)
- Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the
  graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees
- Generic Services host (Docker connections/registries, run sessions); TODO
  scope/changes tabs with changelists; Coverage report import
- Data sources: wire-compatible vendors, URL import, SSL certs, Options,
  Schemas and Advanced tabs, color and comment

Workbench
- Run Anything, Customize Main Toolbar, named tool window layouts, menu
  completeness, Code Cleanup, macros, "Don't Show Again", editor banners
- Chinese translations for the new UI

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-25 21:24:00 +08:00

448 рядки
15 KiB
TypeScript

/**
* Running git operations: error reporting, console output, network ops with
* progress / cancellation, and writing text back into working files.
*/
import { Channel } from "@tauri-apps/api/core";
import { create } from "zustand";
import type { GitOperation, RemoteOp } from "@/bindings";
import { call, commands, emitLocalIdeEvent, isCancelled, nextRequestId } from "@/platform/ipc";
import { editorsFor } from "@/editor/api";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { progressFraction } from "./model";
import { currentRepo, refreshNow, repoName, useVcs } from "./store";
// ---------------------------------------------------------------------------
// Console (Git tool window → Console tab)
export type ConsoleKind = "cmd" | "out" | "err" | "info";
export interface ConsoleLine {
id: number;
time: number;
kind: ConsoleKind;
text: string;
}
interface ConsoleState {
lines: ConsoleLine[];
}
const MAX_LINES = 5000;
let lineSeq = 0;
export const useVcsConsole = create<ConsoleState>(() => ({ lines: [] }));
export function logConsole(kind: ConsoleKind, text: string) {
const time = Date.now();
const add = text
.replace(/\r\n?/g, "\n")
.split("\n")
.filter((l, i, all) => l.length > 0 || (i > 0 && i < all.length - 1))
.map((l) => ({ id: ++lineSeq, time, kind, text: l }));
if (!add.length) return;
useVcsConsole.setState((s) => {
const lines = s.lines.concat(add);
return { lines: lines.length > MAX_LINES ? lines.slice(lines.length - MAX_LINES) : lines };
});
}
export function clearConsole() {
useVcsConsole.setState({ lines: [] });
}
export function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
// ---------------------------------------------------------------------------
// Local operations
export interface OpOptions<T> {
/** Success balloon text. */
success?: string | ((value: T) => string);
/** Refresh status afterwards (default true). */
refresh?: boolean;
/** Extra actions on the error balloon. */
errorActions?: (message: string) => Array<{ label: string; run: () => void }>;
/** Don't show an error balloon (the caller handles it). */
quiet?: boolean;
/** Called with the failure message; returning true suppresses the error balloon. */
onError?: (message: string) => boolean | Promise<boolean>;
}
/** Runs `fn`, logging to the console and reporting failures. `undefined` on error. */
export async function runOp<T>(title: string, fn: () => Promise<T>, opts: OpOptions<T> = {}): Promise<T | undefined> {
logConsole("cmd", title);
try {
const value = await fn();
const msg = typeof opts.success === "function" ? opts.success(value) : opts.success;
if (msg) {
logConsole("info", msg);
notifications().notify({ group: "vcs", level: "info", title: msg });
}
return value;
} catch (e) {
const message = errorMessage(e);
logConsole("err", message);
const handled = opts.onError ? await opts.onError(message) : false;
if (!opts.quiet && !handled)
notifications().notify({
group: "vcs",
level: "error",
title: `${title} failed`,
body: message,
actions: opts.errorActions?.(message),
});
return undefined;
} finally {
if (opts.refresh !== false) void refreshNow();
}
}
/** Saves all documents so git sees the editor contents. */
export async function saveAll() {
try {
await maybeEditorService()?.saveAll();
} catch {
// Saving is best effort; git then works on what is on disk.
}
}
// ---------------------------------------------------------------------------
// Network operations
const REMOTE_TITLE: Record<RemoteOp["op"], [string, string]> = {
fetch: ["Fetching", "Fetch"],
pull: ["Updating project", "Update"],
pullFrom: ["Pulling", "Pull"],
push: ["Pushing", "Push"],
deleteBranch: ["Deleting remote branch", "Delete remote branch"],
pushBranch: ["Pushing", "Push"],
updateBranch: ["Updating branch", "Update"],
};
let activeRemote: number | null = null;
/** Error message of the last failed remote operation (null after a success / cancel). */
let lastRemoteError: string | null = null;
export function lastRemoteFailure(): string | null {
return lastRemoteError;
}
export function cancelRemote() {
if (activeRemote !== null) void commands.cancelRequest(activeRemote);
}
/** Every repository of the project (Fetch and Update Project act on all of them, as in IDEA). */
export function allRepos(): Array<string | null> {
const roots = useVcs.getState().roots;
return roots.length ? roots : [null];
}
/**
* Runs fetch / pull / push with progress in the status bar and output in the console,
* in each of `repos` (default: the selected repository) in turn.
*/
export async function runRemote(
op: RemoteOp,
detail?: string,
repos: ReadonlyArray<string | null> = [currentRepo()],
): Promise<boolean> {
if (useVcs.getState().remoteBusy) {
notifications().notify({ group: "vcs", level: "warning", title: "Another remote operation is in progress" });
return false;
}
const [progressTitle, doneTitle] = REMOTE_TITLE[op.op];
const request = nextRequestId();
const taskId = 900_000 + request;
activeRemote = request;
useVcs.setState({ remoteBusy: progressTitle });
const task = (message: string | null, fraction: number | null, done: boolean) =>
emitLocalIdeEvent({ type: "task", id: taskId, title: progressTitle, message, fraction, done });
task(detail ?? null, null, false);
notifications().notify({
group: "vcs",
level: "info",
key: "vcs.remote",
title: `${progressTitle}…`,
body: detail,
sticky: true,
actions: [{ label: "Cancel", run: cancelRemote }],
});
logConsole("cmd", `git ${op.op}${detail ? ` (${detail})` : ""}`);
const channel = new Channel<string>();
let lastLine = "";
channel.onmessage = (line) => {
logConsole("out", line);
const text = line.trim();
if (text) lastLine = text;
task(text || null, progressFraction(text), false);
};
try {
// Every repository is tried; the failures are reported together. A pull skips the
// repositories that have nothing to pull from (detached HEAD, e.g. submodules; no upstream).
const failures: string[] = [];
const updated: UpdatedRange[] = [];
for (const repo of repos) {
const name = repos.length > 1 && repo ? repoName(repo) : null;
if (name) logConsole("info", name);
if (op.op === "pull" && repos.length > 1) {
const info = await call(commands.vcsBranchInfo(repo)).catch(() => null);
if (info && (!info.head || !info.upstream)) {
logConsole("info", info.head ? "No upstream branch, skipped" : "Detached HEAD, skipped");
continue;
}
}
try {
const before = op.op === "pull" || op.op === "pullFrom" ? await headCommit(repo) : null;
await call(commands.vcsRemote(repo, request, op, channel));
const after = before ? await headCommit(repo) : null;
if (before && after && before !== after) updated.push({ repo, from: before, to: after });
} catch (e) {
if (isCancelled(e) || !name) throw e;
logConsole("err", errorMessage(e));
failures.push(`${name}: ${errorMessage(e)}`);
}
}
if (failures.length) throw new Error(failures.join("\n"));
lastRemoteError = null;
if (op.op === "pull" || op.op === "pullFrom") await notifyUpdated(updated);
else
notifications().notify({
group: "vcs",
level: "info",
key: "vcs.remote",
title: `${doneTitle} successful`,
body: lastLine && !/%/.test(lastLine) ? lastLine : undefined,
});
return true;
} catch (e) {
const cancelled = isCancelled(e);
lastRemoteError = cancelled ? null : errorMessage(e);
logConsole(cancelled ? "info" : "err", cancelled ? `${doneTitle} cancelled` : errorMessage(e));
notifications().notify({
group: "vcs",
level: cancelled ? "warning" : "error",
key: "vcs.remote",
title: cancelled ? `${doneTitle} cancelled` : `${doneTitle} failed`,
body: cancelled ? undefined : errorMessage(e),
});
return false;
} finally {
task(null, 1, true);
activeRemote = null;
useVcs.setState({ remoteBusy: null });
void refreshNow();
}
}
interface UpdatedRange {
repo: string | null;
from: string;
to: string;
}
const headCommit = (repo: string | null) =>
call(commands.vcsBranchInfo(repo)).then(
(i) => i.headCommit,
() => null,
);
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
/** IDEA's Update Project result: "N files updated in M commits" with View Commits. */
async function notifyUpdated(updated: readonly UpdatedRange[]) {
if (!updated.length) {
notifications().notify({ group: "vcs", level: "info", key: "vcs.remote", title: "All files are up-to-date" });
return;
}
let files = 0;
let commits = 0;
await Promise.all(
updated.map(async ({ repo, from, to }) => {
const [changed, log] = await Promise.all([
call(commands.vcsDiffRevisions(repo, from, to)).catch(() => []),
call(
commands.vcsLog(repo, {
branch: null,
path: null,
author: null,
text: null,
range: `${from}..${to}`,
skip: 0,
limit: 0,
}),
).catch(() => []),
]);
files += changed.length;
commits += log.length;
}),
);
notifications().notify({
group: "vcs",
level: "info",
key: "vcs.remote",
title: `${plural(files, "file")} updated in ${plural(commits, "commit")}`,
actions: [
{
label: "View Commits",
run: () => {
const { repo, from, to } = updated.find((u) => u.repo === currentRepo()) ?? updated[0]!;
void import("./CompareBranches").then((m) => m.showCommitRange("Update Info", from, to, repo));
},
},
],
});
}
// ---------------------------------------------------------------------------
// Local history operations (continue / abort / cherry-pick / revert / reset)
export interface GitOperationOptions {
/** Status bar / balloon text while running, e.g. "Cherry-picking". */
progress: string;
/** Past-tense balloon on success, e.g. "Cherry-picked abc123"; a function is called after the status refreshed. */
success: string | (() => string);
/** Title of the error balloon, e.g. "Cherry-pick failed". */
failure: string;
/** Called with the failure message; returning true suppresses the error balloon. */
onError?: (message: string) => boolean | Promise<boolean>;
}
/**
* Runs a long local git operation like `runRemote`: progress in the status bar,
* output in the Git console, a sticky balloon with Cancel.
*/
export async function runGitOperation(op: GitOperation, opts: GitOperationOptions): Promise<boolean> {
if (useVcs.getState().remoteBusy) {
notifications().notify({ group: "vcs", level: "warning", title: "Another Git operation is in progress" });
return false;
}
await saveAll();
const request = nextRequestId();
const taskId = 900_000 + request;
activeRemote = request;
useVcs.setState({ remoteBusy: opts.progress });
const task = (message: string | null, done: boolean) =>
emitLocalIdeEvent({ type: "task", id: taskId, title: opts.progress, message, fraction: done ? 1 : null, done });
task(null, false);
notifications().notify({
group: "vcs",
level: "info",
key: "vcs.operation",
title: `${opts.progress}…`,
sticky: true,
actions: [{ label: "Cancel", run: cancelRemote }],
});
logConsole("cmd", `git ${describeOperation(op)}`);
const channel = new Channel<string>();
channel.onmessage = (line) => {
logConsole("out", line);
task(line.trim() || null, false);
};
try {
await call(commands.vcsOperation(currentRepo(), request, op, channel));
let success = opts.success;
if (typeof success === "function") {
await refreshNow();
success = success();
}
logConsole("info", success);
notifications().notify({ group: "vcs", level: "info", key: "vcs.operation", title: success });
return true;
} catch (e) {
const cancelled = isCancelled(e);
const message = errorMessage(e);
logConsole(cancelled ? "info" : "err", cancelled ? `${opts.failure}: cancelled` : message);
notifications().expire?.("vcs.operation");
await refreshNow();
const handled = !cancelled && opts.onError ? await opts.onError(message) : false;
if (!handled)
notifications().notify({
group: "vcs",
level: cancelled ? "warning" : "error",
key: "vcs.operation",
title: cancelled ? `${opts.progress} cancelled` : opts.failure,
body: cancelled ? undefined : message,
});
return false;
} finally {
task(null, true);
activeRemote = null;
useVcs.setState({ remoteBusy: null });
void refreshNow();
}
}
export function describeOperation(op: GitOperation): string {
switch (op.op) {
case "continue":
case "abort":
case "skip":
return `${useVcs.getState().branch?.operation ?? ""} --${op.op}`.trim();
case "cherryPick":
return `cherry-pick ${op.commits.map((c) => c.slice(0, 8)).join(" ")}`;
case "revert":
return `revert ${op.commits.map((c) => c.slice(0, 8)).join(" ")}`;
case "reset":
return `reset --${op.mode} ${op.commit.slice(0, 8)}`;
case "interactiveRebase":
return `rebase --interactive ${op.base ? op.base.slice(0, 8) : "--root"}`;
}
}
// ---------------------------------------------------------------------------
// Working file text
export interface TextEdit {
from: number;
to: number;
insert: string;
}
/** Current text of a working file: the open editor's buffer, else the file (read-only). */
export async function readWorkingText(path: string): Promise<string | null> {
const editor = editorsFor(path)[0];
if (editor) return editor.view.state.doc.toString();
try {
return await call(commands.fsReadText(path));
} catch {
return null;
}
}
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Applies edits (in the coordinates of `readWorkingText(path)`) to a working
* file: through the open editor (one undoable transaction) or, when the file
* isn't open, through a transient backend document that is saved.
*/
export async function applyFileEdits(path: string, edits: TextEdit[], opts: { save?: boolean } = {}) {
const editor = editorsFor(path)[0];
if (editor) {
editor.view.dispatch({ changes: edits, userEvent: "input.vcs", scrollIntoView: true });
if (opts.save) {
// Let editor/core sync the change to the backend before saving.
await tick(60);
await maybeEditorService()?.saveAll();
}
return;
}
const info = await call(commands.docOpen(path));
try {
await call(commands.docEdit(info.id, info.version, { changes: [...edits].sort((a, b) => a.from - b.from) }));
await call(commands.docSave(info.id));
} finally {
void commands.docClose(info.id);
}
}
/** Replaces the whole content of a working file. */
export async function writeFileText(path: string, text: string, opts: { save?: boolean } = {}) {
const current = (await readWorkingText(path)) ?? "";
if (current === text) {
if (opts.save) await saveAll();
return;
}
await applyFileEdits(path, [{ from: 0, to: current.length, insert: text }], opts);
}