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
29 changes: 29 additions & 0 deletions src/main/ipc/handlers/ShellHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ const EDITOR_COMMANDS: Record<Exclude<ExternalEditorId, 'none' | 'custom'>, stri
zed: 'zed',
};

/**
* Protocols allowed to be opened in the default system browser/handler
*/
const ALLOWED_EXTERNAL_PROTOCOLS = new Set([
'http:',
'https:',
'mailto:',
]);

/**
* Register shell IPC handlers
*/
Expand Down Expand Up @@ -68,6 +77,25 @@ export function registerShellHandlers(): void {
}
}
);

// Open an external URL in the default system browser/handler
ipcMain.handle(
IPC_CHANNELS.SHELL.OPEN_EXTERNAL,
async (_event, url: string): Promise<void> => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return;
}

if (!ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol)) {
return;
}

await shell.openExternal(parsed.toString());
}
);
}

/**
Expand All @@ -76,4 +104,5 @@ export function registerShellHandlers(): void {
export function unregisterShellHandlers(): void {
ipcMain.removeHandler(IPC_CHANNELS.SHELL.REVEAL_IN_FILE_MANAGER);
ipcMain.removeHandler(IPC_CHANNELS.SHELL.OPEN_IN_EDITOR);
ipcMain.removeHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL);
}
4 changes: 4 additions & 0 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,10 @@ const electronAPI: ElectronAPI = {
openInEditor: (filePath: string): Promise<OpenInEditorResult> => {
return ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_IN_EDITOR, filePath);
},

openExternal: (url: string): Promise<void> => {
return ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_EXTERNAL, url);
},
},

};
Expand Down
67 changes: 67 additions & 0 deletions src/renderer/components/MarkdownViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export class MarkdownViewer {
// Setup context menu handling
this.setupContextMenu();

// Setup external link handling
this.setupLinkHandling();

this.initialized = true;
}

Expand Down Expand Up @@ -303,12 +306,53 @@ export class MarkdownViewer {
});
}

/**
* Setup click handling so links to the internet open in the system browser
*/
private setupLinkHandling(): void {
this.container.addEventListener('click', (e) => {
if (e.button !== 0) return;

const target = e.target as HTMLElement;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return;

const href = anchor.getAttribute('href');
if (!href || !this.isExternalUrl(href)) return;

e.preventDefault();
void window.electronAPI.shell.openExternal(href);
});
}

/**
* Determine whether a link points to the internet (vs. an in-document anchor)
*/
private isExternalUrl(href: string): boolean {
try {
const protocol = new URL(href).protocol;
return protocol === 'http:' || protocol === 'https:' || protocol === 'mailto:';
} catch {
return false;
}
}

/**
* Handle context menu event
*/
private async handleContextMenu(e: MouseEvent): Promise<void> {
const target = e.target as HTMLElement;

// External links: offer copy/open actions
const anchor = target.closest('a[href]');
if (anchor instanceof HTMLAnchorElement) {
const href = anchor.getAttribute('href');
if (href && this.isExternalUrl(href)) {
await this.handleLinkContextMenu(e, href);
return;
}
}

// Find plugin-rendered element
const pluginElement = target.closest('[data-plugin-id]');
if (!pluginElement || !(pluginElement instanceof HTMLElement)) {
Expand Down Expand Up @@ -356,6 +400,29 @@ export class MarkdownViewer {
}
}

/**
* Show a context menu for an external link
*/
private async handleLinkContextMenu(e: MouseEvent, href: string): Promise<void> {
e.preventDefault();

const selectedId = await window.electronAPI.contextMenu.show({
items: [
{ id: 'copy-link', label: 'Copy Link to Clipboard', enabled: true },
{ id: 'open-link', label: 'Open in Default Browser', enabled: true },
],
x: e.screenX,
y: e.screenY,
});

if (selectedId === 'copy-link') {
await window.electronAPI.clipboard.writeText(href);
this.toast.success('Link copied to clipboard');
} else if (selectedId === 'open-link') {
await window.electronAPI.shell.openExternal(href);
}
}

/**
* Highlight an element being targeted by context menu
*/
Expand Down
2 changes: 2 additions & 0 deletions src/shared/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const IPC_CHANNELS = {
SHELL: {
REVEAL_IN_FILE_MANAGER: 'shell:reveal-in-file-manager',
OPEN_IN_EDITOR: 'shell:open-in-editor',
OPEN_EXTERNAL: 'shell:open-external',
},
} as const;

Expand Down Expand Up @@ -254,6 +255,7 @@ export interface OpenInEditorResult {
export interface ShellAPI {
revealInFileManager: (filePath: string) => Promise<void>;
openInEditor: (filePath: string) => Promise<OpenInEditorResult>;
openExternal: (url: string) => Promise<void>;
}

/**
Expand Down
61 changes: 59 additions & 2 deletions tests/unit/main/ipc/handlers/ShellHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock('electron', () => {
},
shell: {
showItemInFolder: vi.fn(),
openExternal: vi.fn(() => Promise.resolve()),
},
};
});
Expand Down Expand Up @@ -89,7 +90,7 @@ describe('ShellHandler', () => {
});

describe('registerShellHandlers', () => {
it('should register both shell IPC handlers', () => {
it('should register all shell IPC handlers', () => {
registerShellHandlers();

expect(ipcMain.handle).toHaveBeenCalledWith(
Expand All @@ -100,11 +101,15 @@ describe('ShellHandler', () => {
IPC_CHANNELS.SHELL.OPEN_IN_EDITOR,
expect.any(Function)
);
expect(ipcMain.handle).toHaveBeenCalledWith(
IPC_CHANNELS.SHELL.OPEN_EXTERNAL,
expect.any(Function)
);
});
});

describe('unregisterShellHandlers', () => {
it('should remove both shell IPC handlers', () => {
it('should remove all shell IPC handlers', () => {
registerShellHandlers();
unregisterShellHandlers();

Expand All @@ -114,6 +119,58 @@ describe('ShellHandler', () => {
expect(ipcMain.removeHandler).toHaveBeenCalledWith(
IPC_CHANNELS.SHELL.OPEN_IN_EDITOR
);
expect(ipcMain.removeHandler).toHaveBeenCalledWith(
IPC_CHANNELS.SHELL.OPEN_EXTERNAL
);
});
});

describe('OPEN_EXTERNAL handler', () => {
it('should open http URLs in the default browser', async () => {
registerShellHandlers();

const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL);
await handler?.({}, 'http://example.com/');

expect(shell.openExternal).toHaveBeenCalledWith('http://example.com/');
});

it('should open https URLs in the default browser', async () => {
registerShellHandlers();

const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL);
await handler?.({}, 'https://example.com/page');

expect(shell.openExternal).toHaveBeenCalledWith('https://example.com/page');
});

it('should open mailto links', async () => {
registerShellHandlers();

const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL);
await handler?.({}, 'mailto:someone@example.com');

expect(shell.openExternal).toHaveBeenCalledWith('mailto:someone@example.com');
});

it('should ignore disallowed protocols', async () => {
registerShellHandlers();

const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL);
await handler?.({}, 'file:///etc/passwd');
await handler?.({}, 'javascript:alert(1)');

expect(shell.openExternal).not.toHaveBeenCalled();
});

it('should ignore invalid URLs', async () => {
registerShellHandlers();

const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL);
await handler?.({}, 'not a url');
await handler?.({}, '#section');

expect(shell.openExternal).not.toHaveBeenCalled();
});
});

Expand Down
Loading