Файли
openidea/src/features/app/closeGuard.ts
T
l0ng-ai 2d7726cf53 feat(workbench): Edit single files without opening a project (#145)
Files can now be opened without a project. A file dropped on the Welcome screen, picked with File › Open File…, or passed on the command line or to a second instance opens in a lightweight window: editor tabs only, with no project tree, indexing, language servers, VCS or project tool windows. Syntax highlighting, find, save, undo and Local History still work. More files opened later join the same window as new tabs. Open as Project in the status bar opens the file's directory as a full project, following the open-project policy, and reopens the file there.

Backend: a light session reuses Project with an unindexed index, no LSP or VCS, and a flat watch on each opened file's directory. It is never reused as a project for the same root. Previously a file passed at launch opened its parent directory as a project.
2026-10-28 22:06:35 +08:00

178 рядки
5.7 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 { closeProject, 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();
// Closing the last project (or LightEdit) window closes its project and shows the welcome
// screen (to open another one); closing the welcome screen, or quitting, closes the window.
if (!ctx.quitting && ctx.windows <= 1 && (s.project || s.lightEdit)) {
if (await confirmLeave(deps)) await closeProject();
return false;
}
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 || s.lightEdit !== prev.lightEdit) 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));
}