fix(ipc): wire preload bridge so menu Open file actually loads in editor + preview

Two related defects were breaking the 'open file' flow:

1. The BrowserWindow webPreferences had no preload path AND
   contextIsolation: false, so contextBridge.exposeInMainWorld
   threw on load and window.electronAPI was never set. Result:
   every renderer-side ipc.* call returned CHANNEL_MISSING and
   silently no-op'd. Toolbar Open/Save, the file menu, every
   dialog — all broken.

2. The 'file.opened' IPC bridge in register-menu-commands.ts
   dispatched to a 'file.opened' command that was never registered
   as a handler. Even if the preload had worked, the main menu's
   File -> Open would have been a no-op once it reached the
   command store.

Fixes:
- window/index.js: add preload path; set contextIsolation: true
  (required for contextBridge) and nodeIntegration: false
  (renderer no longer needs direct Node access; everything goes
  through the whitelisted preload bridge).
- file-store.ts: add openFileFromMain(path, content) that opens
  a tab + editor buffer from content the main process already read
  (skips the renderer's ipc.file.read since main has the bytes).
  Preserves existing buffer content if the file is already open.
- register-menu-commands.ts: register 'file.opened' to call
  openFileFromMain. Drop the dead 'command.palette' bridge
  (no consumer, no UI, no handler).
- tests: two new cases for openFileFromMain — verifies it does
  not call ipc.file.read and does not clobber user edits on
  re-open.

Verified end-to-end via the run-desktop driver: triggering
file-opened from the main process with a test .md now shows the
content in BOTH the CodeMirror editor and the rendered preview
pane (plus the Outline panel picks up the H1). 497 tests pass.
This commit is contained in:
2026-06-07 22:35:30 +05:30
parent 69afd5fc54
commit 5ef1610873
4 changed files with 64 additions and 3 deletions
+9 -2
View File
@@ -16,8 +16,15 @@ function createMainWindow() {
y: bounds.y,
show: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
// The preload script exposes `window.electronAPI` — the only IPC
// bridge the renderer uses. Without this, every renderer call returns
// CHANNEL_MISSING and file/folder/save all silently no-op.
preload: path.join(__dirname, '../../preload.js'),
// contextIsolation MUST be true for the preload's contextBridge
// exposeInMainWorld call to succeed. Without it, the preload throws
// on load and the renderer never gets the IPC bridge.
contextIsolation: true,
nodeIntegration: false,
spellcheck: true
},
icon: path.join(__dirname, '../../../assets/icon.png')
@@ -102,6 +102,10 @@ export function registerMenuCommands(): void {
});
register('view.toggleSidebar', () => useAppStore.getState().toggleSidebar());
register('view.togglePreview', () => useAppStore.getState().togglePreview());
register('file.opened', (payload?: { path?: string; content?: string }) => {
if (!payload?.path) return;
void useFileStore.getState().openFileFromMain(payload.path, payload.content ?? '');
});
}
export function useRegisterMenuCommands(): void {
@@ -118,7 +122,6 @@ export function useRegisterMenuCommands(): void {
export function useBridgeNativeMenu(): void {
useMenuAction('file-save', 'file.save');
useMenuAction('toggle-preview', 'view.togglePreview');
useMenuAction('toggle-command-palette', 'command.palette');
useMenuAction('toggle-sidebar-panel', 'view.sidebarPanel', (panel) => panel as string);
useMenuAction('toggle-bottom-panel', 'view.bottomPanel');
useMenuAction('toggle-find', 'find.toggle');
+20
View File
@@ -36,6 +36,7 @@ interface FileState {
loadChildren: (dirPath: string) => Promise<void>;
toggleExpanded: (dirPath: string) => void;
openFile: (filePath: string) => Promise<void>;
openFileFromMain: (filePath: string, content: string) => Promise<void>;
closeTab: (id: string) => void;
setActiveTab: (id: string) => void;
markTabClean: (id: string) => void;
@@ -165,6 +166,25 @@ export const useFileStore = create<FileState>()(
});
},
// Used when the main process has already read the file (e.g. file-opened
// IPC from File → Open menu). Skips the renderer-side IPC read since main
// already has the content. Preserves the editor's current buffer content
// if the file is already open.
openFileFromMain: async (filePath, content) => {
const existing = useFileStore.getState().openTabs.find((t) => t.id === filePath);
if (existing) {
set((s) => { s.activeTabId = filePath; });
return;
}
const title = filePath.split('/').pop() ?? filePath;
useEditorStore.getState().openBuffer(filePath, filePath, content);
set((s) => {
s.openTabs.push({ id: filePath, path: filePath, title, dirty: false });
s.activeTabId = filePath;
});
},
closeTab: (id) => {
set((s) => {
const idx = s.openTabs.findIndex((t) => t.id === id);
+31
View File
@@ -232,6 +232,37 @@ describe('useFileStore', () => {
expect(useEditorStore.getState().buffers.size).toBe(0);
});
// --- openFileFromMain ---
it('openFileFromMain does NOT call ipc.file.read (main already sent content)', async () => {
await useFileStore.getState().openFileFromMain('/root/from-menu.md', '# from menu');
expect(fakeRead).not.toHaveBeenCalled();
const buf = useEditorStore.getState().buffers.get('/root/from-menu.md');
expect(buf).not.toBeUndefined();
expect(buf!.content).toBe('# from menu');
const tabs = useFileStore.getState().openTabs;
expect(tabs).toHaveLength(1);
expect(tabs[0].path).toBe('/root/from-menu.md');
expect(tabs[0].title).toBe('from-menu.md');
expect(useFileStore.getState().activeTabId).toBe('/root/from-menu.md');
});
it('openFileFromMain on an already-open file does not overwrite the editor buffer', async () => {
// Open once via the menu path
await useFileStore.getState().openFileFromMain('/root/x.md', '# menu version');
// User then types something in the editor (simulated via updateContent)
useEditorStore.getState().updateContent('/root/x.md', '# user edited');
// Menu sends the same file again (e.g. user picks it from Recent Files)
await useFileStore.getState().openFileFromMain('/root/x.md', '# menu version');
// Buffer keeps the user's edits — the menu re-open must not clobber them
expect(useEditorStore.getState().buffers.get('/root/x.md')!.content).toBe('# user edited');
expect(useFileStore.getState().openTabs).toHaveLength(1);
expect(useFileStore.getState().activeTabId).toBe('/root/x.md');
});
// --- closeTab ---
it('closeTab removes the tab and updates activeTabId to the previous neighbor', async () => {