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

* 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>

* Feature/cmd click on links (#5927)

fix: clean up whitespace and formatting in linkAware functions

fix rediff

Feature/cmd click on links (#6132)

* Allow ctrl/cmd + click to open URLs

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

* refactored the community contribution to match Autocomplete's implementation

* updated the code to resolve issues caused during merge conflict resolution with the use of makeLinkAware

* fix: updated the code to use lodash's debounce and removed redundant undefined checks

* fix: correct debouncing test expectation in linkAware.spec.js

The test was incorrectly expecting 3 setTimeout calls when debouncing
should only result in one active timeout. Updated the test to verify
debouncing behavior correctly by checking that setTimeout is called
with the correct delay, and that only one execution happens after
the debounce delay.

* fix: fixed merge issues in linkAware.js

* fix: fixed CodeMirror assignment to this.editor

* fix: formatting fixes

* fix: formatting fix

---------

Co-authored-by: abansal21 <37215457+abansal21@users.noreply.github.com>
Co-authored-by: Chirag Chandrashekhar <chirag@usebruno.com>

---------

Co-authored-by: Arun Bansal <37215457+abansal21@users.noreply.github.com>
Co-authored-by: Chirag Chandrashekhar <chirag@usebruno.com>
This commit is contained in:
Sid
2025-11-18 17:56:37 +05:30
committed by GitHub
10 changed files with 827 additions and 4 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 { setupLinkAware } from 'utils/codemirror/linkAware';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
const CodeMirror = require('codemirror');
@@ -204,6 +205,8 @@ export default class CodeEditor extends React.Component {
editor,
autoCompleteOptions
);
setupLinkAware(editor);
}
}
@@ -266,6 +269,7 @@ export default class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
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 { setupLinkAware } from 'utils/codemirror/linkAware';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -30,6 +31,8 @@ class MultiLineEditor extends Component {
const variables = getAllVariables(this.props.collection, this.props.item);
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
placeholder: this.props.placeholder,
mode: 'brunovariables',
@@ -84,6 +87,8 @@ class MultiLineEditor extends Component {
this.editor,
autoCompleteOptions
);
setupLinkAware(this.editor);
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
@@ -168,6 +173,9 @@ 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 { setupLinkAware } from 'utils/codemirror/linkAware';
const CodeMirror = require('codemirror');
@@ -138,6 +139,8 @@ export default class QueryEditor extends React.Component {
editor.on('beforeChange', this._onBeforeChange);
}
this.addOverlay();
setupLinkAware(editor);
}
componentDidUpdate(prevProps) {
@@ -170,6 +173,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,6 +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';
import { setupLinkAware } from 'utils/codemirror/linkAware';
const CodeMirror = require('codemirror');
@@ -40,7 +41,7 @@ class SingleLineEditor extends Component {
this.props.onSave();
}
};
const noopHandler = () => {};
const noopHandler = () => { };
this.editor = CodeMirror(this.editorRef.current, {
placeholder: this.props.placeholder ?? '',
@@ -94,7 +95,7 @@ class SingleLineEditor extends Component {
this.editor,
autoCompleteOptions
);
this.editor.setValue(String(this.props.value ?? ''));
this.editor.on('change', this._onEdit);
this.editor.on('paste', this._onPaste);
@@ -106,6 +107,7 @@ class SingleLineEditor extends Component {
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
setupLinkAware(this.editor);
}
/** Enable or disable masking the rendered content of the editor */
@@ -189,6 +191,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,183 @@
import LinkifyIt from 'linkify-it';
import { isMacOS } from 'utils/common/platform';
import { debounce } from 'lodash';
/**
* Marks URLs in the CodeMirror editor with clickable link styling
* @param {Object} editor - The CodeMirror editor instance
* @param {Object} linkify - The LinkifyIt instance for URL detection
* @param {string} linkClass - CSS class name for links
* @param {string} linkHint - Tooltip text for links
*/
function markUrls(editor, linkify, linkClass, linkHint) {
const doc = editor.getDoc();
const text = doc.getValue();
// Clear existing link marks
editor.getAllMarks().forEach((mark) => {
if (mark.className === linkClass) mark.clear();
});
// Find and mark new URLs
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
}
});
});
}
/**
* Handles mouse enter events on links to show hover effects
* @param {Event} event - The mouse enter event
* @param {string} linkClass - CSS class name for links
* @param {string} linkHoverClass - CSS class name for hovered links
* @param {Function} updateCmdCtrlClass - Function to update Cmd/Ctrl state
*/
function handleMouseEnter(event, linkClass, linkHoverClass, updateCmdCtrlClass) {
const el = event.target;
if (!el.classList.contains(linkClass)) return;
updateCmdCtrlClass(event);
el.classList.add(linkHoverClass);
// Add hover effect to previous siblings that are also links
let sibling = el.previousElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.add(linkHoverClass);
sibling = sibling.previousElementSibling;
}
// Add hover effect to next siblings that are also links
sibling = el.nextElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.add(linkHoverClass);
sibling = sibling.nextElementSibling;
}
}
/**
* Handles mouse leave events on links to remove hover effects
* @param {Event} event - The mouse leave event
* @param {string} linkClass - CSS class name for links
* @param {string} linkHoverClass - CSS class name for hovered links
*/
function handleMouseLeave(event, linkClass, linkHoverClass) {
const el = event.target;
el.classList.remove(linkHoverClass);
// Remove hover effect from previous siblings that are also links
let sibling = el.previousElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.remove(linkHoverClass);
sibling = sibling.previousElementSibling;
}
// Remove hover effect from next siblings that are also links
sibling = el.nextElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.remove(linkHoverClass);
sibling = sibling.nextElementSibling;
}
}
/**
* Updates the CSS class on the editor wrapper based on Cmd/Ctrl key state
* @param {Event} event - The keyboard event
* @param {HTMLElement} editorWrapper - The editor wrapper element
* @param {string} cmdCtrlClass - CSS class name for Cmd/Ctrl pressed state
* @param {Function} isCmdOrCtrlPressed - Function to check if Cmd/Ctrl is pressed
*/
function updateCmdCtrlClass(event, editorWrapper, cmdCtrlClass, isCmdOrCtrlPressed) {
if (isCmdOrCtrlPressed(event)) {
editorWrapper.classList.add(cmdCtrlClass);
} else {
editorWrapper.classList.remove(cmdCtrlClass);
}
}
/**
* Handles click events on links to open them externally
* @param {Event} event - The click event
* @param {string} linkClass - CSS class name for links
* @param {Function} isCmdOrCtrlPressed - Function to check if Cmd/Ctrl is pressed
*/
function handleClick(event, linkClass, isCmdOrCtrlPressed) {
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);
}
}
}
/**
* Sets up link awareness for a CodeMirror editor instance.
* This enables automatic URL detection, styling, and click-to-open functionality.
* @param {Object} editor - The CodeMirror editor instance
* @param {Object} options - Configuration options (currently unused but reserved for future use)
* @returns {void}
*/
function setupLinkAware(editor, options = {}) {
if (!editor) {
return;
}
// CSS class names and configuration
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';
// Helper function to check if Cmd/Ctrl is pressed
const isCmdOrCtrlPressed = (event) => (isMacOS() ? event.metaKey : event.ctrlKey);
// Initialize LinkifyIt for URL detection
const linkify = new LinkifyIt();
const editorWrapper = editor.getWrapperElement();
// Create bound versions of event handlers with proper parameters
const boundMarkUrls = () => markUrls(editor, linkify, linkClass, linkHint);
const boundUpdateCmdCtrlClass = (event) => updateCmdCtrlClass(event, editorWrapper, cmdCtrlClass, isCmdOrCtrlPressed);
const boundHandleClick = (event) => handleClick(event, linkClass, isCmdOrCtrlPressed);
const boundHandleMouseEnter = (event) => handleMouseEnter(event, linkClass, linkHoverClass, boundUpdateCmdCtrlClass);
const boundHandleMouseLeave = (event) => handleMouseLeave(event, linkClass, linkHoverClass);
// Create debounced version of markUrls
const debouncedMarkUrls = debounce(() => {
requestAnimationFrame(boundMarkUrls);
}, 150);
// Initial URL marking
boundMarkUrls();
// Set up event listeners
editor.on('changes', debouncedMarkUrls);
window.addEventListener('keydown', boundUpdateCmdCtrlClass);
window.addEventListener('keyup', boundUpdateCmdCtrlClass);
editorWrapper.addEventListener('click', boundHandleClick);
editorWrapper.addEventListener('mouseover', boundHandleMouseEnter);
editorWrapper.addEventListener('mouseout', boundHandleMouseLeave);
// Cleanup function to remove all event listeners
editor._destroyLinkAware = () => {
editor.off('changes', debouncedMarkUrls);
window.removeEventListener('keydown', boundUpdateCmdCtrlClass);
window.removeEventListener('keyup', boundUpdateCmdCtrlClass);
editorWrapper.removeEventListener('click', boundHandleClick);
editorWrapper.removeEventListener('mouseover', boundHandleMouseEnter);
editorWrapper.removeEventListener('mouseout', boundHandleMouseLeave);
};
}
export { setupLinkAware };

