feat: Allow ctrl/cmd + click to open URLs present in codemirror editors (#5160)

* Allow ctrl/cmd + click to open URLs

* fix for when user does cmd+tab, then comes back without it

---------

Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
Arun Bansal
2025-10-29 11:45:11 +00:00
committed by Sid
parent 2ac41806a2
commit 460832f3ed
10 changed files with 838 additions and 8 deletions

16
package-lock.json generated
View File

@@ -26871,6 +26871,7 @@
"jsonc-parser": "^3.2.1",
"jsonpath-plus": "^10.3.0",
"know-your-http-well": "^0.5.0",
"linkify-it": "^5.0.0",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
@@ -28420,6 +28421,15 @@
"node": ">=18.0.0"
}
},
"packages/bruno-app/node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"packages/bruno-app/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -28468,6 +28478,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-app/node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"packages/bruno-app/node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View File

@@ -48,6 +48,7 @@
"jsonc-parser": "^3.2.1",
"jsonpath-plus": "^10.3.0",
"know-your-http-well": "^0.5.0",
"linkify-it": "^5.0.0",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",

View File

@@ -14,6 +14,7 @@ import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
const CodeMirror = require('codemirror');
@@ -47,7 +48,7 @@ export default class CodeEditor extends React.Component {
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
const editor = (this.editor = CodeMirror(this._node, {
const editor = (this.editor = makeLinkAwareCodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
lineWrapping: this.props.enableLineWrapping ?? true,
@@ -266,6 +267,9 @@ export default class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
if(this.editor._destroyLinkAware) {
this.editor._destroyLinkAware();
}
this.editor.off('change', this._onEdit);
this.editor.off('scroll', this.onScroll);
this.editor = null;

View File

@@ -5,6 +5,7 @@ import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -29,7 +30,9 @@ class MultiLineEditor extends Component {
/** @type {import("codemirror").Editor} */
const variables = getAllVariables(this.props.collection, this.props.item);
this.editor = CodeMirror(this.editorRef.current, {
this.editor = makeLinkAwareCodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
placeholder: this.props.placeholder,
mode: 'brunovariables',
@@ -168,6 +171,11 @@ class MultiLineEditor extends Component {
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
if(this.editor._destroyLinkAware) {
this.editor._destroyLinkAware();
}
if (this.maskedEditor) {
this.maskedEditor.destroy();
this.maskedEditor = null;

View File

@@ -17,6 +17,7 @@ import StyledWrapper from './StyledWrapper';
import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
const CodeMirror = require('codemirror');
@@ -35,7 +36,7 @@ export default class QueryEditor extends React.Component {
}
componentDidMount() {
const editor = (this.editor = CodeMirror(this._node, {
const editor = (this.editor = makeLinkAwareCodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
tabSize: 2,
@@ -170,6 +171,9 @@ export default class QueryEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
if(this.editor._destroyLinkAware) {
this.editor._destroyLinkAware();
}
this.editor.off('change', this._onEdit);
this.editor.off('keyup', this._onKeyUp);
this.editor.off('hasCompletion', this._onHasCompletion);

View File

@@ -6,8 +6,7 @@ import { MaskedEditor } from 'utils/common/masked-editor';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
class SingleLineEditor extends Component {
constructor(props) {
@@ -42,7 +41,7 @@ class SingleLineEditor extends Component {
};
const noopHandler = () => {};
this.editor = CodeMirror(this.editorRef.current, {
this.editor = makeLinkAwareCodeMirror(this.editorRef.current, {
placeholder: this.props.placeholder ?? '',
lineWrapping: false,
lineNumbers: false,
@@ -189,6 +188,9 @@ class SingleLineEditor extends Component {
componentWillUnmount() {
if (this.editor) {
if(this.editor._destroyLinkAware) {
this.editor._destroyLinkAware();
}
this.editor.off('change', this._onEdit);
this.editor.off('paste', this._onPaste);
this._clearNewlineMarkers();

View File

@@ -469,6 +469,14 @@ const GlobalStyle = createGlobalStyle`
background: #08f !important;
color: #fff !important;
}
.hovered-link.CodeMirror-link {
text-decoration: underline !important;
}
.cmd-ctrl-pressed .hovered-link.CodeMirror-link[data-url] {
cursor: pointer;
color: ${(props) => props.theme.textLink} !important;
}
`;
export default GlobalStyle;

View File

@@ -0,0 +1,134 @@
const CodeMirror = require('codemirror');
import LinkifyIt from 'linkify-it';
import { isMacOS } from 'utils/common/platform';
export default function makeLinkAwareCodeMirror(host, options = {}) {
const cmdCtrlClass = 'cmd-ctrl-pressed';
const linkClass = 'CodeMirror-link';
const linkHoverClass = 'hovered-link';
const linkHint = isMacOS() ? 'Hold Cmd and click to open link' : 'Hold Ctrl and click to open link';
const isCmdOrCtrlPressed = (event) => (isMacOS() ? event.metaKey : event.ctrlKey);
const editor = CodeMirror(host, {
...options,
configureMouse: (cm, repeat, ev) => {
if (isCmdOrCtrlPressed(ev) && ev.target?.classList.contains(linkClass)) {
return { addNew: false }; // prevent multi-cursor on Cmd+click on links
}
return {};
}
});
if (!editor) return editor;
const linkify = new LinkifyIt();
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const debouncedMarkUrls = debounce(() => {
requestAnimationFrame(markUrls);
}, 150);
function markUrls() {
const doc = editor.getDoc();
const text = doc.getValue();
editor.getAllMarks().forEach((mark) => {
if (mark.className === linkClass) mark.clear();
});
const matches = linkify.match(text);
matches?.forEach(({ index, lastIndex, url }) => {
const from = editor.posFromIndex(index);
const to = editor.posFromIndex(lastIndex);
editor.markText(from, to, {
className: linkClass,
attributes: {
'data-url': url,
title: linkHint
}
});
});
}
const handleMouseEnter = (e) => {
const el = e.target;
if (!el.classList.contains(linkClass)) return;
updateCmdCtrlClass(e);
el.classList.add(linkHoverClass);
let sibling = el.previousElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.add(linkHoverClass);
sibling = sibling.previousElementSibling;
}
sibling = el.nextElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.add(linkHoverClass);
sibling = sibling.nextElementSibling;
}
};
const handleMouseLeave = (e) => {
const el = e.target;
el.classList.remove(linkHoverClass);
let sibling = el.previousElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.remove(linkHoverClass);
sibling = sibling.previousElementSibling;
}
sibling = el.nextElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.remove(linkHoverClass);
sibling = sibling.nextElementSibling;
}
};
const editorWrapper = editor.getWrapperElement();
function updateCmdCtrlClass(event) {
if (isCmdOrCtrlPressed(event)) {
editorWrapper.classList.add(cmdCtrlClass);
} else {
editorWrapper.classList.remove(cmdCtrlClass);
}
}
function handleClick(event) {
if (!isCmdOrCtrlPressed(event)) return;
if (event.target.classList.contains(linkClass)) {
event.preventDefault();
event.stopPropagation();
const url = event.target.getAttribute('data-url');
if (url) {
window?.ipcRenderer?.openExternal(url);
}
}
}
// Initial marking and event binding
markUrls();
editor.on('changes', debouncedMarkUrls);
window.addEventListener('keydown', updateCmdCtrlClass);
window.addEventListener('keyup', updateCmdCtrlClass);
editorWrapper.addEventListener('click', handleClick);
// Listen for mouseover to add hover effect
editorWrapper.addEventListener('mouseover', handleMouseEnter);
// Listen for mouseout to reset the hover effect
editorWrapper.addEventListener('mouseout', handleMouseLeave);
editor._destroyLinkAware = () => {
editor.off('changes', debouncedMarkUrls);
window.removeEventListener('keydown', updateCmdCtrlClass);
window.removeEventListener('keyup', updateCmdCtrlClass);
editorWrapper.removeEventListener('click', handleClick);
editorWrapper.removeEventListener('mouseover', handleMouseEnter);
editorWrapper.removeEventListener('mouseout', handleMouseLeave);
};
// Return editor instance
return editor;
}

View File

@@ -0,0 +1,652 @@
import makeLinkAwareCodeMirror from './makeLinkAwareCodeMirror';
import LinkifyIt from 'linkify-it';
import { isMacOS } from 'utils/common/platform';
const CodeMirror = require('codemirror');
// Mock dependencies
jest.mock('codemirror', () => {
const mockEditor = {
getDoc: jest.fn(),
getAllMarks: jest.fn(),
markText: jest.fn(),
posFromIndex: jest.fn(),
getWrapperElement: jest.fn(),
on: jest.fn(),
off: jest.fn(),
_destroyLinkAware: undefined
};
const CodeMirror = jest.fn(() => mockEditor);
return CodeMirror;
});
// Mock linkify-it
jest.mock('linkify-it', () => {
return jest.fn().mockImplementation(() => ({
match: jest.fn()
}));
});
jest.mock('utils/common/platform', () => ({
isMacOS: jest.fn()
}));
// Mock requestAnimationFrame
global.requestAnimationFrame = jest.fn((cb) => cb());
// Mock window.ipcRenderer
global.window = {
...global.window,
ipcRenderer: {
openExternal: jest.fn()
},
addEventListener: jest.fn(),
removeEventListener: jest.fn()
};
describe('makeLinkAwareCodeMirror', () => {
let mockHost;
let mockEditor;
let mockDoc;
let mockWrapperElement;
let mockLinkify;
let mockMark;
let originalTimeout;
let mockSetTimeout;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
// Create a Jest mock for setTimeout
mockSetTimeout = jest.spyOn(global, 'setTimeout');
// Store original timeout and mock requestAnimationFrame
originalTimeout = global.setTimeout;
global.requestAnimationFrame = jest.fn((cb) => cb());
// Setup DOM mocks
mockHost = document.createElement('div');
mockWrapperElement = {
classList: {
add: jest.fn(),
remove: jest.fn()
},
addEventListener: jest.fn(),
removeEventListener: jest.fn()
};
mockMark = {
clear: jest.fn()
};
mockDoc = {
getValue: jest.fn().mockReturnValue('Check out https://example.com and http://test.org')
};
mockEditor = {
getDoc: jest.fn().mockReturnValue(mockDoc),
getAllMarks: jest.fn().mockReturnValue([mockMark]),
markText: jest.fn(),
posFromIndex: jest.fn().mockImplementation((index) => ({ line: 0, ch: index })),
getWrapperElement: jest.fn().mockReturnValue(mockWrapperElement),
on: jest.fn(),
off: jest.fn()
};
mockLinkify = {
match: jest.fn().mockReturnValue([
{ index: 10, lastIndex: 28, url: 'https://example.com' },
{ index: 33, lastIndex: 48, url: 'http://test.org' }
])
};
// Setup mocks
CodeMirror.mockReturnValue(mockEditor);
LinkifyIt.mockImplementation(() => mockLinkify);
// Mock window and ipcRenderer
global.window = {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
ipcRenderer: {
openExternal: jest.fn()
}
};
});
afterEach(() => {
delete global.window;
delete global.requestAnimationFrame;
global.setTimeout = originalTimeout;
mockSetTimeout.mockRestore();
jest.useRealTimers();
});
describe('editor creation and configuration', () => {
it('should create a CodeMirror editor with default options', () => {
const result = makeLinkAwareCodeMirror(mockHost);
expect(CodeMirror).toHaveBeenCalledWith(
mockHost,
expect.objectContaining({
configureMouse: expect.any(Function)
})
);
expect(result).toBe(mockEditor);
});
it('should merge custom options with default configuration', () => {
const customOptions = { lineNumbers: true, theme: 'dark' };
makeLinkAwareCodeMirror(mockHost, customOptions);
expect(CodeMirror).toHaveBeenCalledWith(
mockHost,
expect.objectContaining({
lineNumbers: true,
theme: 'dark',
configureMouse: expect.any(Function)
})
);
});
it('should return early if editor creation fails', () => {
CodeMirror.mockReturnValue(null);
const result = makeLinkAwareCodeMirror(mockHost);
expect(result).toBeNull();
});
it('should add _destroyLinkAware method to editor', () => {
const result = makeLinkAwareCodeMirror(mockHost);
expect(result._destroyLinkAware).toBeInstanceOf(Function);
});
});
describe('platform-specific key detection', () => {
it('should detect Cmd key on macOS', () => {
isMacOS.mockReturnValue(true);
makeLinkAwareCodeMirror(mockHost);
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
const mockEvent = { metaKey: true, ctrlKey: false, target: { classList: { contains: () => true } } };
const result = configureMouse(null, null, mockEvent);
expect(result).toEqual({ addNew: false });
});
it('should detect Ctrl key on non-macOS', () => {
isMacOS.mockReturnValue(false);
makeLinkAwareCodeMirror(mockHost);
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
const mockEvent = { metaKey: false, ctrlKey: true, target: { classList: { contains: () => true } } };
const result = configureMouse(null, null, mockEvent);
expect(result).toEqual({ addNew: false });
});
it('should return empty object when modifier key is not pressed', () => {
makeLinkAwareCodeMirror(mockHost);
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
const mockEvent = { metaKey: false, ctrlKey: false, target: { classList: { contains: () => true } } };
const result = configureMouse(null, null, mockEvent);
expect(result).toEqual({});
});
it('should return empty object when target is not a link', () => {
isMacOS.mockReturnValue(true);
makeLinkAwareCodeMirror(mockHost);
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
const mockEvent = { metaKey: true, target: { classList: { contains: () => false } } };
const result = configureMouse(null, null, mockEvent);
expect(result).toEqual({});
});
});
describe('CSS class management', () => {
it('should add cmd-ctrl-pressed class when modifier key is pressed', () => {
isMacOS.mockReturnValue(true);
makeLinkAwareCodeMirror(mockHost);
const keydownHandler = global.window.addEventListener.mock.calls.find((call) => call[0] === 'keydown')[1];
const mockEvent = { metaKey: true };
keydownHandler(mockEvent);
expect(mockWrapperElement.classList.add).toHaveBeenCalledWith('cmd-ctrl-pressed');
});
it('should remove cmd-ctrl-pressed class when modifier key is released', () => {
isMacOS.mockReturnValue(false);
makeLinkAwareCodeMirror(mockHost);
const keyupHandler = global.window.addEventListener.mock.calls.find((call) => call[0] === 'keyup')[1];
const mockEvent = { ctrlKey: false };
keyupHandler(mockEvent);
expect(mockWrapperElement.classList.remove).toHaveBeenCalledWith('cmd-ctrl-pressed');
});
});
describe('click handling', () => {
it('should open external URL when Cmd+clicking on a link', () => {
isMacOS.mockReturnValue(true);
makeLinkAwareCodeMirror(mockHost);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: true,
target: {
classList: { contains: (className) => className === 'CodeMirror-link' },
getAttribute: () => 'https://example.com'
},
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};
clickHandler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.stopPropagation).toHaveBeenCalled();
expect(global.window.ipcRenderer.openExternal).toHaveBeenCalledWith('https://example.com');
});
it('should not open URL when clicking without modifier key', () => {
makeLinkAwareCodeMirror(mockHost);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: false,
ctrlKey: false,
target: {
classList: { contains: (className) => className === 'CodeMirror-link' },
getAttribute: () => 'https://example.com'
}
};
clickHandler(mockEvent);
expect(global.window.ipcRenderer.openExternal).not.toHaveBeenCalled();
});
it('should not open URL when clicking on non-link element', () => {
isMacOS.mockReturnValue(true);
makeLinkAwareCodeMirror(mockHost);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: true,
target: {
classList: { contains: () => false }
}
};
clickHandler(mockEvent);
expect(global.window.ipcRenderer.openExternal).not.toHaveBeenCalled();
});
it('should not open URL when data-url attribute is missing', () => {
isMacOS.mockReturnValue(true);
makeLinkAwareCodeMirror(mockHost);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: true,
target: {
classList: { contains: (className) => className === 'CodeMirror-link' },
getAttribute: () => null
},
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};
clickHandler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.stopPropagation).toHaveBeenCalled();
expect(global.window.ipcRenderer.openExternal).not.toHaveBeenCalled();
});
});
// Test debouncing behavior
describe('debouncing', () => {
it('should debounce URL marking on content changes', () => {
makeLinkAwareCodeMirror(mockHost);
// Clear the calls from initial setup
mockEditor.getAllMarks.mockClear();
// Simulate multiple rapid content changes
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
changeHandler();
changeHandler();
changeHandler();
expect(setTimeout).toHaveBeenCalledTimes(3);
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 150);
// Fast-forward timers
jest.runAllTimers();
// Should only mark URLs once
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
expect(mockEditor.getAllMarks).toHaveBeenCalledTimes(1);
});
it('should apply link tooltips when marking URLs', () => {
makeLinkAwareCodeMirror(mockHost);
expect(mockEditor.markText).toHaveBeenCalledWith(
{ line: 0, ch: 10 },
{ line: 0, ch: 28 },
{
className: 'CodeMirror-link',
attributes: {
'data-url': 'https://example.com',
title: 'Hold Cmd and click to open link'
}
}
);
});
});
// Test animation frame handling
describe('animation frame handling', () => {
it('should use requestAnimationFrame for URL marking', () => {
makeLinkAwareCodeMirror(mockHost);
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
changeHandler();
jest.runAllTimers();
expect(requestAnimationFrame).toHaveBeenCalled();
});
});
describe('hover behavior', () => {
it('should add hover class on mouseover for link elements', () => {
makeLinkAwareCodeMirror(mockHost);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
(call) => call[0] === 'mouseover'
)[1];
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn(),
remove: jest.fn()
},
previousElementSibling: {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn(),
remove: jest.fn()
},
previousElementSibling: null
},
nextElementSibling: {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn(),
remove: jest.fn()
},
nextElementSibling: null
}
};
const mockEvent = { target: mockTarget };
mouseoverHandler(mockEvent);
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockTarget.previousElementSibling.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockTarget.nextElementSibling.classList.add).toHaveBeenCalledWith('hovered-link');
});
it('should not add hover class for non-link elements', () => {
makeLinkAwareCodeMirror(mockHost);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
(call) => call[0] === 'mouseover'
)[1];
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(false),
add: jest.fn()
}
};
const mockEvent = { target: mockTarget };
mouseoverHandler(mockEvent);
expect(mockTarget.classList.add).not.toHaveBeenCalled();
});
it('should remove hover class on mouseout', () => {
makeLinkAwareCodeMirror(mockHost);
const mouseoutHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseout')[1];
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
remove: jest.fn()
},
previousElementSibling: {
classList: {
contains: jest.fn().mockReturnValue(true),
remove: jest.fn()
},
previousElementSibling: null
},
nextElementSibling: {
classList: {
contains: jest.fn().mockReturnValue(true),
remove: jest.fn()
},
nextElementSibling: null
}
};
const mockEvent = { target: mockTarget };
mouseoutHandler(mockEvent);
expect(mockTarget.classList.remove).toHaveBeenCalledWith('hovered-link');
expect(mockTarget.previousElementSibling.classList.remove).toHaveBeenCalledWith('hovered-link');
expect(mockTarget.nextElementSibling.classList.remove).toHaveBeenCalledWith('hovered-link');
});
it('should handle multi-span links correctly on hover', () => {
makeLinkAwareCodeMirror(mockHost);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
(call) => call[0] === 'mouseover'
)[1];
// Create a mock with a chain of link spans
const mockNestedPrev = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: null
};
const mockPrev = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: mockNestedPrev
};
const mockNestedNext = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
nextElementSibling: null
};
const mockNext = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
nextElementSibling: mockNestedNext
};
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: mockPrev,
nextElementSibling: mockNext
};
const mockEvent = { target: mockTarget };
mouseoverHandler(mockEvent);
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockPrev.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockNestedPrev.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockNext.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockNestedNext.classList.add).toHaveBeenCalledWith('hovered-link');
});
});
// Test memory cleanup
describe('memory cleanup', () => {
it('should properly clean up all event listeners and marks', () => {
const editor = makeLinkAwareCodeMirror(mockHost);
editor._destroyLinkAware();
expect(mockEditor.off).toHaveBeenCalled();
expect(global.window.removeEventListener).toHaveBeenCalledTimes(2);
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledTimes(3); // click, mouseover, mouseout
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function));
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledWith('mouseover', expect.any(Function));
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledWith('mouseout', expect.any(Function));
});
});
describe('edge cases', () => {
it('should handle missing target in mouse event', () => {
makeLinkAwareCodeMirror(mockHost);
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
const mockEvent = { metaKey: true, target: null };
const result = configureMouse(null, null, mockEvent);
expect(result).toEqual({});
});
it('should handle missing ipcRenderer', () => {
delete global.window.ipcRenderer;
isMacOS.mockReturnValue(true);
makeLinkAwareCodeMirror(mockHost);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: true,
target: {
classList: { contains: (className) => className === 'CodeMirror-link' },
getAttribute: () => 'https://example.com'
},
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};
expect(() => clickHandler(mockEvent)).not.toThrow();
});
it('should handle LinkifyIt returning null matches', () => {
mockLinkify.match.mockReturnValue(null);
expect(() => makeLinkAwareCodeMirror(mockHost)).not.toThrow();
expect(mockEditor.markText).not.toHaveBeenCalled();
});
it('should handle null siblings in mouseover events', () => {
makeLinkAwareCodeMirror(mockHost);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
(call) => call[0] === 'mouseover'
)[1];
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: null,
nextElementSibling: null
};
const mockEvent = { target: mockTarget };
expect(() => mouseoverHandler(mockEvent)).not.toThrow();
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
});
it('should handle non-link siblings in mouseover events', () => {
makeLinkAwareCodeMirror(mockHost);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
(call) => call[0] === 'mouseover'
)[1];
const mockPrev = {
classList: {
contains: jest.fn().mockReturnValue(false),
add: jest.fn()
}
};
const mockNext = {
classList: {
contains: jest.fn().mockReturnValue(false),
add: jest.fn()
}
};
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: mockPrev,
nextElementSibling: mockNext
};
const mockEvent = { target: mockTarget };
mouseoverHandler(mockEvent);
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockPrev.classList.add).not.toHaveBeenCalled();
expect(mockNext.classList.add).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,4 @@
const { ipcRenderer, contextBridge, webUtils } = require('electron');
const { ipcRenderer, contextBridge, webUtils, shell } = require('electron');
contextBridge.exposeInMainWorld('ipcRenderer', {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
@@ -14,5 +14,6 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
getFilePath(file) {
const path = webUtils.getPathForFile(file);
return path;
}
},
openExternal: (url) => shell.openExternal(url)
});