mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 09:01:40 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fb811717 | ||
|
|
2d94df1fd3 | ||
|
|
b2024e56ab | ||
|
|
206cc989f1 | ||
|
|
846df99536 |
No files matched your search
@@ -0,0 +1,283 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
attachWindowResizeFocusRestore,
|
||||
WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
|
||||
type MinimalDocument,
|
||||
} from "./restoreWindowResizeFocus";
|
||||
|
||||
type FakeWindow = {
|
||||
listeners: Map<string, Array<() => void>>;
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
emit: (type: string) => void;
|
||||
};
|
||||
|
||||
const createWindowStub = (): FakeWindow => {
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? [];
|
||||
const index = bucket.indexOf(listener);
|
||||
if (index >= 0) bucket.splice(index, 1);
|
||||
},
|
||||
emit(type) {
|
||||
for (const listener of [...(listeners.get(type) ?? [])]) listener();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type SchedulerState = {
|
||||
schedule: (callback: () => void, delay: number) => number;
|
||||
cancel: (id: number) => void;
|
||||
runNext: () => void;
|
||||
pendingCount: () => number;
|
||||
};
|
||||
|
||||
const createScheduler = (): SchedulerState => {
|
||||
type Entry = { id: number; callback: () => void; cancelled: boolean };
|
||||
const queue: Entry[] = [];
|
||||
let nextId = 1;
|
||||
return {
|
||||
schedule(callback, delay) {
|
||||
assert.equal(delay, WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS);
|
||||
queue.push({ id: nextId, callback, cancelled: false });
|
||||
return nextId++;
|
||||
},
|
||||
cancel(id) {
|
||||
for (const entry of queue) {
|
||||
if (entry.id === id) entry.cancelled = true;
|
||||
}
|
||||
},
|
||||
runNext() {
|
||||
while (queue.length) {
|
||||
const entry = queue.shift()!;
|
||||
if (!entry.cancelled) {
|
||||
entry.callback();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
pendingCount: () => queue.filter((entry) => !entry.cancelled).length,
|
||||
};
|
||||
};
|
||||
|
||||
test("resize restores terminal focus once the resize settles", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
assert.equal(scheduler.pendingCount(), 1, "restore must be debounced");
|
||||
assert.equal(refocusCalls, 0);
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("a continuous frame drag only triggers one restore", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
win.emit("resize");
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1, "debounced resizes must collapse into one restore");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus when the app window is not focused", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => false } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize restore is skipped while a modal dialog is open", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: (selector: string) => (
|
||||
selector === '[role="dialog"][data-state="open"]' ? {} : null
|
||||
),
|
||||
hasFocus: () => true,
|
||||
} as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus from an active editable field", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: { tagName: "INPUT", matches: () => false },
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0, "restore must not yank focus out of an editable field");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus from a non-editable interactive control", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
// Host-tree sidebar row / composer resize separator: a keyboard-focusable
|
||||
// element that is not editable.
|
||||
activeElement: { tagName: "DIV", matches: () => false },
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0, "restore must not yank focus off a focusable row or separator");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize restores focus when nothing but the document shell holds focus", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: { tagName: "BODY", matches: () => false },
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1, "focus parked on body must still be restored to the terminal");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize still restores while the xterm helper textarea is focused", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: {
|
||||
tagName: "TEXTAREA",
|
||||
matches: (selector: string) => selector === "textarea.xterm-helper-textarea",
|
||||
},
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1, "terminal-owned focus must be restored after resize");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("detach removes the listener and cancels a pending restore", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
detach();
|
||||
|
||||
assert.equal((win.listeners.get("resize") ?? []).length, 0, "listener must be removed");
|
||||
assert.equal(scheduler.pendingCount(), 0, "pending restore must be cancelled");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
});
|
||||
|
||||
test("a win without listener support degrades to a no-op detach", () => {
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win: null,
|
||||
refocus: () => {},
|
||||
scheduleTimeout: () => 1,
|
||||
cancelTimeout: () => {},
|
||||
});
|
||||
assert.doesNotThrow(() => detach());
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { hasOpenAppDialog } from "./focusTerminalSession";
|
||||
|
||||
/** Debounce so a continuous frame drag (or maximize transition) triggers one restore. */
|
||||
export const WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS = 120;
|
||||
|
||||
export type MinimalWindow = {
|
||||
addEventListener?: (type: string, listener: () => void) => unknown;
|
||||
removeEventListener?: (type: string, listener: () => void) => unknown;
|
||||
};
|
||||
|
||||
export type MinimalActiveElement = {
|
||||
tagName?: string | null;
|
||||
matches?: (selector: string) => boolean;
|
||||
};
|
||||
|
||||
export type MinimalDocument = Parameters<typeof hasOpenAppDialog>[0] & {
|
||||
hasFocus?: () => boolean;
|
||||
activeElement?: MinimalActiveElement | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when a real DOM element other than the xterm helper textarea holds
|
||||
* keyboard focus — an editable field (search box, chat input, SFTP filter,
|
||||
* inline rename, ...) as well as non-editable interactive controls that are
|
||||
* keyboard-focusable (buttons, host-tree rows, resize separators, sliders).
|
||||
* Resize-driven restores must not yank focus away from any of them.
|
||||
* Focus parked on `body`/`html` (or an xterm helper textarea) does not count
|
||||
* as being held somewhere else, so the restore may proceed.
|
||||
*/
|
||||
export const hasActiveForeignFocusElement = (doc: MinimalDocument | null): boolean => {
|
||||
const element = doc?.activeElement;
|
||||
if (!element) return false;
|
||||
if (typeof element.matches === "function" && element.matches("textarea.xterm-helper-textarea")) {
|
||||
return false;
|
||||
}
|
||||
const tagName = typeof element.tagName === "string" ? element.tagName.toLowerCase() : "";
|
||||
return tagName !== "body" && tagName !== "html";
|
||||
};
|
||||
|
||||
type AttachWindowResizeFocusRestoreOptions = {
|
||||
win?: MinimalWindow | null;
|
||||
doc?: MinimalDocument | null;
|
||||
refocus?: () => void;
|
||||
delayMs?: number;
|
||||
scheduleTimeout?: (callback: () => void, delay: number) => unknown;
|
||||
cancelTimeout?: (id: unknown) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* #3647: dragging the OS window frame (or maximizing) leaves the active xterm
|
||||
* helper textarea unfocused, so keystrokes do nothing until the user clicks
|
||||
* back into the terminal. Attach a window `resize` listener that re-runs the
|
||||
* tab-change refocus path after the resize settles.
|
||||
*
|
||||
* Restore is skipped when the app window does not hold OS input focus, when a
|
||||
* modal dialog is open, or when any element other than the xterm helper
|
||||
* textarea already holds focus (editable fields, but also keyboard-focusable
|
||||
* interactive controls such as buttons or sidebar rows), so the restore never
|
||||
* steals typing or keyboard-navigation position from elsewhere.
|
||||
*/
|
||||
export const attachWindowResizeFocusRestore = (
|
||||
{
|
||||
win = typeof window !== "undefined" ? window : null,
|
||||
doc = typeof document !== "undefined" ? document : null,
|
||||
refocus,
|
||||
delayMs = WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
|
||||
scheduleTimeout = (callback: () => void, delay: number) => setTimeout(callback, delay),
|
||||
cancelTimeout = (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
}: AttachWindowResizeFocusRestoreOptions = {},
|
||||
): (() => void) => {
|
||||
if (!win?.addEventListener || !win.removeEventListener || typeof refocus !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let timerId: unknown = null;
|
||||
const restoreFocus = () => {
|
||||
timerId = null;
|
||||
if (doc?.hasFocus?.() === false) return;
|
||||
if (hasOpenAppDialog(doc)) return;
|
||||
// The window holds OS focus, but the user may still be typing in a
|
||||
// non-terminal field or navigating by keyboard (sidebar rows, buttons).
|
||||
// Never steal focus from an element that actually holds it.
|
||||
if (hasActiveForeignFocusElement(doc)) return;
|
||||
refocus();
|
||||
};
|
||||
const handleResize = () => {
|
||||
if (timerId !== null) {
|
||||
cancelTimeout(timerId);
|
||||
}
|
||||
timerId = scheduleTimeout(restoreFocus, delayMs);
|
||||
};
|
||||
|
||||
win.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
if (timerId !== null) {
|
||||
cancelTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type SidePanelTabRemap,
|
||||
} from '../../domain/workspaceSidePanelTabRemap';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import { attachWindowResizeFocusRestore } from '../terminal/restoreWindowResizeFocus';
|
||||
import { toast } from '../ui/toast';
|
||||
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
|
||||
import type { RendererCwdSource } from '../terminal/sftpCwd';
|
||||
@@ -747,6 +748,23 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
refocusActiveTerminalSession();
|
||||
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
|
||||
|
||||
// Restore keyboard focus after the OS window is resized or maximized.
|
||||
// #3647: dragging the frame (or maximizing) leaves the active xterm helper
|
||||
// textarea unfocused on Windows, so keystrokes do nothing until a click.
|
||||
// Reuse the tab-change refocus path; the helper only fires once the resize
|
||||
// settles and is gated on the app window actually holding input focus.
|
||||
useEffect(() => {
|
||||
if (!isTerminalLayerVisible) return undefined;
|
||||
return attachWindowResizeFocusRestore({
|
||||
win: window,
|
||||
doc: document,
|
||||
refocus: () => {
|
||||
if (isComposeBarOpen) return;
|
||||
refocusActiveTerminalSession?.();
|
||||
},
|
||||
});
|
||||
}, [document, isComposeBarOpen, isTerminalLayerVisible, refocusActiveTerminalSession, window]);
|
||||
|
||||
// When focusedSessionId changes or terminal layer becomes visible,
|
||||
// focus the corresponding terminal to restore :focus-within CSS state
|
||||
useEffect(() => {
|
||||
|
||||
@@ -76,6 +76,9 @@ const DEFAULT_WINDOW_WIDTH = 1400;
|
||||
const DEFAULT_WINDOW_HEIGHT = 900;
|
||||
// Minimum window size: enough to render the expanded sidebar + a usable
|
||||
// host list + the 420px host details / new-host aside panel without overflow.
|
||||
// Small displays shrink this fit to their work area at window-creation time
|
||||
// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647);
|
||||
// the minimum tracks the display the window currently occupies.
|
||||
const MIN_WINDOW_WIDTH = 1100;
|
||||
const MIN_WINDOW_HEIGHT = 640;
|
||||
|
||||
|
||||
@@ -6,6 +6,104 @@ const { attachDisplayRecovery } = require("./displayRecovery.cjs");
|
||||
|
||||
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
|
||||
|
||||
// #3647: the 1100×640 layout minimum is larger than the usable workspace on
|
||||
// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574
|
||||
// logical px), which made those windows impossible to fit — let alone shrink.
|
||||
// Keep the layout minimum on capable screens, but shrink it to the work area
|
||||
// of the display the window targets so the window can always fit with a small
|
||||
// margin.
|
||||
const MIN_SIZE_FIT_FACTOR = 0.9;
|
||||
const MIN_WINDOW_FLOOR_WIDTH = 320;
|
||||
const MIN_WINDOW_FLOOR_HEIGHT = 240;
|
||||
|
||||
function pickTargetDisplay(screenModule, bounds) {
|
||||
if (!screenModule || typeof screenModule.getDisplayMatching !== "function") {
|
||||
return screenModule?.getPrimaryDisplay?.() || null;
|
||||
}
|
||||
try {
|
||||
return screenModule.getDisplayMatching(bounds) || screenModule.getPrimaryDisplay?.() || null;
|
||||
} catch {
|
||||
try {
|
||||
return screenModule.getPrimaryDisplay?.() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
|
||||
let effectiveWidth = minWidth;
|
||||
let effectiveHeight = minHeight;
|
||||
try {
|
||||
// Size for the display the window actually lives on (saved position or
|
||||
// current placement), falling back to the primary display.
|
||||
const display = bounds
|
||||
? pickTargetDisplay(screenModule, bounds)
|
||||
: screenModule?.getPrimaryDisplay?.();
|
||||
const workArea = display?.workArea;
|
||||
if (Number.isFinite(workArea?.width) && workArea.width > 0) {
|
||||
effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR));
|
||||
}
|
||||
if (Number.isFinite(workArea?.height) && workArea.height > 0) {
|
||||
effectiveHeight = Math.min(effectiveHeight, Math.floor(workArea.height * MIN_SIZE_FIT_FACTOR));
|
||||
}
|
||||
} catch {
|
||||
// screen unavailable (e.g. before app ready): keep the static minimums
|
||||
}
|
||||
return {
|
||||
width: Math.max(MIN_WINDOW_FLOOR_WIDTH, effectiveWidth),
|
||||
height: Math.max(MIN_WINDOW_FLOOR_HEIGHT, effectiveHeight),
|
||||
};
|
||||
}
|
||||
|
||||
// Keep the BrowserWindow minimum in sync with the display it currently lives
|
||||
// on: docking, unplugging a screen, or display-scaling changes can move the
|
||||
// window onto a display with a different work area.
|
||||
function attachDisplayMinimumSync({ win, screen: screenModule, minWidth, minHeight }) {
|
||||
if (!win || !screenModule || typeof screenModule.on !== "function") return () => {};
|
||||
|
||||
const applyMinimumForCurrentDisplay = () => {
|
||||
try {
|
||||
if (win.isDestroyed?.()) return;
|
||||
const next = resolveWindowMinimumSize(screenModule, minWidth, minHeight, win.getBounds());
|
||||
const [currentWidth, currentHeight] = win.getMinimumSize?.() || [];
|
||||
if (next.width === currentWidth && next.height === currentHeight) return;
|
||||
win.setMinimumSize(next.width, next.height);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
};
|
||||
|
||||
const onDisplayMetricsChanged = () => applyMinimumForCurrentDisplay();
|
||||
const onDisplayRemoved = () => applyMinimumForCurrentDisplay();
|
||||
const onWindowMoved = () => applyMinimumForCurrentDisplay();
|
||||
try {
|
||||
screenModule.on("display-metrics-changed", onDisplayMetricsChanged);
|
||||
screenModule.on("display-removed", onDisplayRemoved);
|
||||
win.on("move", onWindowMoved);
|
||||
} catch {
|
||||
try {
|
||||
win.removeListener?.("move", onWindowMoved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
return function detachDisplayMinimumSync() {
|
||||
try {
|
||||
screenModule.removeListener?.("display-metrics-changed", onDisplayMetricsChanged);
|
||||
screenModule.removeListener?.("display-removed", onDisplayRemoved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
win.removeListener?.("move", onWindowMoved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setTerminalKeyboardFocusForWindow(win, focused) {
|
||||
if (!win || win.isDestroyed?.() || !win.webContents) return false;
|
||||
const isFocused = focused === true;
|
||||
@@ -113,11 +211,24 @@ function createMainWindowApi(ctx) {
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
};
|
||||
|
||||
// Size the minimum to the display the restored window will live on
|
||||
// (a saved position may target a smaller secondary display), falling
|
||||
// back to the primary display when there is no usable saved position.
|
||||
const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
|
||||
? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height }
|
||||
: null;
|
||||
const windowMinimumSize = resolveWindowMinimumSize(
|
||||
screen,
|
||||
MIN_WINDOW_WIDTH,
|
||||
MIN_WINDOW_HEIGHT,
|
||||
savedTargetBounds,
|
||||
);
|
||||
|
||||
if (savedState) {
|
||||
// Use saved dimensions, but clamp to the minimum so a previously
|
||||
// shrunk window from an older build cannot start below the minimum.
|
||||
windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH);
|
||||
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
|
||||
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
|
||||
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
|
||||
|
||||
// Only use saved position if the screen is available at that location
|
||||
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
|
||||
@@ -149,8 +260,8 @@ function createMainWindowApi(ctx) {
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...windowBounds,
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
minWidth: windowMinimumSize.width,
|
||||
minHeight: windowMinimumSize.height,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
show: false,
|
||||
@@ -190,6 +301,20 @@ function createMainWindowApi(ctx) {
|
||||
detachDisplayRecovery = null;
|
||||
}
|
||||
|
||||
// #3647: docking / display-scaling changes can move the window onto a
|
||||
// display with a different work area; keep the minimum in sync so the
|
||||
// window can always fit its current display.
|
||||
const detachDisplayMinimumSync = attachDisplayMinimumSync({
|
||||
win,
|
||||
screen,
|
||||
// Pass the configured (layout) minimum as the baseline, not the
|
||||
// display-fitted value resolved above: otherwise a reduced minimum
|
||||
// would become a permanent ceiling and could never grow back to
|
||||
// MIN_WINDOW_WIDTH × MIN_WINDOW_HEIGHT on a larger display.
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
});
|
||||
|
||||
// Clear reference when the main window is destroyed
|
||||
win.on('closed', () => {
|
||||
try {
|
||||
@@ -197,6 +322,11 @@ function createMainWindowApi(ctx) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
detachDisplayMinimumSync?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (win?.webContents?.id) {
|
||||
unhealthyWebContentsIds.delete(win.webContents.id);
|
||||
@@ -595,6 +725,8 @@ function createMainWindowApi(ctx) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachDisplayMinimumSync,
|
||||
createMainWindowApi,
|
||||
resolveWindowMinimumSize,
|
||||
setTerminalKeyboardFocusForWindow,
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { resolveWindowMinimumSize, attachDisplayMinimumSync } = require("./mainWindow.cjs");
|
||||
|
||||
test("uses the static layout minimums when they fit the display", () => {
|
||||
const size = resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => ({ workArea: { width: 1920, height: 1080 } }) },
|
||||
1100,
|
||||
640,
|
||||
);
|
||||
assert.deepEqual(size, { width: 1100, height: 640 });
|
||||
});
|
||||
|
||||
test("sizes the minimum for the display containing the target bounds", () => {
|
||||
const primary = { workArea: { width: 1920, height: 1080 } };
|
||||
const secondary = { workArea: { width: 1093, height: 585 } };
|
||||
const size = resolveWindowMinimumSize(
|
||||
{
|
||||
getPrimaryDisplay: () => primary,
|
||||
getDisplayMatching: (bounds) => (bounds.x > 1920 ? secondary : primary),
|
||||
},
|
||||
1100,
|
||||
640,
|
||||
{ x: 2000, y: 0, width: 1000, height: 700 },
|
||||
);
|
||||
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
});
|
||||
|
||||
test("falls back to the primary display when the target cannot be matched", () => {
|
||||
const size = resolveWindowMinimumSize(
|
||||
{
|
||||
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
|
||||
getDisplayMatching: () => null,
|
||||
},
|
||||
1100,
|
||||
640,
|
||||
{ x: 0, y: 0, width: 1000, height: 700 },
|
||||
);
|
||||
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
});
|
||||
|
||||
test("refreshes the window minimum when display metrics change", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const screenModule = new EventEmitter();
|
||||
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1920, height: 1080 } });
|
||||
const win = new EventEmitter();
|
||||
win.isDestroyed = () => false;
|
||||
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
|
||||
let minimum = { width: 1100, height: 640 };
|
||||
win.getMinimumSize = () => [minimum.width, minimum.height];
|
||||
win.setMinimumSize = (width, height) => { minimum = { width, height }; };
|
||||
|
||||
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
|
||||
try {
|
||||
// Docking change shrinks the primary work area under the window.
|
||||
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1093, height: 585 } });
|
||||
screenModule.emit("display-metrics-changed");
|
||||
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
|
||||
// No-op emission keeps the same minimum.
|
||||
screenModule.emit("display-metrics-changed");
|
||||
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
} finally {
|
||||
detach();
|
||||
}
|
||||
});
|
||||
|
||||
test("ignores display events after detach", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const screenModule = new EventEmitter();
|
||||
const win = new EventEmitter();
|
||||
win.isDestroyed = () => false;
|
||||
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
|
||||
win.getMinimumSize = () => [1100, 640];
|
||||
let called = 0;
|
||||
win.setMinimumSize = () => { called += 1; };
|
||||
|
||||
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
|
||||
detach();
|
||||
screenModule.emit("display-metrics-changed");
|
||||
screenModule.emit("display-removed");
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
|
||||
test("shrinks the minimum to fit a small primary work area (#3647)", () => {
|
||||
// 1366×768 at 125% Windows scaling leaves ~1093×585 logical px of work area.
|
||||
const size = resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }) },
|
||||
1100,
|
||||
640,
|
||||
);
|
||||
assert.equal(size.width, Math.floor(1093 * 0.9));
|
||||
assert.equal(size.height, Math.floor(585 * 0.9));
|
||||
assert.ok(size.width < 1100 && size.height < 640);
|
||||
});
|
||||
|
||||
test("never shrinks below a small usable floor", () => {
|
||||
const size = resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => ({ workArea: { width: 300, height: 200 } }) },
|
||||
1100,
|
||||
640,
|
||||
);
|
||||
assert.deepEqual(size, { width: 320, height: 240 });
|
||||
});
|
||||
|
||||
test("keeps the static minimums when screen is unavailable", () => {
|
||||
assert.deepEqual(resolveWindowMinimumSize(null, 1100, 640), { width: 1100, height: 640 });
|
||||
assert.deepEqual(resolveWindowMinimumSize({}, 1100, 640), { width: 1100, height: 640 });
|
||||
assert.deepEqual(
|
||||
resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => { throw new Error("screen not ready"); } },
|
||||
1100,
|
||||
640,
|
||||
),
|
||||
{ width: 1100, height: 640 },
|
||||
);
|
||||
});
|
||||
|
||||
test("createWindow clamps saved bounds and BrowserWindow minima to the fitted size", async () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMainWindowApi } = require("./mainWindow.cjs");
|
||||
|
||||
let constructedOptions = null;
|
||||
class Window extends EventEmitter {
|
||||
constructor(options) {
|
||||
super();
|
||||
constructedOptions = options;
|
||||
this.destroyed = false;
|
||||
this.webContents = Object.assign(new EventEmitter(), {
|
||||
id: 71,
|
||||
isDestroyed: () => this.destroyed,
|
||||
setWindowOpenHandler() {},
|
||||
openDevTools() {},
|
||||
send() {},
|
||||
});
|
||||
}
|
||||
close() {}
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { width: 1000, height: 700 }; }
|
||||
setBackgroundColor() {}
|
||||
loadURL() { return Promise.resolve(); }
|
||||
}
|
||||
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: false,
|
||||
pendingWindowStateWrite: null, queuedWindowState: null, windowStateCloseRequested: false,
|
||||
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname, URL, require, console, setTimeout, clearTimeout,
|
||||
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
|
||||
getMainWindowCount: () => 1,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; },
|
||||
loadWindowState() { return { width: 980, height: 560, x: 0, y: 0 }; },
|
||||
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
|
||||
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {},
|
||||
shouldCloseWindowFromInput() { return false; },
|
||||
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
|
||||
closeSettingsWindow() {}, hideSettingsWindow() {}, applyWindowOpacityToWindow() {},
|
||||
});
|
||||
|
||||
await api.createWindow(
|
||||
{
|
||||
BrowserWindow: Window,
|
||||
nativeTheme: {},
|
||||
app: {},
|
||||
screen: {
|
||||
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
|
||||
getAllDisplays: () => [],
|
||||
},
|
||||
shell: {},
|
||||
ipcMain: {},
|
||||
},
|
||||
{
|
||||
preload: "/tmp/preload.cjs",
|
||||
devServerUrl: "http://localhost:5173",
|
||||
isDev: true,
|
||||
appIcon: null,
|
||||
isMac: true,
|
||||
electronDir: __dirname,
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(constructedOptions);
|
||||
assert.equal(constructedOptions.minWidth, Math.floor(1093 * 0.9));
|
||||
assert.equal(constructedOptions.minHeight, Math.floor(585 * 0.9));
|
||||
assert.equal(constructedOptions.width, Math.max(980, Math.floor(1093 * 0.9)));
|
||||
assert.equal(constructedOptions.height, Math.max(560, Math.floor(585 * 0.9)));
|
||||
});
|
||||
Reference in new issue
Block a user