fix(vcs): open comparisons in independently resizable native windows (#818)

Compare with Local, Compare Versions and the other revision comparisons open in a separate window that belongs to their project window. The window can be moved outside the main window, resized from its edges and corners, and shows the full diff. Edits made there and in the project window are mirrored both ways.

Root cause of #798: the comparison was an in-window dialog with a fixed size.

Settings and comparison windows now share one auxiliary-window mechanism (`AuxKind`, `open_auxiliary_window`). It handles labels, the title bar, placement over the parent, event forwarding, document-edit mirroring, menu routing, closing together with the parent, and remembered geometry per kind.

Co-authored-by: xdpan1234 <[email protected]>
Цей коміт міститься в:
xdpan1234таxdpan1234 authored and GitHub committed 2026-11-04 12:27:45 +08:00
1 джерело 268cf01a86
коміт 0a70b0af01
26 files changed
+918 -198

No files matched your search

+29
Переглянути файл
@@ -769,6 +769,11 @@ export const commands = {
* `background` (`#rrggbb`) paints a new window until its page has loaded.
*/
windowOpenSettings: (page: string | null, newProjects: boolean, background: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_open_settings", { page, newProjects, background })),
/**
* Opens a window listing the files that differ between two revisions of this window's project.
* `background` (`#rrggbb`) paints it until its page has loaded.
*/
windowOpenComparison: (comparison: ComparisonLaunch, background: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_open_comparison", { comparison, background })),
/** Focuses the next / previous window (⌘` / ⇧⌘`). */
windowCycle: (forward: boolean) => __TAURI_INVOKE<void>("window_cycle", { forward }),
windowToggleFullscreen: () => typedError<boolean, IdeError>(__TAURI_INVOKE("window_toggle_fullscreen")),
@@ -791,6 +796,8 @@ export const commands = {
newProjects: boolean,
/** Where to place the caret in some of `files` (`openidea --line 12 --column 5 <file>`). */
positions?: FilePosition[],
/** Comparison windows: the revisions to compare (boxed: most launches have none). */
comparison?: ComparisonLaunch | null,
} | null>("window_take_launch"),
windowCloseContext: () => __TAURI_INVOKE<CloseContext>("window_close_context"),
/** Projects open in project windows, in window order (title bar project widget). */
@@ -2126,6 +2133,26 @@ export type CompareScope = {
singleFile: boolean,
};
/** Files that differ between `from` and `to` (the working tree when `None`). */
export type ComparisonLaunch = {
from: ComparisonRevision,
to: ComparisonRevision | null,
scope: ComparisonScope | null,
repo: IpcPath | null,
};
/** A Git revision and how the comparison window names it. */
export type ComparisonRevision = {
rev: string,
label: string,
};
/** Limits a comparison to some paths (Compare with Branch on a selection). */
export type ComparisonScope = {
paths: IpcPath[],
repositories: IpcPath[] | null,
};
export type CompletionItem = {
label: string,
/** e.g. `(req: CreateOrderRequest)` shown right after the label. */
@@ -4462,6 +4489,8 @@ export type Launch = {
newProjects: boolean,
/** Where to place the caret in some of `files` (`openidea --line 12 --column 5 <file>`). */
positions?: FilePosition[],
/** Comparison windows: the revisions to compare (boxed: most launches have none). */
comparison?: ComparisonLaunch | null,
};
export type LaunchConfig = {
+32 -9
Переглянути файл
@@ -12,6 +12,7 @@ import { t } from "@/platform/i18n";
import { closeProject, provideLeaveProjectGuard, useProject } from "@/platform/project";
import { maybeEditorService, notifications, type EditorCloseSession } from "@/workbench/contributions";
import { ask, type Ask } from "@/workbench/messages";
import { AUX_CLOSED, auxiliaryWindow, closeDocumentWindows, type AuxCloseResult } from "@/platform/auxWindow";
/** Document ids the backend reports dirty in this window. */
const dirtyDocs = new Set<number>();
@@ -108,6 +109,10 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
closing = true;
try {
const ctx = await commands.windowCloseContext();
if (!(await closeDocumentWindows())) {
await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose());
return false;
}
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.
@@ -137,13 +142,16 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
}
}
/** 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,
);
/**
* Unsaved changes before this window opens another project in place or closes it. `project` names
* the project in the prompt when this window doesn't hold it (a comparison window).
*/
export async function confirmLeave(
deps: CloseDeps = defaultDeps,
project = useProject.getState().project?.name ?? null,
): Promise<boolean> {
if (!(await closeDocumentWindows())) return false;
return decideClose({ quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project }, deps);
}
/** Quit: confirm once (if enabled), then let the backend close every window. */
@@ -174,11 +182,26 @@ export function installCloseGuard(deps: CloseDeps = defaultDeps): () => void {
let disposed = false;
if (isTauri()) {
void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => {
// Documents an auxiliary window released are no longer dirty unless this window edits them too.
const offAux = await getCurrentWindow().listen<AuxCloseResult>(AUX_CLOSED, ({ payload }) => {
if (
!payload.closed ||
auxiliaryWindow(payload.label)?.parent !== getCurrentWindow().label ||
!Array.isArray(payload.documents)
)
return;
const own = new Set(maybeEditorService()?.dirtyDocumentIds?.() ?? []);
for (const doc of payload.documents) if (!own.has(doc)) dirtyDocs.delete(doc);
});
const off = await getCurrentWindow().onCloseRequested(async (event) => {
if (!(await onCloseRequested(deps))) event.preventDefault();
});
if (disposed) off();
else unlisten = off;
const stop = () => {
off();
offAux();
};
if (disposed) stop();
else unlisten = stop;
});
}
return () => {
+4 -1
Переглянути файл
@@ -20,8 +20,11 @@ import { installLaunchHandling, installLightEditAutoClose, windowActions } from
import { WorkspaceDialogHost } from "./WorkspaceDialog";
import { installWindowGroups } from "@/platform/windowGroups";
/** The UI locale of a secondary (Settings) window. */
/** The UI locale of a secondary (Settings, comparison) window. */
export const activateLocale = installLocaleSync;
/** Native menu items of a secondary window that runs actions itself (comparison). */
export const activateMenuBridge = installMenuBridge;
export { confirmLeave } from "./closeGuard";
export function activate(): () => void {
const disposers = [
+28 -17
Переглянути файл
@@ -12,7 +12,7 @@ import { activate as projectTree } from "./project-tree";
import { activate as search } from "./search";
import { activate as dependencies } from "./dependencies";
import { activate as codeInsight } from "./code-insight";
import { activate as vcs, activateSettingsPages as vcsPages } from "./vcs";
import { activate as vcs, activateComparisonWindow, activateSettingsPages as vcsPages } from "./vcs";
import { activate as terminal } from "./terminal";
import { activate as run } from "./run";
import { activate as runAnything } from "./run-anything";
@@ -24,8 +24,8 @@ import { activate as buildTools } from "./build-tools";
import { activate as build } from "./build";
import { activate as templates, activateSettingsPages as templatesPages } from "./templates";
import { activate as todo, activateSettingsPages as todoPages } from "./todo";
import { activate as settings, activateSettingsWindow } from "./settings";
import { activate as app, activateLocale as appLocale } from "./app";
import { activate as settings, activateRuntimeSettings, activateSettingsWindow } from "./settings";
import { activate as app, activateLocale as appLocale, activateMenuBridge, confirmLeave } from "./app";
import { activate as perf } from "./perf";
import { activate as plugins, activateSettingsPages as pluginsPages } from "./plugins";
import { activate as tools, activateSettingsPages as toolsPages } from "./tools";
@@ -90,20 +90,40 @@ const features: Array<[string, Activate]> = [
["remote-dev", remoteDev],
];
export function activateFeatures(): () => void {
function activateAll(what: string, list: Array<[string, Activate]>): () => void {
const disposers: Array<() => void> = [];
for (const [name, activate] of features) {
for (const [name, activate] of list) {
try {
const d = activate();
if (d) disposers.push(d);
} catch (e) {
console.error(`feature ${name} failed to activate`, e);
console.error(`${what} of ${name} failed to activate`, e);
}
}
return () => disposers.reverse().forEach((d) => d());
}
export { SettingsWindow, settingsWindowParent } from "./settings";
export function activateFeatures(): () => void {
return activateAll("feature", features);
}
export { SettingsWindow } from "./settings";
export { ComparisonWindow } from "./vcs";
/**
* A comparison window of project window `parent`: the changed files between two revisions and
* their diffs, with the editor and Git but no workbench.
*/
export function activateComparisonWindowFeatures(parent: string): () => void {
return activateAll("comparison window", [
["locale", appLocale],
["settings", activateRuntimeSettings],
["menu", activateMenuBridge],
["editor", editor],
["vcs", vcs],
["comparison", () => activateComparisonWindow(parent, (project) => confirmLeave(undefined, project))],
]);
}
/**
* The native Settings window of project window `parent`: only the settings
@@ -122,14 +142,5 @@ export function activateSettingsWindowFeatures(parent: string): () => void {
["vim", vimPages],
["vcs", vcsPages],
];
const disposers: Array<() => void> = [];
for (const [name, activate] of pages) {
try {
const d = activate();
if (d) disposers.push(d);
} catch (e) {
console.error(`settings pages of ${name} failed to activate`, e);
}
}
return () => disposers.reverse().forEach((d) => d());
return activateAll("settings pages", pages);
}
+2 -1
Переглянути файл
@@ -40,7 +40,8 @@ import { activatePythonStatus } from "./languages/PythonStatus";
import { nativeToAsciiAction } from "./nativeToAscii";
import { isImeKey } from "@/platform/ime";
export { settingsWindowParent } from "./window/bridge";
/** Settings-driven runtime state (zoom, fonts, theme) of a secondary window. */
export const activateRuntimeSettings = trackRuntimeSettings;
export const SettingsWindow = lazyComponent(() => import("./window/SettingsWindow").then((m) => m.SettingsWindow));
/** Same event as `windows::LAUNCH_EVENT`: a pending launch (page) is waiting. */
+196
Переглянути файл
@@ -0,0 +1,196 @@
/**
* The comparison window (`comparison-<parent>-<n>`): files that differ between two revisions of
* its parent's project, and their diff, in a window of its own (IDEA's Changes Between dialog).
*/
import clsx from "clsx";
import { useEffect } from "react";
import { create } from "zustand";
import { emitTo } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { editorController } from "@/editor/core/controller";
import { AUX_CLOSED, type AuxCloseResult } from "@/platform/auxWindow";
import { setContext } from "@/platform/context";
import { isImeKey } from "@/platform/ime";
import { call, commands, type ComparisonLaunch } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { restoreRemote } from "@/platform/remote";
import { usePlatform } from "@/platform/platformInfo";
import { Button, Spinner, T, isPopupOpen } from "@/ui";
import { editorService, notifications } from "@/workbench/contributions";
import { EditorArea } from "@/workbench/EditorArea";
import { OverlayHost } from "@/workbench/OverlayHost";
import { TitleBar } from "@/workbench/TitleBar";
import { useApplyTheme } from "@/workbench/theme";
import { diffInput, type DiffData } from "./diffInput";
import { ChangesBetweenDialog } from "./operations";
import { refreshNow, useVcs } from "./store";
import styles from "./common.module.css";
const useComparison = create<{
launch: ComparisonLaunch | null;
title: string;
full: boolean;
error: string | null;
}>(() => ({ launch: null, title: "", full: false, error: null }));
/**
* Loads the comparison of this window's launch. `confirmLeave` settles unsaved changes of the
* documents edited here, in the named project, before the window closes.
*/
export function activateComparisonWindow(
parent: string,
confirmLeave: (project: string | null) => Promise<boolean>,
): () => void {
let projectName: string | null = null;
const native = getCurrentWindow();
let disposed = false;
let closing = false;
let stopClose: (() => void) | undefined;
void native
.onCloseRequested(async (event) => {
if (closing) {
event.preventDefault();
return;
}
closing = true;
const models = [...editorController().manager.byId.values()];
let closed = false;
try {
await Promise.all(models.map((model) => model.sync.whenIdle()));
closed = await confirmLeave(projectName);
if (closed) {
for (const model of models) model.dispose();
await Promise.all(
models.map((model) =>
commands.docClose(model.doc).catch((error) => console.warn("cannot release comparison document", error)),
),
);
}
} catch (error) {
notifications().notify({ level: "error", title: "Could not close comparison", body: String(error) });
} finally {
if (!closed) event.preventDefault();
const result: AuxCloseResult = {
label: native.label,
closed,
documents: closed ? models.map((model) => model.doc) : [],
};
await emitTo(parent, AUX_CLOSED, result).catch((error) =>
console.warn("cannot report comparison close", error),
);
closing = false;
}
})
.then((off) => {
if (disposed) off();
else stopClose = off;
});
const onKey = (event: KeyboardEvent) => {
if (event.defaultPrevented || isImeKey(event) || isPopupOpen()) return;
if (event.key === "Escape") {
event.preventDefault();
void native.close();
}
};
const onCloseShortcut = (event: KeyboardEvent) => {
if (isImeKey(event) || isPopupOpen()) return;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "w") {
event.preventDefault();
event.stopImmediatePropagation();
void native.close();
}
};
window.addEventListener("keydown", onKey);
window.addEventListener("keydown", onCloseShortcut, true);
void (async () => {
await restoreRemote();
const launch = (await commands.windowTakeLaunch())?.comparison;
if (!launch) throw new Error("Missing comparison request");
const project = await commands.projectCurrent();
if (!project) throw new Error("The comparison's project is no longer open");
projectName = project.name;
// Settings only: restoring the project's editor session here would overwrite the main window's tabs.
useProject.setState({ settings: project.settings });
const roots = await call(commands.vcsRoots());
useVcs.setState({
available: roots.length > 0,
root: project.root,
roots: roots.map((root) => root.root),
repo: launch.repo,
});
setContext("projectOpen", true);
setContext("vcsAvailable", roots.length > 0);
await refreshNow();
useComparison.setState({ launch });
})().catch((error) => useComparison.setState({ error: String(error) }));
return () => {
disposed = true;
stopClose?.();
window.removeEventListener("keydown", onKey);
window.removeEventListener("keydown", onCloseShortcut, true);
};
}
/** The comparison's caption, on the title bar and the native window (Window menu, task switcher). */
function showCaption(caption: string) {
if (useComparison.getState().title === caption) return;
useComparison.setState({ title: caption });
void getCurrentWindow()
.setTitle(caption)
.catch(() => {});
}
export function ComparisonWindow() {
useApplyTheme();
const { launch, title, full, error } = useComparison();
// macOS overlays traffic lights on the bar; Windows and Linux are frameless, like Settings.
const os = usePlatform((s) => s.os);
const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar);
const showDiff = (data: DiffData) => {
void editorService()
.openInput(diffInput(data), { focus: true })
.then(() => useComparison.setState({ full: true }))
.catch((error) => notifications().notify({ level: "error", title: "Could not show diff", body: String(error) }));
};
useEffect(() => {
if (full) editorService().focus?.();
}, [full]);
return (
<main className={clsx(styles.comparisonWindow, os === "linux" && styles.comparisonWindowEdge)}>
{ownTitleBar && <TitleBar minimal dialog title={title} />}
{error ? (
<div className={styles.error} role="alert">
{error}
</div>
) : !launch ? (
<div className={styles.comparisonWindowLoading} role="status">
<Spinner />
<T>Loading…</T>
</div>
) : (
<>
{full && (
<div className={styles.compareToolbar}>
<Button onClick={() => useComparison.setState({ full: false })}>Back to Changed Files</Button>
</div>
)}
<div className={styles.comparisonWindowBody} hidden={full}>
<ChangesBetweenDialog
from={launch.from}
to={launch.to}
scope={launch.scope ?? undefined}
native
onCaption={showCaption}
onShowDiff={showDiff}
onClose={() => void getCurrentWindow().close()}
/>
</div>
<div className={styles.comparisonWindowBody} hidden={!full}>
<EditorArea />
</div>
</>
)}
<OverlayHost />
</main>
);
}
+58
Переглянути файл
@@ -280,6 +280,64 @@
background: var(--bg-list-selected);
}
.comparisonDialog > [data-dialog-body] {
display: flex;
flex-direction: column;
}
.comparisonDialog .comparison {
flex: 1 1 auto;
}
.comparisonWindow {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--bg-dialog, var(--bg-elev));
color: var(--fg);
}
/* Linux: frameless X11 windows have no shadow, so mark the edge in CSS (like the Settings window). */
.comparisonWindowEdge {
position: relative;
}
.comparisonWindowEdge::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
box-shadow: inset 0 0 0 1px var(--border-window);
}
.comparisonWindowLoading {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--fg-muted);
}
.comparisonWindowBody {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.comparisonWindowBody[hidden] {
display: none;
}
.comparisonWindowBody > .comparisonWindow {
flex: 1;
}
.comparisonWindowBody > .comparison {
flex: 1 1 auto;
}
.comparisonWindowFooter {
display: flex;
justify-content: flex-end;
flex: none;
gap: 12px;
padding: 12px 16px;
}
.comparison {
display: flex;
height: min(520px, 65vh);
+2
Переглянути файл
@@ -147,6 +147,8 @@ function conflictsBody(names: string[]): string {
}
/** Settings › Version Control › Log (also used by the Settings window). */
export { activateComparisonWindow, ComparisonWindow } from "./ComparisonWindow";
export function activateSettingsPages(): () => void {
return registerSettingsPage({
id: "vcs.log",
+67 -37
Переглянути файл
@@ -7,9 +7,10 @@ import { commitKey, commitRepo } from "./multiRoot";
import { useEffect, useMemo, useState } from "react";
import type { ChangedFile, Commit, CompareResult, ResetMode, WorkingTreeChange } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands, nextRequestId } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, Input, Spinner, T } from "@/ui";
import { call, commands, isTauri, nextRequestId } from "@/platform/ipc";
import { Button, Checkbox, Dialog, Icon, Input, Spinner, T } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { windowBackgroundHex } from "@/workbench/theme";
import { diffInput, openDiff, type DiffData, type DiffSide } from "./diffInput";
import { openDialog, promptText } from "./dialogs";
import { KIND_COLOR, fileCount } from "./model";
@@ -460,31 +461,32 @@ export function showCompareWithLocal(commit: Commit) {
/** Files that differ between `rev` (a commit or branch) and the working tree. */
export function showDiffWithWorkingTree(rev: string, label = rev) {
openDialog((close) => <ChangesBetweenDialog from={{ rev, label }} to={null} onClose={close} />);
showComparison({ rev, label }, null);
}
/** IDEA's Compare Versions of two Git Log commits: files that differ between them, older first. */
export function showCompareVersions(older: Commit, newer: Commit) {
openDialog((close) => (
<ChangesBetweenDialog
from={{ rev: older.id, label: short(older.id) }}
to={{ rev: newer.id, label: short(newer.id) }}
onClose={close}
/>
));
showComparison({ rev: older.id, label: short(older.id) }, { rev: newer.id, label: short(newer.id) });
}
/** Files that differ between two revisions (e.g. two branches from Compare with Current). */
export function showCompareRevisions(from: string, to: string, fromLabel = from, toLabel = to) {
openDialog((close) => (
<ChangesBetweenDialog from={{ rev: from, label: fromLabel }} to={{ rev: to, label: toLabel }} onClose={close} />
));
showComparison({ rev: from, label: fromLabel }, { rev: to, label: toLabel });
}
export function showPathComparison(paths: string[], rev: string, repositories: string[] | null) {
openDialog((close) => (
<ChangesBetweenDialog from={{ rev, label: rev }} to={null} scope={{ paths, repositories }} onClose={close} />
));
showComparison({ rev, label: rev }, null, { paths, repositories });
}
function showComparison(from: Revision, to: Revision | null, scope?: ScopedComparison) {
if (!isTauri()) {
openDialog((close) => <ChangesBetweenDialog from={from} to={to} scope={scope} onClose={close} />);
return;
}
const comparison = { from, to, scope: scope ?? null, repo: currentRepo() };
void call(commands.windowOpenComparison(comparison, windowBackgroundHex())).catch((error) =>
notifications().notify({ level: "error", title: "Could not open comparison", body: String(error) }),
);
}
interface Revision {
@@ -516,11 +518,18 @@ export function ChangesBetweenDialog({
to,
scope,
onClose,
native = false,
onCaption,
onShowDiff,
}: {
from: Revision;
to: Revision | null;
scope?: ScopedComparison;
onClose: () => void;
/** Content of the comparison window rather than a dialog: its caption goes to `onCaption`. */
native?: boolean;
onCaption?: (caption: string) => void;
onShowDiff?: (data: DiffData) => void;
}) {
const [root] = useState(currentRepoRoot);
const [repo] = useState(currentRepo);
@@ -607,27 +616,20 @@ export function ChangesBetweenDialog({
const skipped = result?.skippedPaths.map((path) => displayPath(path)) ?? [];
const skippedNames = skipped.slice(0, 5).join(", ");
const skippedLabel = skipped.length > 5 ? tx`${skippedNames} and ${skipped.length - 5} more` : skippedNames;
return (
<Dialog
open
title={files ? `${title} — ${count}` : title}
onClose={onClose}
width={input ? 1100 : 680}
buttons={[
{
label: "Show Diff",
role: "default",
disabled: !current,
onClick: () => {
if (current) {
void openDiff(diffFor(current));
onClose();
}
},
},
{ label: "Close", role: "cancel" },
]}
>
const caption = files ? `${title} — ${count}` : title;
useEffect(() => {
if (native) onCaption?.(caption);
}, [native, onCaption, caption]);
const showDiff = () => {
if (!current) return;
if (onShowDiff) onShowDiff(diffFor(current));
else {
void openDiff(diffFor(current));
onClose();
}
};
const contents = (
<>
{error && (
<div className={styles.error} role="alert">
{error}
@@ -682,6 +684,34 @@ export function ChangesBetweenDialog({
}
/>
) : null}
</>
);
if (native)
return (
<div className={styles.comparisonWindow}>
<div className={styles.comparisonWindowBody}>{contents}</div>
<div className={styles.comparisonWindowFooter}>
<Button onClick={onClose}>Close</Button>
<Button variant="primary" disabled={!current} onClick={showDiff}>
Show Diff
</Button>
</div>
</div>
);
return (
<Dialog
open
title={caption}
onClose={onClose}
width={input ? 1100 : 680}
resizable="both"
className={styles.comparisonDialog}
buttons={[
{ label: "Show Diff", role: "default", disabled: !current, onClick: showDiff },
{ label: "Close", role: "cancel" },
]}
>
{contents}
</Dialog>
);
}
+20 -7
Переглянути файл
@@ -3,7 +3,14 @@ import ReactDOM from "react-dom/client";
import "./styles/global.css";
import { Workbench } from "./workbench/Workbench";
import { activateSecondaryWindow, activateWorkbench } from "./workbench/activate";
import { activateFeatures, activateSettingsWindowFeatures, SettingsWindow, settingsWindowParent } from "./features";
import {
activateComparisonWindowFeatures,
activateFeatures,
activateSettingsWindowFeatures,
ComparisonWindow,
SettingsWindow,
} from "./features";
import { auxiliaryWindow } from "./platform/auxWindow";
import { installRepeatedDoubleClick } from "./platform/doubleClick";
import { installErrorLogging } from "./platform/errorLog";
import { installKeyboardDispatcher } from "./platform/keymap";
@@ -21,12 +28,16 @@ if (import.meta.env.DEV && isTauri()) {
installRepeatedDoubleClick();
installErrorLogging();
// The native Settings window (`settings-<parent>`) loads this page too.
const settingsParent = settingsWindowParent();
// Auxiliary windows (Settings `settings-<parent>`, comparisons `comparison-<parent>-<n>`) load this page too.
const aux = auxiliaryWindow();
if (settingsParent !== null) {
if (aux?.kind === "settings") {
activateSecondaryWindow();
activateSettingsWindowFeatures(settingsParent);
activateSettingsWindowFeatures(aux.parent);
} else if (aux?.kind === "comparison") {
activateSecondaryWindow();
activateComparisonWindowFeatures(aux.parent);
installKeyboardDispatcher();
} else {
activateWorkbench();
// `vite build --mode perf` is a production bundle for the performance harness (`perf/`).
@@ -46,8 +57,10 @@ if (settingsParent !== null) {
}
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>{settingsParent !== null ? <SettingsWindow /> : <Workbench />}</React.StrictMode>,
<React.StrictMode>
{aux?.kind === "settings" ? <SettingsWindow /> : aux?.kind === "comparison" ? <ComparisonWindow /> : <Workbench />}
</React.StrictMode>,
);
hideSplash();
// Views split out of the start-up bundle load in the background once the UI is up.
if (settingsParent === null) prefetchLazyComponents();
if (!aux) prefetchLazyComponents();
+17
Переглянути файл
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { auxiliaryWindow } from "./auxWindow";
describe("auxiliaryWindow", () => {
it("reads the kind and parent from the label", () => {
expect(auxiliaryWindow("settings-main")).toEqual({ kind: "settings", parent: "main" });
expect(auxiliaryWindow("settings-project-2")).toEqual({ kind: "settings", parent: "project-2" });
expect(auxiliaryWindow("comparison-main-0")).toEqual({ kind: "comparison", parent: "main" });
expect(auxiliaryWindow("comparison-project-2-14")).toEqual({ kind: "comparison", parent: "project-2" });
});
it("leaves project windows and malformed labels alone", () => {
for (const label of ["", "main", "project-1", "settings-", "comparison-main", "comparison-main-x"]) {
expect(auxiliaryWindow(label)).toBeNull();
}
});
});
+78
Переглянути файл
@@ -0,0 +1,78 @@
/**
* Auxiliary windows: Settings and comparison windows that belong
* to a project window (their parent) and share its project. Labels name the
* kind and the parent (`settings-main`, `comparison-project-2-3`), mirroring
* `windows::auxiliary` in src-tauri/src/windows.rs.
*/
import { getAllWindows, getCurrentWindow } from "@tauri-apps/api/window";
import { TauriEvent } from "@tauri-apps/api/event";
import { isTauri } from "./ipc";
export type AuxKind = "settings" | "comparison";
/** Kinds with several windows per parent (`<kind>-<parent>-<n>`); the others have one (`<kind>-<parent>`). */
const MULTIPLE: ReadonlySet<AuxKind> = new Set(["comparison"]);
const KINDS: readonly AuxKind[] = ["settings", "comparison"];
/** An auxiliary window reports to its parent whether it closed, and which documents it released. */
export const AUX_CLOSED = "openidea://auxiliary/closed";
export interface AuxCloseResult {
label: string;
closed: boolean;
documents: number[];
}
function currentLabel(): string {
if (!isTauri()) return "";
try {
return getCurrentWindow().label;
} catch {
return "";
}
}
/** The kind and parent of an auxiliary window, or null for a project window. */
export function auxiliaryWindow(label = currentLabel()): { kind: AuxKind; parent: string } | null {
for (const kind of KINDS) {
if (!label.startsWith(`${kind}-`)) continue;
const rest = label.slice(kind.length + 1);
const parent = MULTIPLE.has(kind) ? /^(.+)-\d+$/.exec(rest)?.[1] : rest;
if (parent) return { kind, parent };
}
return null;
}
/**
* Closes this project window's auxiliary windows that show documents, one at a time: each settles
* its own unsaved changes first. False when one stays open (the user cancelled).
*/
export async function closeDocumentWindows(): Promise<boolean> {
if (!isTauri()) return true;
const parent = getCurrentWindow();
const children = (await getAllWindows()).filter((window) => {
const aux = auxiliaryWindow(window.label);
return aux !== null && aux.kind !== "settings" && aux.parent === parent.label;
});
for (const child of children) {
let finish!: (closed: boolean) => void;
const result = new Promise<boolean>((resolve) => {
finish = resolve;
});
const offResult = await parent.listen<AuxCloseResult>(AUX_CLOSED, ({ payload }) => {
if (payload.label === child.label && !payload.closed) finish(false);
});
const offDestroyed = await child.once(TauriEvent.WINDOW_DESTROYED, () => finish(true));
try {
await child.unminimize();
await child.show();
await child.setFocus();
await child.close();
if (!(await result)) return false;
} finally {
offResult();
offDestroyed();
}
}
return true;
}
+5 -1
Переглянути файл
@@ -5924,5 +5924,9 @@
"Couldn't create the worktree for #{number}": "无法为 #{number} 创建工作树",
"Couldn't commit the suggested change": "无法提交建议的更改",
"Couldn't apply the suggested change": "无法应用建议的更改",
"Couldn't resolve the thread": "无法解决讨论串"
"Couldn't resolve the thread": "无法解决讨论串",
"Could not close comparison": "无法关闭比较窗口",
"Could not open comparison": "无法打开比较窗口",
"Could not show diff": "无法显示差异",
"Back to Changed Files": "返回更改文件列表"
}