Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "nexus-code",
"productName": "NexusCode",
"version": "0.6.2",
"version": "0.7.0",
"description": "Multi-workspace VSCode-style editor for macOS. Monaco editor + terminal in one window.",
"license": "MIT",
"private": true,
Expand Down
39 changes: 38 additions & 1 deletion src/main/features/app-state/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ipcContract } from "../../../shared/ipc/contract";
import type { AppState } from "../../../shared/types/app-state";
import type { StateService } from "../../infra/storage/state-service";
import { register, validateArgs } from "../../infra/ipc-router";
import type { StateService } from "../../infra/storage/state-service";

const c = ipcContract.appState.call;

Expand All @@ -17,6 +17,29 @@ export interface AppStateChannelOptions {
* (c) broadcast `appState.languageChanged` to all renderer windows.
*/
onLanguageChanged?: (language: AppState["language"] & string) => void;
/**
* Invoked synchronously after a `set` call that includes a
* `keybindingOverrides` field, with the new override list.
*
* The main/index.ts wiring uses this to:
* (a) reinstall the native application menu so accelerator labels
* reflect the effective bindings;
* (b) broadcast `appState.keybindingsChanged` to all renderer
* windows so each recompiles its dispatcher tables.
*/
onKeybindingsChanged?: (overrides: NonNullable<AppState["keybindingOverrides"]>) => void;
/**
* Invoked synchronously after a `set` call that includes an
* `editorKeybindingOverrides` field, with the new override list.
*
* The main/index.ts wiring uses this only to broadcast
* `appState.editorKeybindingsChanged` to all renderer windows so each
* re-reconciles Monaco. No native-menu rebuild — editor commands are
* not menu items.
*/
onEditorKeybindingsChanged?: (
overrides: NonNullable<AppState["editorKeybindingOverrides"]>,
) => void;
}

export function registerAppStateChannel(
Expand All @@ -39,6 +62,20 @@ export function registerAppStateChannel(
if (patch.language !== undefined && opts.onLanguageChanged !== undefined) {
opts.onLanguageChanged(patch.language);
}

// Same pattern for keybinding overrides: menu rebuild + broadcast
// are owned by the caller so this module stays IPC-only.
if (patch.keybindingOverrides !== undefined && opts.onKeybindingsChanged !== undefined) {
opts.onKeybindingsChanged(patch.keybindingOverrides);
}

// Editor (Monaco) overrides: broadcast-only, no menu involvement.
if (
patch.editorKeybindingOverrides !== undefined &&
opts.onEditorKeybindingsChanged !== undefined
) {
opts.onEditorKeybindingsChanged(patch.editorKeybindingOverrides);
}
},
},
listen: {},
Expand Down
37 changes: 37 additions & 0 deletions src/main/features/browser/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@

import { ipcContract } from "../../../shared/ipc/contract";
import { ipcOk } from "../../../shared/ipc/result";
import { COMMANDS } from "../../../shared/keybindings/commands";
import { broadcast, register, validateArgs } from "../../infra/ipc-router";
import type { GlobalStorage } from "../../infra/storage/global-storage";
import type { WorkspaceStorage } from "../../infra/storage/workspace-storage";
import { installBrowserKeyInterceptor } from "./keyboard";
import type { BrowserPermissionPromptManager } from "./permission-prompt-manager";
import type { BrowserTabRegistry } from "./registry";

Expand All @@ -47,6 +49,32 @@ export interface BrowserChannelDeps {
* Must be called after the registry is initialised (i.e. after the main
* window exists).
*/
/**
* Run a browser command resolved by the key interceptor against `tabId`.
* Shared by the page-view and docked-DevTools-view interceptors so both
* surfaces behave identically. Five act on the registry directly; URL
* focus needs the renderer, so it is bounced over IPC.
*/
function runBrowserCommand(registry: BrowserTabRegistry, command: string, tabId: string): void {
switch (command) {
case COMMANDS.browserReload:
registry.reload({ tabId });
break;
case COMMANDS.browserHardReload:
registry.reload({ tabId, ignoreCache: true });
break;
case COMMANDS.browserGoBack:
registry.goBack({ tabId });
break;
case COMMANDS.browserGoForward:
registry.goForward({ tabId });
break;
case COMMANDS.browserFocusUrl:
broadcast("browser", "focusUrl", { tabId });
break;
}
}

export function registerBrowserChannel(
registry: BrowserTabRegistry,
channelDeps?: BrowserChannelDeps,
Expand Down Expand Up @@ -124,6 +152,15 @@ export function registerBrowserChannel(
broadcast("browser", "focused", { tabId });
});

// Intercept keystrokes that land while the PAGE has focus — they
// never reach the renderer dispatcher (the WebContentsView is
// outside the renderer DOM), so match them here and run the
// browser command. (The docked DevTools view is covered by the
// registry's DevTools key interceptor wired above.)
installBrowserKeyInterceptor(wc, tabId, (command, t) =>
runBrowserCommand(registry, command, t),
);

return ipcOk(undefined);
},

