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..4b036aa8a 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 { 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; diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index af3a77d19..150040285 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 { 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; diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js index decc7bd1d..ddb359109 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 { 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); diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index 5fbd71789..7718ed28e 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -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(); 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/linkAware.js b/packages/bruno-app/src/utils/codemirror/linkAware.js new file mode 100644 index 000000000..c7aa694c3 --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/linkAware.js @@ -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 }; diff --git a/packages/bruno-app/src/utils/codemirror/linkAware.spec.js b/packages/bruno-app/src/utils/codemirror/linkAware.spec.js new file mode 100644 index 000000000..565033d8b --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/linkAware.spec.js @@ -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(); + }); + }); +}); 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) });