Skip to content

fix(vscode-preview): enable left-click on external links in diagram preview#2908

Merged
davydkov merged 1 commit into
mainfrom
fix/left-click-links-vscode-2422
Apr 25, 2026
Merged

fix(vscode-preview): enable left-click on external links in diagram preview#2908
davydkov merged 1 commit into
mainfrom
fix/left-click-links-vscode-2422

Conversation

@ckeller42

@ckeller42 ckeller42 commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fix left-click on external links in the VSCode extension diagram preview. Previously, only right-click opened links in the browser. Now left-click works too.

Ref: #2422 (comment)

Root Cause

The click event listener was registered in the bubble phase (default). React 18+ attaches event listeners to the React root node, and diagram overlay components call stopPropagation() on click events. This prevents the native DOM event from reaching the document-level link interceptor.

Right-click (auxclick) wasn't affected because React doesn't handle non-primary button clicks.

Fix

  1. Capture phase for the click listener (addEventListener('click', handler, true)). This fires before React's event system, ensuring the interceptor always sees clicks on links.

  2. Interactive element guard — skips clicks on <button>, [role="button"], <input>, <select>, <textarea> inside links. Prevents the copy button in link badges from triggering URL navigation.

  3. Defense-in-depth preventDefault() — when an interactive element is inside an external <a href>, the interceptor calls preventDefault() to suppress anchor navigation even though it doesn't open the URL. This prevents the VSCode webview blank-page bug regardless of whether the interactive element's own handler calls preventDefault(). The interceptor owns "no blank-page navigation" — components shouldn't need webview-awareness.

  4. stopPropagation() after intercepting — prevents the captured click from also triggering diagram interactions (node selection, panning).

Files changed

File Change
packages/vscode-preview/src/interceptExternalLinks.ts Capture phase + interactive guard + defense-in-depth preventDefault
packages/vscode-preview/src/interceptExternalLinks.spec.ts 20 tests (6 new: per-selector interactive guard, preventDefault on interactive-inside-link, button-not-inside-link, stopPropagation, capture phase wiring)

