diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 5cd88b0cc9..faadce610e 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -101,7 +101,14 @@ export default defineConfig({ plugins: [ postcssPresetEnv({ stage: 0, - features: { 'nesting-rules': true } + features: { + 'nesting-rules': true, + // Electron ships Chromium, which supports CSS logical properties + // natively. Leave them untouched so `padding-inline-start` / + // `inset-inline-start` mirror correctly under `dir="rtl"` instead + // of being down-compiled to hard-coded LTR physical props (#4673). + 'logical-properties-and-values': false + } }) ] } diff --git a/packages/desktop/src/main/app/index.ts b/packages/desktop/src/main/app/index.ts index 372b74d5ea..74411b3f34 100644 --- a/packages/desktop/src/main/app/index.ts +++ b/packages/desktop/src/main/app/index.ts @@ -828,12 +828,20 @@ class App { }) ipcMain.handle('mt::keybinding-save-user-keybindings', async(_event, userKeybindings) => { - const { keybindings } = this._accessor + const { keybindings, menu } = this._accessor const editorWindows = this._windowManager .getWindowsByType(WindowType.EDITOR) .map(({ win }) => win.browserWindow) .filter((win): win is BrowserWindow => win != null) - return keybindings.setUserKeybindings(userKeybindings, editorWindows) + const saved = await keybindings.setUserKeybindings(userKeybindings, editorWindows) + + menu.updateKeybindings() + const keybindingMap = Object.fromEntries(keybindings.keys) + for (const win of editorWindows) { + win.webContents.send('mt::keybindings-response', keybindingMap) + } + + return saved }) ipcMain.handle('mt::fs-trash-item', async(_event, fullPath: string) => { diff --git a/packages/desktop/src/main/menu/index.ts b/packages/desktop/src/main/menu/index.ts index a66c873c9b..db818946cd 100644 --- a/packages/desktop/src/main/menu/index.ts +++ b/packages/desktop/src/main/menu/index.ts @@ -309,6 +309,42 @@ class AppMenu { }) } + /** + * Rebuild every window menu so updated keybinding accelerators are reflected + * wherever shortcuts are shown: the menu bar on Windows/Linux and the macOS + * application menu for both editor and settings windows. + */ + updateKeybindings(): void { + const recentUsedDocuments = this.getRecentlyUsedDocuments() + this.windowMenus.forEach((value, key) => { + const { menu: oldMenu, type } = value + + let newMenu: Menu | null = null + if (type === MenuType.EDITOR) { + if (!oldMenu) return + const { menu: rebuilt } = this._buildEditorMenu(recentUsedDocuments) + if (!rebuilt) return + + updateMenuItem(oldMenu, rebuilt, 'sourceCodeModeMenuItem') + updateMenuItem(oldMenu, rebuilt, 'typewriterModeMenuItem') + updateMenuItem(oldMenu, rebuilt, 'focusModeMenuItem') + updateMenuItem(oldMenu, rebuilt, 'sideBarMenuItem') + updateMenuItem(oldMenu, rebuilt, 'tabBarMenuItem') + newMenu = rebuilt + } else if (type === MenuType.SETTINGS) { + newMenu = this._buildSettingMenu().menu + if (!newMenu) return + } else { + return + } + + value.menu = newMenu + if (this.activeWindowId === key) { + this._setApplicationMenu(newMenu) + } + }) + } + /** * Update line ending menu items. * diff --git a/packages/desktop/src/renderer/src/components/search/index.vue b/packages/desktop/src/renderer/src/components/search/index.vue index c835a5f736..6ae87b26cc 100644 --- a/packages/desktop/src/renderer/src/components/search/index.vue +++ b/packages/desktop/src/renderer/src/components/search/index.vue @@ -170,6 +170,11 @@ watch(searchValue, () => { }) watch(searchMatches, (newValue, oldValue) => { + // Once the search bar is open it owns the query. Ignore editor + // selection-changes while open — notably the spurious selection-change the + // engine emits when the bar steals editor focus, which would otherwise + // clobber the just-prefilled value (e.g. leaving a stale single character). + if (showSearch.value) return if (!newValue || !oldValue) return const { value } = newValue if (value !== oldValue.value) { @@ -177,6 +182,16 @@ watch(searchMatches, (newValue, oldValue) => { } }) +// Seed the find input from the current selection synchronously, before the bar +// opens and steals focus. Relying on the reactive `searchMatches` watch alone +// races with the focus-steal selection-change and can drop the prefill. +const prefillFromSelection = () => { + const selected = searchMatches.value?.value + if (selected) { + searchValue.value = selected + } +} + const highlightIndex = computed(() => { if (searchMatches.value) { return searchMatches.value.index @@ -229,6 +244,7 @@ const toggleCtrl = (ctrl: 'isCaseSensitive' | 'isWholeWord' | 'isRegexp') => { } const listenFind = () => { + prefillFromSelection() showSearch.value = true type.value = 'search' nextTick(() => { @@ -240,6 +256,7 @@ const listenFind = () => { } const listenReplace = () => { + prefillFromSelection() showSearch.value = true type.value = 'replace' } diff --git a/packages/desktop/test/unit/specs/keybinding-menu-rebuild.spec.ts b/packages/desktop/test/unit/specs/keybinding-menu-rebuild.spec.ts new file mode 100644 index 0000000000..f7cf565aaf --- /dev/null +++ b/packages/desktop/test/unit/specs/keybinding-menu-rebuild.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Main-process slice: after the user saves new keybindings the application menu +// must be rebuilt so the menu bar shows the updated accelerators (#3998). Drive +// `AppMenu.updateKeybindings()` with a fake Electron `Menu` and stubbed menu +// templates and assert every window menu (editor + macOS settings) is rebuilt +// from the current keybindings and the active window's menu is re-applied. + +const { buildFromTemplate, setApplicationMenu, configureMenu, configSettingMenu } = vi.hoisted( + () => ({ + buildFromTemplate: vi.fn((template: unknown) => ({ + template, + getMenuItemById: () => ({ checked: false, enabled: true }) + })), + setApplicationMenu: vi.fn(), + configureMenu: vi.fn(() => ['EDITOR_TEMPLATE']), + configSettingMenu: vi.fn(() => ['SETTINGS_TEMPLATE']) + }) +) + +vi.mock('electron', () => ({ + app: { addRecentDocument: vi.fn(), clearRecentDocuments: vi.fn() }, + ipcMain: { on: vi.fn(), handle: vi.fn(), emit: vi.fn() }, + Menu: { buildFromTemplate, setApplicationMenu, getApplicationMenu: vi.fn() } +})) + +vi.mock('common/filesystem', () => ({ + ensureDirSync: vi.fn(), + isDirectory2: () => false, + isFile2: () => false +})) + +// macOS so the settings window also owns a (non-null) menu that must rebuild. +vi.mock('main_renderer/config', () => ({ isLinux: false, isOsx: true, isWindows: false })) + +vi.mock('main_renderer/menu/actions/edit', () => ({ updateSidebarMenu: vi.fn() })) +vi.mock('main_renderer/menu/actions/format', () => ({ updateFormatMenu: vi.fn() })) +vi.mock('main_renderer/menu/actions/paragraph', () => ({ updateSelectionMenus: vi.fn() })) +vi.mock('main_renderer/menu/actions/view', () => ({ viewLayoutChanged: vi.fn() })) +vi.mock('main_renderer/utils/internalIpc', () => ({ onInternalChannel: vi.fn() })) +vi.mock('main_renderer/i18n.js', () => ({ setLanguage: vi.fn() })) +vi.mock('main_renderer/menu/templates', () => ({ + default: configureMenu, + configSettingMenu +})) + +import AppMenu from 'main_renderer/menu' +import type Preference from 'main_renderer/preferences' +import type Keybindings from 'main_renderer/keyboard/shortcutHandler' + +const makeAppMenu = () => { + const preferences = { getItem: () => 'en' } as unknown as Preference + const keybindings = { registerEditorKeyHandlers: vi.fn() } as unknown as Keybindings + return new AppMenu(preferences, keybindings, '/tmp/mt-test') +} + +describe('AppMenu.updateKeybindings rebuilds menus after a keybinding change (#3998)', () => { + beforeEach(() => { + buildFromTemplate.mockClear() + setApplicationMenu.mockClear() + configureMenu.mockClear() + configSettingMenu.mockClear() + }) + + it('rebuilds the active editor menu and re-applies it as the application menu', () => { + const appMenu = makeAppMenu() + const editorWin = { id: 1 } as never + appMenu.addEditorMenu(editorWin) + appMenu.setActiveWindow(1) + + configureMenu.mockClear() + setApplicationMenu.mockClear() + + appMenu.updateKeybindings() + + // The editor menu is rebuilt from the current keybindings... + expect(configureMenu).toHaveBeenCalled() + // ...and pushed to the OS as the active application menu. + expect(setApplicationMenu).toHaveBeenCalledTimes(1) + }) + + it('also rebuilds the settings-window menu so its accelerators refresh', () => { + const appMenu = makeAppMenu() + const settingWin = { id: 2 } as never + appMenu.addSettingMenu(settingWin) + + configSettingMenu.mockClear() + + appMenu.updateKeybindings() + + expect(configSettingMenu).toHaveBeenCalled() + }) +}) diff --git a/packages/desktop/test/unit/specs/search-prefill.spec.ts b/packages/desktop/test/unit/specs/search-prefill.spec.ts new file mode 100644 index 0000000000..ea4e25d160 --- /dev/null +++ b/packages/desktop/test/unit/specs/search-prefill.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' +import { parse, compileScript } from 'vue/compiler-sfc' +import ts from 'typescript' +import { ref, computed, watch, nextTick } from 'vue' + +// Regression guard for the find-bar prefill race (issue: the input showed a +// stale single char like "T" instead of the selection). The bug lives entirely +// in search/index.vue's reactive logic: `watch(searchMatches)` mirrors the +// editor selection into the input, but when the find bar opens it steals focus +// and the engine emits a spurious selection-change pointing at the document +// start, which clobbers the just-prefilled value. +// +// The desktop unit runner ships no @vitejs/plugin-vue / @vue/test-utils, so we +// compile the real