Expand Down
122 changes: 122 additions & 0 deletions src/main/features/browser/keyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Browser-view key interceptor (main process).
*
* WHY THIS EXISTS
* A browser tab is a `WebContentsView` — a separate web contents painted
* over the renderer DOM. Keystrokes that land while the *page* has focus
* never reach the renderer window's document, so the global keybinding
* dispatcher (which listens there) cannot see them. That is why the
* browser shortcuts (⌘R reload, ⌘⇧R hard reload, ⌘[ / ⌘] back/forward,
* ⌘L focus URL, ⌘⌥I devtools) appeared dead once you clicked into a page.
*
* The fix: intercept input on each browser webContents via
* `before-input-event` in main, match it against the SAME declarative
* KEYBINDINGS table (with user overrides applied), and run the command.
* Five of the six act in main directly through the registry; URL focus is
* a renderer concern, so it is bounced back over IPC.
*
* Customization: `updateBrowserKeybindings(overrides)` recomputes the
* match list from defaults + user overrides; main calls it at boot and on
* every `keybindingsChanged`. Matching reuses `parseAccelerator` +
* `matchesKeyState`, so the renderer dispatcher and this interceptor can
* never diverge on what a given accelerator means.
*
* Scope note: `before-input-event` only fires for the focused webContents,
* and we only attach to browser views — so the `when: "browserTabActive"`
* scope is satisfied structurally. We match on the keystroke alone.
*/

import type { WebContents } from "electron";
import { ALL_COMMAND_IDS, COMMANDS, type CommandId } from "../../../shared/keybindings/commands";
import { KEYBINDINGS } from "../../../shared/keybindings/index";
import {
matchesKeyState,
type ParsedKeystroke,
parseAccelerator,
} from "../../../shared/keybindings/keybinding-parse";
import {
applyKeybindingOverrides,
type KeybindingOverride,
} from "../../../shared/keybindings/overrides";
import { createLogger } from "../../../shared/log/main";

const logger = createLogger("browser-keyboard");

const KNOWN_COMMANDS: ReadonlySet<string> = new Set(ALL_COMMAND_IDS);
const IS_MAC = process.platform === "darwin";

/**
* Commands this interceptor routes when the browser page view has focus.
* (DevTools is intentionally excluded — ⌘⌥I belongs to the Electron menu
* role for app-window DevTools; the browser page's DevTools is button-only.)
*/
const BROWSER_COMMANDS: readonly CommandId[] = [
COMMANDS.browserFocusUrl,
COMMANDS.browserReload,
COMMANDS.browserHardReload,
COMMANDS.browserGoBack,
COMMANDS.browserGoForward,
];
const BROWSER_COMMAND_SET: ReadonlySet<string> = new Set(BROWSER_COMMANDS);

interface CompiledBinding {
command: CommandId;
parsed: ParsedKeystroke;
}

/** Current effective (defaults + overrides) primary bindings for browser commands. */
let compiled: CompiledBinding[] = compile(undefined);

function compile(overrides: readonly KeybindingOverride[] | undefined): CompiledBinding[] {
const effective = applyKeybindingOverrides(KEYBINDINGS, overrides, KNOWN_COMMANDS);
const out: CompiledBinding[] = [];
for (const decl of effective) {
if (!BROWSER_COMMAND_SET.has(decl.command)) continue;
if (decl.primary === undefined) continue; // unbound or chord-only — nothing to match
try {
out.push({ command: decl.command as CommandId, parsed: parseAccelerator(decl.primary) });
} catch (err) {
logger.warn(`skipping unparseable browser binding ${decl.command}=${decl.primary}: ${err}`);
}
}
return out;
}

/** Recompute the browser match list from user overrides (boot + on change). */
export function updateBrowserKeybindings(
overrides: readonly KeybindingOverride[] | undefined,
): void {
compiled = compile(overrides);
}

/**
* Attach the `before-input-event` interceptor to one browser webContents.
* `run` executes the resolved command for `tabId` (registry action or an
* IPC bounce). Safe to call once per view at creation time.
*/
export function installBrowserKeyInterceptor(
wc: WebContents,
tabId: string,
run: (command: CommandId, tabId: string) => void,
): void {
wc.on("before-input-event", (event, input) => {
if (input.type !== "keyDown") return;
if (typeof input.code !== "string" || input.code === "") return;

const state = {
code: input.code,
meta: input.meta,
ctrl: input.control,
shift: input.shift,
alt: input.alt,
};

for (const b of compiled) {
if (matchesKeyState(b.parsed, state, IS_MAC)) {
event.preventDefault();
run(b.command, tabId);
return;
}
}
});
}
29 changes: 23 additions & 6 deletions src/main/features/menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,20 @@
* registration and are unaffected by menu replacement.
*/

import type { TFunction } from "i18next";
import { app, BrowserWindow, Menu, type MenuItemConstructorOptions } from "electron";
import { COMMANDS } from "../../../shared/keybindings/commands";
import type { TFunction } from "i18next";
import type { CommandId } from "../../../shared/keybindings/commands";
import { ALL_COMMAND_IDS, COMMANDS } from "../../../shared/keybindings/commands";
import { KEYBINDINGS } from "../../../shared/keybindings/index";
import {
applyKeybindingOverrides,
type KeybindingOverride,
} from "../../../shared/keybindings/overrides";
import { isMac } from "../../infra/platform";
import { buildMenuTemplate, type MenuItemSpec } from "./template";

const KNOWN_COMMANDS: ReadonlySet<string> = new Set(ALL_COMMAND_IDS);

export interface InstallAppMenuOptions {
/** Called when the user clicks "Check for Updates..." in the App menu. */
onCheckForUpdates?: () => void;
Expand All @@ -34,20 +41,32 @@ export interface InstallAppMenuOptions {
* fallback strings embedded in buildMenuTemplate are used.
*/
t?: TFunction;
/**
* User keybinding overrides from appState. Menu accelerator labels
* render the EFFECTIVE binding (defaults + overrides) so the menu
* never shows a shortcut the dispatcher would not honor. Pass
* `stateService.getState().keybindingOverrides` — both at boot and on
* every rebuild (language change, keybinding change).
*/
keybindingOverrides?: readonly KeybindingOverride[];
}

export function installAppMenu(options: InstallAppMenuOptions = {}): void {
const template = buildMenuTemplate({
isMac: isMac(),
appName: app.getName(),
t: options.t,
bindings: applyKeybindingOverrides(KEYBINDINGS, options.keybindingOverrides, KNOWN_COMMANDS),
});

const electronTemplate = template.map((spec) => toElectron(spec, options));
Menu.setApplicationMenu(Menu.buildFromTemplate(electronTemplate));
}

function toElectron(spec: MenuItemSpec, options: InstallAppMenuOptions): MenuItemConstructorOptions {
function toElectron(
spec: MenuItemSpec,
options: InstallAppMenuOptions,
): MenuItemConstructorOptions {
switch (spec.type) {
case "separator":
return { type: "separator" };
Expand All @@ -71,9 +90,7 @@ function toElectron(spec: MenuItemSpec, options: InstallAppMenuOptions): MenuIte
// no canvas-vs-DOM mismatch — Cocoa's native dispatch reaches the
// correct target in every focus context.
const base: MenuItemConstructorOptions =
spec.label !== undefined
? { role: spec.role, label: spec.label }
: { role: spec.role };
spec.label !== undefined ? { role: spec.role, label: spec.label } : { role: spec.role };
if (spec.role === "copy") base.registerAccelerator = false;
return base;
}
Expand Down
Loading