View File

@@ -0,0 +1,591 @@
import { setupLinkAware } from './linkAware';
import LinkifyIt from 'linkify-it';
import { isMacOS } from 'utils/common/platform';
// No need to mock CodeMirror since setupLinkAware works with an existing editor
// 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('setupLinkAware', () => {
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
mockWrapperElement = {
classList: {
add: jest.fn(),
remove: jest.fn()
},
addEventListener: jest.fn(),
removeEventListener: jest.fn()
};
mockMark = {
clear: jest.fn(),
className: 'CodeMirror-link'
};
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(),
_destroyLinkAware: undefined
};
mockLinkify = {
match: jest.fn().mockReturnValue([
{ index: 10, lastIndex: 28, url: 'https://example.com' },
{ index: 33, lastIndex: 48, url: 'http://test.org' }
])
};
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 setup and configuration', () => {
it('should set up link awareness on an existing editor', () => {
setupLinkAware(mockEditor);
expect(mockEditor.getWrapperElement).toHaveBeenCalled();
expect(mockEditor.on).toHaveBeenCalledWith('changes', expect.any(Function));
expect(mockWrapperElement.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
expect(mockWrapperElement.addEventListener).toHaveBeenCalledWith('mouseover', expect.any(Function));
expect(mockWrapperElement.addEventListener).toHaveBeenCalledWith('mouseout', expect.any(Function));
});
it('should accept options parameter', () => {
const options = { someOption: true };
setupLinkAware(mockEditor, options);
expect(mockEditor.getWrapperElement).toHaveBeenCalled();
});
it('should return early if editor is null', () => {
const result = setupLinkAware(null);
expect(result).toBeUndefined();
expect(mockEditor.getWrapperElement).not.toHaveBeenCalled();
});
it('should add _destroyLinkAware method to editor', () => {
setupLinkAware(mockEditor);
expect(mockEditor._destroyLinkAware).toBeInstanceOf(Function);
});
});
describe('platform-specific behavior', () => {
it('should use Cmd key hint on macOS', () => {
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
// Verify that markUrls was called which sets the hint
expect(mockEditor.markText).toHaveBeenCalledWith(expect.anything(),
expect.anything(),
expect.objectContaining({
attributes: expect.objectContaining({
title: 'Hold Cmd and click to open link'
})
}));
});
it('should use Ctrl key hint on non-macOS', () => {
isMacOS.mockReturnValue(false);
setupLinkAware(mockEditor);
// Verify that markUrls was called which sets the hint
expect(mockEditor.markText).toHaveBeenCalledWith(expect.anything(),
expect.anything(),
expect.objectContaining({
attributes: expect.objectContaining({
title: 'Hold Ctrl and click to open link'
})
}));
});
});
describe('CSS class management', () => {
it('should add cmd-ctrl-pressed class when modifier key is pressed', () => {
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
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);
setupLinkAware(mockEditor);
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);
setupLinkAware(mockEditor);
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', () => {
setupLinkAware(mockEditor);
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);
setupLinkAware(mockEditor);
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);
setupLinkAware(mockEditor);
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', () => {
setupLinkAware(mockEditor);
// Clear the calls from initial setup
mockEditor.getAllMarks.mockClear();
requestAnimationFrame.mockClear();
// Simulate multiple rapid content changes
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
changeHandler();
changeHandler();
changeHandler();
// With debouncing, setTimeout should be called (lodash debounce uses it internally)
// The exact number may vary, but we should see at least one call
expect(setTimeout).toHaveBeenCalled();
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 150);
// Fast-forward timers
jest.runAllTimers();
// Should only mark URLs once despite multiple rapid changes
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
expect(mockEditor.getAllMarks).toHaveBeenCalledTimes(1);
});
it('should apply link tooltips when marking URLs', () => {
setupLinkAware(mockEditor);
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', () => {
setupLinkAware(mockEditor);
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', () => {
setupLinkAware(mockEditor);
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', () => {
setupLinkAware(mockEditor);
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', () => {
setupLinkAware(mockEditor);
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', () => {
setupLinkAware(mockEditor);
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', () => {
setupLinkAware(mockEditor);
mockEditor._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', () => {
setupLinkAware(mockEditor);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseover')[1];
const mockEvent = { target: null };
// Note: This will throw as the implementation accesses target.classList without null check
expect(() => mouseoverHandler(mockEvent)).toThrow();
});
it('should handle missing ipcRenderer', () => {
delete global.window.ipcRenderer;
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
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(() => setupLinkAware(mockEditor)).not.toThrow();
// markText may still be called to clear existing marks
});
it('should handle null siblings in mouseover events', () => {
setupLinkAware(mockEditor);
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', () => {
setupLinkAware(mockEditor);
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)
});