Test plan

  • Unit tests: 20 passed (6 new tests covering capture phase, interactive guard per selector, defense-in-depth preventDefault)
  • Manual: Tested with combined VSIX (this PR + PR fix(vscode): include all runtime deps in extension package #2910 packaging fix merged locally into test/vscode-combined branch, built via pnpm package-vsix, installed in VSCode, opened monaco_aml_lidar project)
  • Manual: Left-click link in element details Properties tab → opens in browser
  • Manual: Right-click link → still opens in browser (regression check)
  • Manual: Click copy icon inside link badge → copies URL, does NOT navigate
  • CI passes

Manual testing instructions

# To test both this PR and the packaging fix (#2910) together:
git checkout fix/vscode-extension-packaging
git checkout -b test/vscode-combined
git merge fix/left-click-links-vscode-2422 --no-edit
pnpm build
cd packages/vscode && pnpm package-vsix
code --install-extension likec4.vsix --force

# Then in VSCode:
# 1. Reload Window (Ctrl+Shift+P → Developer: Reload Window)
# 2. Open a LikeC4 workspace with elements that have links
# 3. Ctrl+Shift+P → "LikeC4: Open Preview"
# 4. Click element → Properties tab → LEFT-CLICK a link
# 5. Verify: link opens in browser, no blank page
# 6. Click copy icon next to link → should copy, NOT navigate

🤖 Generated with Claude Code

@ckeller42 ckeller42 requested a review from davydkov April 16, 2026 07:38
@ckeller42

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@changeset-bot

changeset-bot Bot commented Apr 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 62d7d63

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 21 packages
Name Type
@likec4/vscode-preview Patch
likec4-vscode Patch
likec4 Patch
@likec4/docs-astro Patch
@likec4/playground Patch
@likec4/style-preset Patch
@likec4/styles Patch
@likec4/config Patch
@likec4/core Patch
@likec4/diagram Patch
@likec4/generators Patch
@likec4/language-server Patch
@likec4/language-services Patch
@likec4/layouts Patch
@likec4/leanix-bridge Patch
@likec4/log Patch
@likec4/lsp Patch
@likec4/mcp Patch
@likec4/react Patch
@likec4/tsconfig Patch
@likec4/vite-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Fixes VSCode preview external-link handling: left-clicks on external https? links now open via the default browser; clicks originating from interactive elements inside links are not routed to the external opener (but still prevent default navigation); intercepted link clicks call stopPropagation(); the click listener is attached in the capture phase.

Changes

Cohort / File(s) Summary
Release Documentation
.changeset/fix-left-click-links-vscode.md
Adds a changeset marking a patch for @likec4/vscode-preview describing left-click external-link behavior.
Link Interception Implementation
packages/vscode-preview/src/interceptExternalLinks.ts
Adds interactiveSelector and logic to avoid invoking external-open for events originating in interactive controls; calls e.stopPropagation() plus e.preventDefault() for intercepted https? anchors; registers click listener with capture=true and removes with the same flag; auxclick unchanged.
Link Interception Tests
packages/vscode-preview/src/interceptExternalLinks.spec.ts
Expands mocks (adds stopPropagation, listener options tracking), adjusts closest() mock, and adds tests covering interactive-element origins, non-interactive behavior, stopPropagation on intercepted links, and capture vs bubble listener registration/removal.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Document
    participant Handler as interceptExternalLinks
    participant Opener as openExternalUrl
    participant Browser

    User->>Document: left-click on element
    Document->>Handler: capture-phase click event
    alt target inside interactive control
        Handler-->>Document: preventDefault (do not call Opener)
    else target not interactive and anchor is external
        Handler->>Document: preventDefault & stopPropagation
        Handler->>Opener: openExternalUrl(href)
        Opener->>Browser: open URL in default browser
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • davydkov
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: enabling left-click functionality on external links in the VSCode diagram preview, which is the primary objective of this PR.
Description check ✅ Passed The pull request description is comprehensive and well-structured, covering summary, root cause analysis, detailed fix explanation, files changed, and test plan with manual testing instructions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/left-click-links-vscode-2422

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/vscode-preview/src/interceptExternalLinks.spec.ts (1)

91-105: Consider table-driven coverage for all interactive selector cases.

This test currently validates only button. A small parameterized test over [role="button"], input, select, and textarea would better protect the selector contract.

As per coding guidelines, "Aim to cover new features with relevant tests and keep test names descriptive".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vscode-preview/src/interceptExternalLinks.spec.ts` around lines 91 -
105, The test only checks the 'button' case of the interactive selector and
should be converted into a table-driven/parameterized test to cover
'[role="button"]', 'input', 'select', and 'textarea' as well; update the spec
that calls handleExternalLinkClick(mockEvent(target), openUrl) to iterate over
an array of selector-tag pairs (or selector values) and for each stub
mockEvent().closest to return the appropriate element for the interactive
selector and an anchor for 'a[href]', then assert openUrl was not called for
every case using the same mock openUrl; reference the existing test helpers
mockEvent, openUrl and the function under test handleExternalLinkClick and reuse
the selector string 'button, [role="button"], input, select, textarea' to drive
the cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/vscode-preview/src/interceptExternalLinks.spec.ts`:
- Around line 91-105: The test only checks the 'button' case of the interactive
selector and should be converted into a table-driven/parameterized test to cover
'[role="button"]', 'input', 'select', and 'textarea' as well; update the spec
that calls handleExternalLinkClick(mockEvent(target), openUrl) to iterate over
an array of selector-tag pairs (or selector values) and for each stub
mockEvent().closest to return the appropriate element for the interactive
selector and an anchor for 'a[href]', then assert openUrl was not called for
every case using the same mock openUrl; reference the existing test helpers
mockEvent, openUrl and the function under test handleExternalLinkClick and reuse
the selector string 'button, [role="button"], input, select, textarea' to drive
the cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f7c0bf62-290d-4bb8-9da9-4ee21b031db0

📥 Commits

Reviewing files that changed from the base of the PR and between 935f6bb and 204f129.

📒 Files selected for processing (3)
  • .changeset/fix-left-click-links-vscode.md
  • packages/vscode-preview/src/interceptExternalLinks.spec.ts
  • packages/vscode-preview/src/interceptExternalLinks.ts

@ckeller42

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@ckeller42 ckeller42 force-pushed the fix/left-click-links-vscode-2422 branch from 204f129 to b5898e2 Compare April 16, 2026 10:53
@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/vscode-preview/src/interceptExternalLinks.spec.ts (2)

91-111: Make the interactive-element parameterized test validate each selector independently.

At Line 100, the mock currently keys off selector.includes('button') && selector.includes('input'), so each it.each case effectively exercises the same branch. This can miss regressions where one selector is dropped but others remain.

Suggested test-mock refinement
   it.each([
     'button',
     '[role="button"]',
     'input',
     'select',
     'textarea',
   ])('skips interactive element inside link: %s', (tag) => {
     const target = {
       closest: vi.fn((selector: string) => {
-        if (selector.includes('button') && selector.includes('input')) {
+        const selectors = selector.split(',').map(s => s.trim())
+        if (selectors.includes(tag)) {
           return { tagName: tag.toUpperCase() }
         }
         if (selector === 'a[href]') {
           return { getAttribute: () => 'https://example.com' }
         }
         return null
       }),
     }

As per coding guidelines: "Aim to cover new features with relevant tests and keep test names descriptive."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vscode-preview/src/interceptExternalLinks.spec.ts` around lines 91 -
111, The test's mock for Element.closest always triggers the same branch because
it checks for selector.includes('button') && selector.includes('input'), so each
it.each case isn't validated independently; update the mock in the 'skips
interactive element inside link' test so closest examines the exact selector
argument (e.g., compare selector === expectedSelector or check
selector.includes(tag) mapped to tag names) and returns an element with tagName:
tag.toUpperCase() only when the selector matches that specific interactive
selector, otherwise return the anchor for 'a[href]' or null; this ensures
handleExternalLinkClick (and the mockEvent/openUrl expectations) are exercised
per selector case rather than all hitting one branch.

151-153: Tighten listener wiring assertions to catch extra registrations/removals.

Using toContainEqual verifies presence but still passes if unintended extra add/remove calls are introduced. Prefer exact assertions for add/remove subsets.

Suggested assertion tightening
-    expect(calls).toContainEqual({ type: 'click', capture: true, action: 'add' })
-    expect(calls).toContainEqual({ type: 'auxclick', capture: false, action: 'add' })
+    expect(calls.filter(c => c.action === 'add')).toEqual([
+      { type: 'click', capture: true, action: 'add' },
+      { type: 'auxclick', capture: false, action: 'add' },
+    ])

@@
-    expect(calls).toContainEqual({ type: 'click', capture: true, action: 'remove' })
-    expect(calls).toContainEqual({ type: 'auxclick', capture: false, action: 'remove' })
+    expect(calls.filter(c => c.action === 'remove')).toEqual([
+      { type: 'click', capture: true, action: 'remove' },
+      { type: 'auxclick', capture: false, action: 'remove' },
+    ])

Also applies to: 166-167

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vscode-preview/src/interceptExternalLinks.spec.ts` around lines 151
- 153, The current tests use toContainEqual on the `calls` array (e.g., the
expectations checking { type: 'click', capture: true, action: 'add' } and {
type: 'auxclick', capture: false, action: 'add' }) which only asserts presence
and misses extra unwanted add/remove registrations; change these to assert exact
subsets by filtering `calls` by `action` (e.g., const adds = calls.filter(c =>
c.action === 'add')) and then assert the exact array equality with toEqual on
the expected add objects, and do the same for removes (and update the other
occurrences around the second pair of assertions at the similar spot referenced
in the comment), ensuring no extra registrations or removals are present.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/vscode-preview/src/interceptExternalLinks.spec.ts`:
- Around line 91-111: The test's mock for Element.closest always triggers the
same branch because it checks for selector.includes('button') &&
selector.includes('input'), so each it.each case isn't validated independently;
update the mock in the 'skips interactive element inside link' test so closest
examines the exact selector argument (e.g., compare selector ===
expectedSelector or check selector.includes(tag) mapped to tag names) and
returns an element with tagName: tag.toUpperCase() only when the selector
matches that specific interactive selector, otherwise return the anchor for
'a[href]' or null; this ensures handleExternalLinkClick (and the
mockEvent/openUrl expectations) are exercised per selector case rather than all
hitting one branch.
- Around line 151-153: The current tests use toContainEqual on the `calls` array
(e.g., the expectations checking { type: 'click', capture: true, action: 'add' }
and { type: 'auxclick', capture: false, action: 'add' }) which only asserts
presence and misses extra unwanted add/remove registrations; change these to
assert exact subsets by filtering `calls` by `action` (e.g., const adds =
calls.filter(c => c.action === 'add')) and then assert the exact array equality
with toEqual on the expected add objects, and do the same for removes (and
update the other occurrences around the second pair of assertions at the similar
spot referenced in the comment), ensuring no extra registrations or removals are
present.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 20f71901-74ce-4d4f-8658-38d4247a72ad

📥 Commits

Reviewing files that changed from the base of the PR and between 204f129 and b5898e2.

📒 Files selected for processing (3)
  • .changeset/fix-left-click-links-vscode.md
  • packages/vscode-preview/src/interceptExternalLinks.spec.ts
  • packages/vscode-preview/src/interceptExternalLinks.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-left-click-links-vscode.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/vscode-preview/src/interceptExternalLinks.ts

…review

Use capture phase for the click event listener so it fires before React's
event system can stopPropagation(). Without capture, left-clicks on links
inside element details overlays never reach the document-level handler.

Also adds an interactive element guard (button, input, etc.) so clicking
a copy button inside a link badge doesn't trigger URL navigation.

Ref: #2422 (comment)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ckeller42

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@ckeller42 ckeller42 force-pushed the fix/left-click-links-vscode-2422 branch from b5898e2 to 62d7d63 Compare April 17, 2026 09:49
@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/vscode-preview/src/interceptExternalLinks.spec.ts (2)

135-143: Redundant stopPropagation override.

Since mockEvent now already provides stopPropagation: vi.fn() (line 11), the spread-and-override here is unnecessary — you can simplify to just const e = mockEvent(mockAnchor('https://example.com')) and assert on e.stopPropagation directly.

♻️ Proposed simplification
   it('also calls stopPropagation on intercepted links', () => {
-    const e = {
-      ...mockEvent(mockAnchor('https://example.com')),
-      stopPropagation: vi.fn(),
-    } as unknown as MouseEvent
+    const e = mockEvent(mockAnchor('https://example.com'))
     handleExternalLinkClick(e, openUrl)
     expect(e.stopPropagation).toHaveBeenCalled()
     expect(openUrl).toHaveBeenCalledWith('https://example.com')
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vscode-preview/src/interceptExternalLinks.spec.ts` around lines 135
- 143, The test redundantly overrides stopPropagation when mockEvent already
supplies it; update the test in interceptExternalLinks.spec.ts by replacing the
spread override with a direct call to mockEvent: set const e =
mockEvent(mockAnchor('https://example.com')) (cast to MouseEvent if necessary),
then call handleExternalLinkClick(e, openUrl) and keep the assertions
expect(e.stopPropagation).toHaveBeenCalled() and
expect(openUrl).toHaveBeenCalledWith('https://example.com'); remove the {...,
stopPropagation: vi.fn()} spread override.

97-116: Interactive-selector matching is overly loose for button.

The predicate selectors.some(s => s === tag || s.includes(tag)) causes the button case to also match when iterating over a selector list containing [role="button"] (since the substring "button" is present). The test still passes because both branches lead to return { tagName: ... } and the assertions only check that openUrl wasn't called, but the mock's behavior no longer faithfully represents what closest(interactiveSelector) would return for a plain <button> vs. a [role="button"]. Consider matching only when s === tag to keep the mock precise:

♻️ Proposed tightening
-        const selectors = selector.split(',').map(s => s.trim())
-        if (selectors.some(s => s === tag || s.includes(tag))) {
-          return { tagName: tag.toUpperCase() }
-        }
+        const selectors = selector.split(',').map(s => s.trim())
+        if (selectors.includes(tag)) {
+          return { tagName: tag.replace(/\W/g, '').toUpperCase() }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vscode-preview/src/interceptExternalLinks.spec.ts` around lines 97 -
116, The test's mock for Element.closest is too permissive for the 'button'
case: inside the closest callback in interceptExternalLinks.spec.ts (the mock
tied to mockEvent/target used by handleExternalLinkClick and openUrl) change the
selector-match predicate so it only matches when a selector exactly equals the
tag (i.e., use strict equality rather than substring includes) so that
`[role="button"]` does not incorrectly match 'button'; keep the rest of the mock
(returning tagName or the 'a[href]' branch) and leave assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/vscode-preview/src/interceptExternalLinks.spec.ts`:
- Around line 135-143: The test redundantly overrides stopPropagation when
mockEvent already supplies it; update the test in interceptExternalLinks.spec.ts
by replacing the spread override with a direct call to mockEvent: set const e =
mockEvent(mockAnchor('https://example.com')) (cast to MouseEvent if necessary),
then call handleExternalLinkClick(e, openUrl) and keep the assertions
expect(e.stopPropagation).toHaveBeenCalled() and
expect(openUrl).toHaveBeenCalledWith('https://example.com'); remove the {...,
stopPropagation: vi.fn()} spread override.
- Around line 97-116: The test's mock for Element.closest is too permissive for
the 'button' case: inside the closest callback in interceptExternalLinks.spec.ts
(the mock tied to mockEvent/target used by handleExternalLinkClick and openUrl)
change the selector-match predicate so it only matches when a selector exactly
equals the tag (i.e., use strict equality rather than substring includes) so
that `[role="button"]` does not incorrectly match 'button'; keep the rest of the
mock (returning tagName or the 'a[href]' branch) and leave assertions unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fe456b6f-bba8-4d30-be2e-352f5989ef34

📥 Commits

Reviewing files that changed from the base of the PR and between b5898e2 and 62d7d63.

📒 Files selected for processing (3)
  • .changeset/fix-left-click-links-vscode.md
  • packages/vscode-preview/src/interceptExternalLinks.spec.ts
  • packages/vscode-preview/src/interceptExternalLinks.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-left-click-links-vscode.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/vscode-preview/src/interceptExternalLinks.ts

@davydkov davydkov merged commit be04c8d into main Apr 25, 2026
16 checks passed
@davydkov davydkov deleted the fix/left-click-links-vscode-2422 branch April 25, 2026 10:59
@likec4-ci likec4-ci Bot mentioned this pull request Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants