From 460832f3ed0617d04274df47658de4d8ae32bd78 Mon Sep 17 00:00:00 2001 From: Arun Bansal <37215457+abansal21@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:45:11 +0000 Subject: [PATCH] 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 --- package-lock.json | 16 + packages/bruno-app/package.json | 1 + .../src/components/CodeEditor/index.js | 6 +- .../src/components/MultiLineEditor/index.js | 10 +- .../RequestPane/QueryEditor/index.js | 6 +- .../src/components/SingleLineEditor/index.js | 8 +- packages/bruno-app/src/globalStyles.js | 8 + .../codemirror/makeLinkAwareCodeMirror.js | 134 ++++ .../makeLinkAwareCodeMirror.spec.js | 652 ++++++++++++++++++ packages/bruno-electron/src/preload.js | 5 +- 10 files changed, 838 insertions(+), 8 deletions(-) create mode 100644 packages/bruno-app/src/utils/codemirror/makeLinkAwareCodeMirror.js create mode 100644 packages/bruno-app/src/utils/codemirror/makeLinkAwareCodeMirror.spec.js diff --git a/package-lock.json b/package-lock.json index 4ef0ae308..2e473bf84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index 1959a15dc..8353b6074 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -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", diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 003e5e739..310f2c1ef 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -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; diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index af3a77d19..05af5231e 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -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; diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js index decc7bd1d..89d1b3b1d 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js @@ -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); diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index 5fbd71789..ad72d3dfc 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -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(); diff --git a/packages/bruno-app/src/globalStyles.js b/packages/bruno-app/src/globalStyles.js index 6e7a7cc4a..ce16e4016 100644 --- a/packages/bruno-app/src/globalStyles.js +++ b/packages/bruno-app/src/globalStyles.js @@ -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; diff --git a/packages/bruno-app/src/utils/codemirror/makeLinkAwareCodeMirror.js b/packages/bruno-app/src/utils/codemirror/makeLinkAwareCodeMirror.js new file mode 100644 index 000000000..9bcf2134d --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/makeLinkAwareCodeMirror.js @@ -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; +} diff --git a/packages/bruno-app/src/utils/codemirror/makeLinkAwareCodeMirror.spec.js b/packages/bruno-app/src/utils/codemirror/makeLinkAwareCodeMirror.spec.js new file mode 100644 index 000000000..0ffabb724 --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/makeLinkAwareCodeMirror.spec.js @@ -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(); + }); + }); +}); diff --git a/packages/bruno-electron/src/preload.js b/packages/bruno-electron/src/preload.js index d89a2dab6..49c9c1b8d 100644 --- a/packages/bruno-electron/src/preload.js +++ b/packages/bruno-electron/src/preload.js @@ -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) });