Файли
openidea/src/features/app/closeGuard.ts
T
l0ng-ai c59f93988b feat(vcs): PyCharm-style Commit view and one shared message dialog
- workbench/messages: one promise-based ask/confirm/confirmChecked/showError
  host replaces the per-feature confirm dialogs (app, services, vcs,
  project tree, keymap, run, database, find, LSP choice popup).
- Commit tool window: Commit | Shelf | Stash tabs as the title, IDEA's
  toolbar (Update Project, View Options, Select Opened File), Unversioned
  collapsed, Commit / Commit and Push on one row, empty-message error.
- Commit Options popup: author, sign-off, before-commit checks, Git hooks;
  vcs_commit takes CommitOptions (--author, --no-verify).
- Remove from Recent Projects asks first; refused history rewrites show an
  error dialog.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 22:59:06 +08:00

172 рядки
5.3 KiB
TypeScript

/**
* Window close / quit flow.
*
* Closing a window (traffic light, Quit closing every window) runs
* `decideClose`: confirm exit when this is the last window and
* `general.confirmExit` is on, then Save All / Don't Save / Cancel when
* documents have unsaved changes. Quit confirms once, then the backend closes
* every window, each of which runs only its unsaved-changes check.
*/
import { commands, isTauri, onIdeEvent, type Settings } from "@/platform/ipc";
import { t } from "@/platform/i18n";
import { provideLeaveProjectGuard, useProject } from "@/platform/project";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { ask, type Ask } from "@/workbench/messages";
/** Document ids the backend reports dirty in this window. */
const dirtyDocs = new Set<number>();
export function dirtyCount(): number {
return dirtyDocs.size;
}
export function confirmExitEnabled(settings: Settings | null | undefined): boolean {
return settings?.general?.confirmExit === true;
}
export interface CloseInput {
quitting: boolean;
windows: number;
confirmExit: boolean;
dirty: number;
project: string | null;
}
export interface CloseDeps {
ask: Ask;
saveAll: () => Promise<void>;
disableConfirmExit: () => Promise<void>;
onSaveFailed: (e: unknown) => void;
}
/** Asks whatever must be asked; resolves true when the window may close. */
export async function decideClose(input: CloseInput, deps: CloseDeps): Promise<boolean> {
if (!input.quitting && input.windows <= 1 && input.confirmExit) {
const r = await deps.ask({
title: t("app.exit.title"),
body: t("app.exit.body"),
buttons: [{ id: "exit", label: t("app.exit.confirm"), primary: true }],
checkbox: t("app.exit.dontAsk"),
});
if (r.button !== "exit") return false;
if (r.checked) await deps.disableConfirmExit().catch(() => {});
}
if (input.dirty > 0) {
const r = await deps.ask({
title: t("app.unsaved.title"),
body: t("app.unsaved.body", { count: input.dirty, project: input.project ?? t("app.title.default") }),
buttons: [
{ id: "discard", label: t("app.unsaved.discard") },
{ id: "save", label: t("app.unsaved.save"), primary: true },
],
icon: "warning",
});
if (r.button === "cancel") return false;
if (r.button === "save") {
try {
await deps.saveAll();
} catch (e) {
deps.onSaveFailed(e);
return false;
}
}
}
return true;
}
const defaultDeps: CloseDeps = {
ask,
saveAll: async () => {
await maybeEditorService()?.saveAll();
},
disableConfirmExit: async () => {
const r = await commands.settingsSet("global", "general.confirmExit", "false");
if (r.status === "ok") useProject.setState({ settings: r.data });
},
onSaveFailed: (e) =>
notifications().notify({
level: "error",
title: t("app.unsaved.saveFailed"),
body: e instanceof Error ? e.message : String(e),
}),
};
let closing = false;
async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
if (closing) return false;
closing = true;
try {
const ctx = await commands.windowCloseContext();
const s = useProject.getState();
const ok = await decideClose(
{
quitting: ctx.quitting,
windows: ctx.windows,
confirmExit: confirmExitEnabled(s.settings),
dirty: dirtyCount(),
project: s.project?.name ?? null,
},
deps,
);
if (!ok && ctx.quitting) await commands.appCancelQuit();
return ok;
} finally {
closing = false;
}
}
/** Unsaved changes before this window opens another project in place or closes it. */
export function confirmLeave(deps: CloseDeps = defaultDeps): Promise<boolean> {
const s = useProject.getState();
return decideClose(
{ quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project: s.project?.name ?? null },
deps,
);
}
/** Quit: confirm once (if enabled), then let the backend close every window. */
export async function quitApp(deps: CloseDeps = defaultDeps): Promise<void> {
if (!isTauri()) return;
if (confirmExitEnabled(useProject.getState().settings)) {
const ok = await decideClose({ quitting: false, windows: 1, confirmExit: true, dirty: 0, project: null }, deps);
if (!ok) return;
}
await commands.appQuit();
}
export function installCloseGuard(deps: CloseDeps = defaultDeps): () => void {
const unsubs = [
onIdeEvent("docDirty", (e) => {
if (e.dirty) dirtyDocs.add(e.doc);
else dirtyDocs.delete(e.doc);
}),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) dirtyDocs.clear();
}),
provideLeaveProjectGuard(() => confirmLeave(deps)),
];
let unlisten: (() => void) | undefined;
let disposed = false;
if (isTauri()) {
void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => {
const off = await getCurrentWindow().onCloseRequested(async (event) => {
if (!(await onCloseRequested(deps))) event.preventDefault();
});
if (disposed) off();
else unlisten = off;
});
}
return () => {
disposed = true;
unlisten?.();
unsubs.forEach((u) => u());
};
}
/** Tests only. */
export function _setDirtyForTests(ids: number[]) {
dirtyDocs.clear();
ids.forEach((i) => dirtyDocs.add(i));
}