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
9 changes: 8 additions & 1 deletion packages/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
]
}
Expand Down
12 changes: 10 additions & 2 deletions packages/desktop/src/main/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@
editor.createWindow(rootDirectory, fileList, markdownList, options, bufferStoreInfo)
this._windowManager.add(editor)
if (this._windowManager.windowCount === 1) {
this._accessor.menu.setActiveWindow(editor.id!)

Check warning on line 491 in packages/desktop/src/main/app/index.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
}
return editor
}
Expand All @@ -501,7 +501,7 @@
setting.createWindow(category ?? null)
this._windowManager.add(setting)
if (this._windowManager.windowCount === 1) {
this._accessor.menu.setActiveWindow(setting.id!)

Check warning on line 504 in packages/desktop/src/main/app/index.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
}
}

Expand Down Expand Up @@ -643,7 +643,7 @@
const settingWins = this._windowManager.getWindowsByType(WindowType.SETTINGS)
if (settingWins.length >= 1) {
// A setting window is already created
const browserSettingWindow = settingWins[0].win.browserWindow!

Check warning on line 646 in packages/desktop/src/main/app/index.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
browserSettingWindow.webContents.send('settings::change-tab', category)
if (isLinux) {
browserSettingWindow.focus()
Expand Down Expand Up @@ -828,12 +828,20 @@
})

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) => {
Expand Down
36 changes: 36 additions & 0 deletions packages/desktop/src/main/menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
17 changes: 17 additions & 0 deletions packages/desktop/src/renderer/src/components/search/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,28 @@ 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) {
searchValue.value = value
}
})

// 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
Expand Down Expand Up @@ -229,6 +244,7 @@ const toggleCtrl = (ctrl: 'isCaseSensitive' | 'isWholeWord' | 'isRegexp') => {
}

const listenFind = () => {
prefillFromSelection()
showSearch.value = true
type.value = 'search'
nextTick(() => {
Expand All @@ -240,6 +256,7 @@ const listenFind = () => {
}

const listenReplace = () => {
prefillFromSelection()
showSearch.value = true
type.value = 'replace'
}
Expand Down
93 changes: 93 additions & 0 deletions packages/desktop/test/unit/specs/keybinding-menu-rebuild.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
121 changes: 121 additions & 0 deletions packages/desktop/test/unit/specs/search-prefill.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <script setup> at runtime, swap its imports for injected
// stubs (but keep Vue's *real* ref/computed/watch/nextTick), run setup() to grab
// the live bindings, and drive the actual reactive code. This mirrors the
// approach in source-code-image-action.spec.ts.

const here = dirname(fileURLToPath(import.meta.url))
const vuePath = resolve(here, '../../../src/renderer/src/components/search/index.vue')

interface Bindings {
searchValue: { value: string }
showSearch: { value: boolean }
listenFind: () => void
}

const loadComponent = (deps: Record<string, unknown>) => {
const src = readFileSync(vuePath, 'utf8')
const { descriptor } = parse(src)
const compiled = compileScript(descriptor, { id: 'test' })
const noImports = compiled.content
.split('\n')
.filter((l) => !/^\s*import\s/.test(l))
.join('\n')
const js = ts.transpileModule(noImports, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText
// eslint-disable-next-line no-new-func
const factory = new Function(
'__deps',
'exports',
'module',
`const { _defineComponent, ref, computed, watch, onMounted, onBeforeUnmount,
nextTick, bus, FindCaseIcon, FindWordIcon, FindRegexIcon, useEditorStore,
storeToRefs, useI18n, debounce, ArrowDown, ArrowUp, RefreshRight, Switch } = __deps
${js}
return module.exports`
) as (deps: Record<string, unknown>, exports: object, module: object) => {
default: { setup: (props: unknown, ctx: { expose: () => void }) => Bindings }
}
const m = { exports: {} as Record<string, unknown> }
return factory(deps, m.exports, m).default
}

const makeBindings = () => {
// currentFile.searchMatches is the channel SELECTION_CHANGE writes the
// selected text into; storeToRefs hands the component a ref to it.
const currentFile = ref<{ searchMatches: { matches: unknown[]; index: number; value: string } } | null>({
searchMatches: { matches: [], index: -1, value: '' }
})
const deps = {
_defineComponent: (o: unknown) => o,
ref,
computed,
watch,
nextTick,
onMounted: () => {},
onBeforeUnmount: () => {},
bus: { on: () => {}, off: () => {}, emit: vi.fn() },
FindCaseIcon: {},
FindWordIcon: {},
FindRegexIcon: {},
ArrowDown: {},
ArrowUp: {},
RefreshRight: {},
Switch: {},
useEditorStore: () => new Proxy({}, { get: () => () => {} }),
storeToRefs: () => ({ currentFile }),
useI18n: () => ({ t: (k: string) => k }),
debounce: (fn: (...a: unknown[]) => unknown) => fn
}
const comp = loadComponent(deps)
const ret = comp.setup({}, { expose: () => {} })
const setSelection = (value: string) => {
currentFile.value = { searchMatches: { matches: [], index: -1, value } }
}
return { ret, setSelection }
}

describe('find-bar prefill from selection', () => {
it('prefills the input with the selected text when the bar opens', async() => {
const { ret, setSelection } = makeBindings()
setSelection('fox')
await nextTick()
ret.listenFind()
await nextTick()
expect(ret.searchValue.value).toBe('fox')
})

it('does not let the focus-steal selection-change clobber the prefill', async() => {
const { ret, setSelection } = makeBindings()
// User selects a word in the editor.
setSelection('fox')
await nextTick()
// Find bar opens (prefills "fox") and steals focus.
ret.listenFind()
await nextTick()
expect(ret.searchValue.value).toBe('fox')
// Opening the bar steals editor focus → the engine emits a spurious
// selection-change pointing at the document start ("T"). It must NOT
// overwrite the prefilled query now that the bar owns it.
setSelection('T')
await nextTick()
expect(ret.showSearch.value).toBe(true)
expect(ret.searchValue.value).toBe('fox')
})
})
Loading
Loading