vite-plugin-source-locator 1.0.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,11 +42,11 @@ No `main.tsx` wiring required. The plugin auto-injects the client overlay in dev
42
42
 
43
43
  ## Pick Mode
44
44
 
45
- 1. Click the badge (bottom-right): **Source Locator (cursor) — click to pick**
45
+ 1. Click the badge (bottom-right): **Source Locator (auto) — click to pick**
46
46
  2. Hover elements — blue highlight + file paths in tooltip
47
47
  3. Click to open source — cycles **TSX → CSS → TSX** when both exist
48
48
  4. **Esc** — cancel pick mode
49
- 5. **Shift+L** — cycle IDE: cursor vscode webstorm
49
+ 5. **Shift+L** — cycle IDE (session only, resets on refresh)
50
50
 
51
51
  ## Exports
52
52
 
@@ -63,7 +63,7 @@ sourceLocator({
63
63
  enabled: true,
64
64
  endpoint: '/__open-in-editor',
65
65
  attribute: 'data-source',
66
- ides: ['cursor', 'vscode', 'webstorm'],
66
+ ides: ['auto', 'cursor', 'vscode', 'webstorm'],
67
67
  theme: 'light',
68
68
  })
69
69
  ```
@@ -89,7 +89,7 @@ import { initSourceLocator } from 'vite-plugin-source-locator/client'
89
89
  initSourceLocator({
90
90
  endpoint: '/__open-in-editor',
91
91
  attribute: 'data-source',
92
- ides: ['cursor', 'vscode', 'webstorm'],
92
+ ides: ['auto', 'cursor', 'vscode', 'webstorm'],
93
93
  theme: {
94
94
  background: '#ffffff',
95
95
  text: '#000000',
@@ -132,19 +132,60 @@ import { initSourceLocator } from 'vite-plugin-source-locator/client'
132
132
  initSourceLocator({
133
133
  endpoint: '/__open-in-editor',
134
134
  attribute: 'data-source',
135
- ides: ['cursor', 'vscode', 'webstorm'],
135
+ ides: ['auto', 'cursor', 'vscode', 'webstorm'],
136
136
  theme: 'blue',
137
137
  })
138
138
  ```
139
139
 
140
+ ## IDE Setup
141
+
142
+ IDE opening works on **macOS, Windows, and Linux** via [`launch-editor`](https://github.com/vitejs/launch-editor).
143
+
144
+ ### Auto detection (default)
145
+
146
+ By default, `ides` includes `'auto'` as the first entry. In `auto` mode, the plugin detects your open IDE:
147
+
148
+ 1. `LAUNCH_EDITOR` / `REACT_EDITOR` environment variable
149
+ 2. Running editor process (VS Code, Cursor, WebStorm, etc.)
150
+ 3. `VISUAL` / `EDITOR` environment variable
151
+
152
+ No CLI on `PATH` is required if the editor is already running.
153
+
154
+ ```typescript
155
+ // auto-detect open IDE (default)
156
+ sourceLocator()
157
+
158
+ // VS Code only
159
+ sourceLocator({ ides: ['vscode'] })
160
+
161
+ // auto + manual override with Shift+L (session only)
162
+ sourceLocator({ ides: ['auto', 'vscode'] })
163
+ ```
164
+
165
+ The first entry in `ides` is the default. Shift+L cycles through the list in memory (resets on page refresh).
166
+
167
+ ### Explicit editor
168
+
169
+ When not using `auto`, the editor CLI should be on your `PATH`:
170
+
171
+ | IDE | CLI command |
172
+ |-----|-------------|
173
+ | Cursor | `cursor` |
174
+ | VS Code | `code` |
175
+ | WebStorm | `webstorm` |
176
+
177
+ Override with environment variables:
178
+
179
+ - `LAUNCH_EDITOR=code`
180
+ - `REACT_EDITOR=code`
181
+
140
182
  ## Adding a New IDE
141
183
 
142
184
  1. Extend `LocatorIde` and `IDE_ORDER` in `src/shared/index.ts`
143
- 2. Add entry to `EDITORS` in `src/vite/editors.ts`
185
+ 2. Use a [launch-editor supported editor name](https://github.com/vitejs/launch-editor#supported-editors) as the new `LocatorIde` value
144
186
 
145
187
  ## Limitations
146
188
 
147
- - macOS only for IDE opening
148
189
  - Dev only — no production impact
149
190
  - JSX/TSX only for `data-source` injection
150
191
  - CSS line 1 only (no source-map line mapping yet)
@@ -1,20 +1,24 @@
1
- import { nextClickTarget, parseSourceLocation, resolveTheme } from '../shared/index.js';
1
+ import { nextClickTarget, nextIde, parseSourceLocation, resolveTheme } from '../shared/index.js';
2
2
  import { findCssSource } from './css-source.js';
3
3
  import { createLocatorOverlayUi } from './overlay.js';
4
- import { cycleStoredIde, getStoredIde } from './preference.js';
4
+ const EMPTY_SOURCES = Object.freeze({
5
+ tsx: undefined,
6
+ css: undefined,
7
+ clickTarget: 'tsx',
8
+ });
5
9
  function getSourceEl(target, attribute, host) {
6
10
  if (!target || host.contains(target) || target === host)
7
11
  return null;
8
12
  const el = target.closest(`[${attribute}]`);
9
13
  return el instanceof HTMLElement ? el : null;
10
14
  }
11
- async function openSourceInEditor(source, config) {
15
+ async function openSourceInEditor(source, config, activeIde) {
12
16
  const loc = parseSourceLocation(source);
13
17
  const params = new URLSearchParams({
14
18
  file: loc.file,
15
19
  line: loc.line,
16
20
  col: loc.col,
17
- ide: getStoredIde(),
21
+ ide: activeIde,
18
22
  });
19
23
  await fetch(`${config.endpoint}?${params.toString()}`);
20
24
  }
@@ -24,29 +28,30 @@ function readElementSources(el, attribute) {
24
28
  return { tsx, css, clickTarget: 'tsx' };
25
29
  }
26
30
  function resolveOpenSource(sources) {
27
- if (sources.clickTarget === 'css')
28
- return sources.css;
29
- return sources.tsx;
31
+ return sources[sources.clickTarget];
30
32
  }
31
33
  export function startPickController(root, host, config) {
32
34
  let pickMode = false;
33
- let sources = { tsx: undefined, css: undefined, clickTarget: 'tsx' };
34
- const ui = createLocatorOverlayUi(root, () => setPickMode(!pickMode), resolveTheme(config.theme));
35
+ let sources = EMPTY_SOURCES;
36
+ let activeIde = config.ides[0] ?? 'auto';
37
+ const ui = createLocatorOverlayUi(root, () => setPickMode(!pickMode), resolveTheme(config.theme), () => activeIde);
35
38
  function setPickMode(active) {
36
39
  pickMode = active;
37
40
  ui.setPickActive(active);
38
41
  if (!active)
39
- sources = { tsx: undefined, css: undefined, clickTarget: 'tsx' };
42
+ sources = EMPTY_SOURCES;
40
43
  }
44
+ const syncSources = (el) => {
45
+ if (el !== ui.getActiveEl())
46
+ sources = readElementSources(el, config.attribute);
47
+ };
41
48
  const updateHover = (target, x, y) => {
42
49
  const el = getSourceEl(target, config.attribute, host);
43
50
  if (!el) {
44
51
  ui.removeTooltip();
45
52
  return;
46
53
  }
47
- if (el !== ui.getActiveEl()) {
48
- sources = readElementSources(el, config.attribute);
49
- }
54
+ syncSources(el);
50
55
  ui.showSourceTooltip(el, sources.tsx, sources.css, sources.clickTarget, x, y);
51
56
  };
52
57
  const onMouseMove = (e) => {
@@ -63,10 +68,10 @@ export function startPickController(root, host, config) {
63
68
  }
64
69
  if (!e.shiftKey || e.key !== 'L')
65
70
  return;
66
- const next = cycleStoredIde(config.ides);
71
+ activeIde = nextIde(activeIde, config.ides);
67
72
  if (!pickMode)
68
73
  ui.refreshBadgeLabel();
69
- ui.flashMessage(`IDE: ${next}`);
74
+ ui.flashMessage(`IDE: ${activeIde}`);
70
75
  };
71
76
  const onClick = async (e) => {
72
77
  if (!pickMode || host.contains(e.target))
@@ -74,19 +79,15 @@ export function startPickController(root, host, config) {
74
79
  e.preventDefault();
75
80
  e.stopPropagation();
76
81
  const el = getSourceEl(e.target, config.attribute, host);
77
- if (!el) {
78
- ui.flashMessage('No source for this element');
79
- return;
80
- }
81
- if (el !== ui.getActiveEl())
82
- sources = readElementSources(el, config.attribute);
83
- const openSource = resolveOpenSource(sources);
84
- if (!openSource) {
82
+ if (el)
83
+ syncSources(el);
84
+ const openSource = el && resolveOpenSource(sources);
85
+ if (!el || !openSource) {
85
86
  ui.flashMessage('No source for this element');
86
87
  return;
87
88
  }
88
- await openSourceInEditor(openSource, config);
89
- sources.clickTarget = nextClickTarget(sources.clickTarget, !!sources.css);
89
+ await openSourceInEditor(openSource, config, activeIde);
90
+ sources = { ...sources, clickTarget: nextClickTarget(sources.clickTarget, !!sources.css) };
90
91
  ui.showSourceTooltip(el, sources.tsx, sources.css, sources.clickTarget, e.clientX, e.clientY);
91
92
  };
92
93
  document.addEventListener('keydown', onKeyDown);
@@ -1,14 +1,17 @@
1
1
  import { formatSourceLocation } from '../shared/index.js';
2
+ const SELECTOR_ID_BONUS = 100;
3
+ const SELECTOR_CLASS_BONUS = 50;
4
+ const SELECTOR_CLASS_MATCH_BONUS = 200;
2
5
  function scoreSelector(selector, element) {
3
6
  let score = selector.length;
4
7
  if (selector.includes('#'))
5
- score += 100;
8
+ score += SELECTOR_ID_BONUS;
6
9
  if (selector.includes('.'))
7
- score += 50;
10
+ score += SELECTOR_CLASS_BONUS;
8
11
  if (element instanceof HTMLElement) {
9
12
  element.classList.forEach((cls) => {
10
13
  if (selector.includes(`.${cls}`))
11
- score += 200;
14
+ score += SELECTOR_CLASS_MATCH_BONUS;
12
15
  });
13
16
  }
14
17
  return score;
@@ -0,0 +1,40 @@
1
+ export declare const UI_IDS: {
2
+ readonly badge: "source-locator-badge";
3
+ readonly tooltip: "source-locator-tooltip";
4
+ readonly highlight: "source-locator-highlight";
5
+ };
6
+ export declare const LAYOUT: {
7
+ readonly badge: {
8
+ readonly position: "fixed";
9
+ readonly bottom: "12px";
10
+ readonly right: "12px";
11
+ readonly padding: "8px 12px";
12
+ readonly borderRadius: "999px";
13
+ readonly fontSize: "11px";
14
+ readonly fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace";
15
+ readonly zIndex: "99999";
16
+ readonly cursor: "pointer";
17
+ readonly boxShadow: "0 4px 16px rgba(0, 0, 0, 0.35)";
18
+ };
19
+ readonly tooltip: {
20
+ readonly position: "fixed";
21
+ readonly padding: "8px 12px";
22
+ readonly borderRadius: "8px";
23
+ readonly fontSize: "12px";
24
+ readonly fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace";
25
+ readonly zIndex: "99999";
26
+ readonly pointerEvents: "none";
27
+ readonly boxShadow: "0 8px 24px rgba(0, 0, 0, 0.45)";
28
+ readonly maxWidth: "420px";
29
+ readonly whiteSpace: "pre";
30
+ readonly lineHeight: "1.5";
31
+ };
32
+ readonly highlight: {
33
+ readonly position: "fixed";
34
+ readonly borderWidth: "2px";
35
+ readonly borderStyle: "solid";
36
+ readonly borderRadius: "4px";
37
+ readonly pointerEvents: "none";
38
+ readonly zIndex: "99998";
39
+ };
40
+ };
@@ -0,0 +1,40 @@
1
+ export const UI_IDS = {
2
+ badge: 'source-locator-badge',
3
+ tooltip: 'source-locator-tooltip',
4
+ highlight: 'source-locator-highlight',
5
+ };
6
+ export const LAYOUT = {
7
+ badge: {
8
+ position: 'fixed',
9
+ bottom: '12px',
10
+ right: '12px',
11
+ padding: '8px 12px',
12
+ borderRadius: '999px',
13
+ fontSize: '11px',
14
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
15
+ zIndex: '99999',
16
+ cursor: 'pointer',
17
+ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.35)',
18
+ },
19
+ tooltip: {
20
+ position: 'fixed',
21
+ padding: '8px 12px',
22
+ borderRadius: '8px',
23
+ fontSize: '12px',
24
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
25
+ zIndex: '99999',
26
+ pointerEvents: 'none',
27
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.45)',
28
+ maxWidth: '420px',
29
+ whiteSpace: 'pre',
30
+ lineHeight: '1.5',
31
+ },
32
+ highlight: {
33
+ position: 'fixed',
34
+ borderWidth: '2px',
35
+ borderStyle: 'solid',
36
+ borderRadius: '4px',
37
+ pointerEvents: 'none',
38
+ zIndex: '99998',
39
+ },
40
+ };
@@ -1,5 +1,5 @@
1
- import type { ClickTarget, LocatorTheme } from '../shared/index.js';
2
- export declare function createLocatorOverlayUi(root: ShadowRoot, onTogglePick: () => void, theme: LocatorTheme): {
1
+ import type { ClickTarget, LocatorIde, LocatorTheme } from '../shared/index.js';
2
+ export declare function createLocatorOverlayUi(root: ShadowRoot, onTogglePick: () => void, theme: LocatorTheme, getActiveIde: () => LocatorIde): {
3
3
  mountBadge: () => void;
4
4
  setPickActive: (active: boolean) => void;
5
5
  refreshBadgeLabel: () => void;
@@ -1,73 +1,12 @@
1
- import { parseSourceLocation } from '../shared/index.js';
1
+ import { LAYOUT, UI_IDS } from './overlay-styles.js';
2
2
  import { badgeLabel } from './preference.js';
3
- const UI_IDS = {
4
- badge: 'source-locator-badge',
5
- tooltip: 'source-locator-tooltip',
6
- highlight: 'source-locator-highlight',
7
- };
8
- const LAYOUT = {
9
- badge: {
10
- position: 'fixed',
11
- bottom: '12px',
12
- right: '12px',
13
- padding: '8px 12px',
14
- borderRadius: '999px',
15
- fontSize: '11px',
16
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
17
- zIndex: '99999',
18
- cursor: 'pointer',
19
- boxShadow: '0 4px 16px rgba(0, 0, 0, 0.35)',
20
- },
21
- tooltip: {
22
- position: 'fixed',
23
- padding: '8px 12px',
24
- borderRadius: '8px',
25
- fontSize: '12px',
26
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
27
- zIndex: '99999',
28
- pointerEvents: 'none',
29
- boxShadow: '0 8px 24px rgba(0, 0, 0, 0.45)',
30
- maxWidth: '420px',
31
- whiteSpace: 'pre',
32
- lineHeight: '1.5',
33
- },
34
- highlight: {
35
- position: 'fixed',
36
- borderWidth: '2px',
37
- borderStyle: 'solid',
38
- borderRadius: '4px',
39
- pointerEvents: 'none',
40
- zIndex: '99998',
41
- },
42
- };
43
- function formatSourceLabel(source, prefix) {
44
- const { file, line } = parseSourceLocation(source);
45
- const name = file.split('/').pop() ?? file;
46
- const label = line !== '1' ? `${name}:${line}` : name;
47
- if (prefix)
48
- return `${prefix}: ${label}`;
49
- return label;
50
- }
51
- function buildTooltipText(tsxSource, cssSource, clickTarget) {
52
- const lines = [];
53
- if (tsxSource)
54
- lines.push(formatSourceLabel(tsxSource, 'TSX'));
55
- if (cssSource)
56
- lines.push(formatSourceLabel(cssSource, 'CSS'));
57
- if (!cssSource) {
58
- lines.push('Click → open TSX');
59
- return lines.join('\n');
60
- }
61
- if (clickTarget === 'tsx') {
62
- lines.push('Click → open TSX');
63
- lines.push('Click again → open CSS');
64
- return lines.join('\n');
65
- }
66
- lines.push('Click → open CSS');
67
- lines.push('Click again → open TSX');
68
- return lines.join('\n');
69
- }
70
- export function createLocatorOverlayUi(root, onTogglePick, theme) {
3
+ import { buildTooltipText } from './tooltip-text.js';
4
+ const HIGHLIGHT_PADDING = 2;
5
+ const TOOLTIP_CURSOR_OFFSET = 16;
6
+ const FLASH_DURATION_MS = 1500;
7
+ const FLASH_HORIZONTAL_OFFSET = 80;
8
+ const FLASH_BOTTOM_OFFSET = 80;
9
+ export function createLocatorOverlayUi(root, onTogglePick, theme, getActiveIde) {
71
10
  let activeEl = null;
72
11
  let flashTimeout = null;
73
12
  let badgeEl = null;
@@ -83,10 +22,10 @@ export function createLocatorOverlayUi(root, onTogglePick, theme) {
83
22
  const highlight = document.createElement('div');
84
23
  highlight.id = UI_IDS.highlight;
85
24
  Object.assign(highlight.style, LAYOUT.highlight, {
86
- top: `${rect.top - 2}px`,
87
- left: `${rect.left - 2}px`,
88
- width: `${rect.width + 4}px`,
89
- height: `${rect.height + 4}px`,
25
+ top: `${rect.top - HIGHLIGHT_PADDING}px`,
26
+ left: `${rect.left - HIGHLIGHT_PADDING}px`,
27
+ width: `${rect.width + HIGHLIGHT_PADDING * 2}px`,
28
+ height: `${rect.height + HIGHLIGHT_PADDING * 2}px`,
90
29
  borderColor: theme.highlightBorder,
91
30
  background: theme.highlightBackground,
92
31
  boxShadow: `0 0 0 1px ${theme.highlightShadow}`,
@@ -99,8 +38,8 @@ export function createLocatorOverlayUi(root, onTogglePick, theme) {
99
38
  tooltip.id = UI_IDS.tooltip;
100
39
  tooltip.textContent = text;
101
40
  Object.assign(tooltip.style, LAYOUT.tooltip, {
102
- top: `${y + 16}px`,
103
- left: `${x + 16}px`,
41
+ top: `${y + TOOLTIP_CURSOR_OFFSET}px`,
42
+ left: `${x + TOOLTIP_CURSOR_OFFSET}px`,
104
43
  background: theme.tooltipBackground,
105
44
  color: theme.tooltipText,
106
45
  border: `1px solid ${theme.tooltipBorder}`,
@@ -116,8 +55,10 @@ export function createLocatorOverlayUi(root, onTogglePick, theme) {
116
55
  const flashMessage = (text) => {
117
56
  if (flashTimeout)
118
57
  clearTimeout(flashTimeout);
119
- showTooltip(text, null, window.innerWidth / 2 - 80, window.innerHeight - 80);
120
- flashTimeout = setTimeout(removeTooltip, 1500);
58
+ const x = window.innerWidth / 2 - FLASH_HORIZONTAL_OFFSET;
59
+ const y = window.innerHeight - FLASH_BOTTOM_OFFSET;
60
+ showTooltip(text, null, x, y);
61
+ flashTimeout = setTimeout(removeTooltip, FLASH_DURATION_MS);
121
62
  };
122
63
  const applyBadgeColors = (active) => {
123
64
  if (!badgeEl)
@@ -129,20 +70,20 @@ export function createLocatorOverlayUi(root, onTogglePick, theme) {
129
70
  document.body.style.cursor = active ? 'crosshair' : '';
130
71
  if (!badgeEl)
131
72
  return;
132
- badgeEl.textContent = badgeLabel(active);
73
+ badgeEl.textContent = badgeLabel(active, getActiveIde());
133
74
  applyBadgeColors(active);
134
75
  if (!active)
135
76
  removeTooltip();
136
77
  };
137
78
  const refreshBadgeLabel = () => {
138
79
  if (badgeEl)
139
- badgeEl.textContent = badgeLabel(false);
80
+ badgeEl.textContent = badgeLabel(false, getActiveIde());
140
81
  };
141
82
  const mountBadge = () => {
142
83
  badgeEl = document.createElement('button');
143
84
  badgeEl.id = UI_IDS.badge;
144
85
  badgeEl.type = 'button';
145
- badgeEl.textContent = badgeLabel(false);
86
+ badgeEl.textContent = badgeLabel(false, getActiveIde());
146
87
  Object.assign(badgeEl.style, LAYOUT.badge, {
147
88
  background: theme.badgeBackground,
148
89
  color: theme.badgeText,
@@ -1,5 +1,2 @@
1
1
  import type { LocatorIde } from '../shared/index.js';
2
- export declare function getStoredIde(): LocatorIde;
3
- export declare function setStoredIde(ide: LocatorIde): void;
4
- export declare function cycleStoredIde(order: LocatorIde[]): LocatorIde;
5
- export declare function badgeLabel(picking: boolean): string;
2
+ export declare function badgeLabel(picking: boolean, activeIde: LocatorIde): string;
@@ -1,19 +1,7 @@
1
- import { DEFAULT_IDE, STORAGE_KEY, isLocatorIde, nextIde } from '../shared/index.js';
2
- export function getStoredIde() {
3
- const stored = localStorage.getItem(STORAGE_KEY) ?? DEFAULT_IDE;
4
- return isLocatorIde(stored) ? stored : DEFAULT_IDE;
5
- }
6
- export function setStoredIde(ide) {
7
- localStorage.setItem(STORAGE_KEY, ide);
8
- }
9
- export function cycleStoredIde(order) {
10
- const next = nextIde(getStoredIde(), order);
11
- setStoredIde(next);
12
- return next;
13
- }
14
- export function badgeLabel(picking) {
15
- const ide = getStoredIde();
1
+ const BADGE_PICKING_HINT = 'Esc to cancel';
2
+ const BADGE_IDLE_HINT = 'click to pick';
3
+ export function badgeLabel(picking, activeIde) {
16
4
  if (picking)
17
- return `Pick element (${ide}) — Esc to cancel`;
18
- return `Source Locator (${ide}) — click to pick`;
5
+ return `Pick element (${activeIde}) — ${BADGE_PICKING_HINT}`;
6
+ return `Source Locator (${activeIde}) — ${BADGE_IDLE_HINT}`;
19
7
  }
@@ -0,0 +1,11 @@
1
+ import type { LocatorTheme } from '../shared/index.js';
2
+ export type SourcePanelPayload = {
3
+ file: string;
4
+ line: string;
5
+ col: string;
6
+ content: string;
7
+ };
8
+ export declare function createSourcePanel(root: ShadowRoot, theme: LocatorTheme): {
9
+ showSourcePanel: (payload: SourcePanelPayload) => void;
10
+ removePanel: () => void | undefined;
11
+ };
@@ -0,0 +1,113 @@
1
+ const PANEL_ID = 'source-locator-panel';
2
+ function fileBasename(file) {
3
+ return file.split('/').pop() ?? file;
4
+ }
5
+ function escapeHtml(text) {
6
+ return text
7
+ .replace(/&/g, '&')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;');
10
+ }
11
+ export function createSourcePanel(root, theme) {
12
+ const removePanel = () => root.getElementById(PANEL_ID)?.remove();
13
+ const showSourcePanel = (payload) => {
14
+ removePanel();
15
+ const lineNumber = Math.max(1, Number.parseInt(payload.line, 10) || 1);
16
+ const lines = payload.content.split('\n');
17
+ const panel = document.createElement('div');
18
+ panel.id = PANEL_ID;
19
+ Object.assign(panel.style, {
20
+ position: 'fixed',
21
+ left: '0',
22
+ right: '0',
23
+ bottom: '0',
24
+ height: '40vh',
25
+ minHeight: '200px',
26
+ maxHeight: '480px',
27
+ display: 'flex',
28
+ flexDirection: 'column',
29
+ background: theme.tooltipBackground,
30
+ borderTop: `2px solid ${theme.tooltipBorder}`,
31
+ boxShadow: '0 -8px 32px rgba(0, 0, 0, 0.45)',
32
+ zIndex: '100000',
33
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
34
+ fontSize: '12px',
35
+ });
36
+ const header = document.createElement('div');
37
+ Object.assign(header.style, {
38
+ display: 'flex',
39
+ alignItems: 'center',
40
+ justifyContent: 'space-between',
41
+ padding: '8px 12px',
42
+ borderBottom: `1px solid ${theme.tooltipBorder}`,
43
+ color: theme.tooltipText,
44
+ flexShrink: '0',
45
+ });
46
+ const title = document.createElement('span');
47
+ title.textContent = `${fileBasename(payload.file)}:${payload.line}:${payload.col}`;
48
+ const closeBtn = document.createElement('button');
49
+ closeBtn.type = 'button';
50
+ closeBtn.textContent = '×';
51
+ closeBtn.setAttribute('aria-label', 'Close source panel');
52
+ Object.assign(closeBtn.style, {
53
+ background: 'transparent',
54
+ border: 'none',
55
+ color: theme.badgeText,
56
+ fontSize: '20px',
57
+ lineHeight: '1',
58
+ cursor: 'pointer',
59
+ padding: '0 4px',
60
+ });
61
+ closeBtn.addEventListener('click', removePanel);
62
+ header.appendChild(title);
63
+ header.appendChild(closeBtn);
64
+ const body = document.createElement('div');
65
+ Object.assign(body.style, {
66
+ flex: '1',
67
+ overflow: 'auto',
68
+ margin: '0',
69
+ });
70
+ const pre = document.createElement('pre');
71
+ Object.assign(pre.style, {
72
+ margin: '0',
73
+ padding: '8px 0',
74
+ color: theme.tooltipText,
75
+ });
76
+ const highlightedLineId = 'source-locator-panel-highlight';
77
+ lines.forEach((lineText, index) => {
78
+ const lineEl = document.createElement('div');
79
+ const lineNum = index + 1;
80
+ const isTarget = lineNum === lineNumber;
81
+ Object.assign(lineEl.style, {
82
+ display: 'flex',
83
+ padding: '0 12px',
84
+ background: isTarget ? theme.highlightBackground : 'transparent',
85
+ borderLeft: isTarget ? `3px solid ${theme.highlightBorder}` : '3px solid transparent',
86
+ });
87
+ if (isTarget)
88
+ lineEl.id = highlightedLineId;
89
+ const num = document.createElement('span');
90
+ num.textContent = String(lineNum).padStart(4, ' ');
91
+ Object.assign(num.style, {
92
+ flexShrink: '0',
93
+ width: '48px',
94
+ color: theme.badgeText,
95
+ opacity: '0.7',
96
+ userSelect: 'none',
97
+ });
98
+ const code = document.createElement('span');
99
+ code.innerHTML = escapeHtml(lineText) || ' ';
100
+ lineEl.appendChild(num);
101
+ lineEl.appendChild(code);
102
+ pre.appendChild(lineEl);
103
+ });
104
+ body.appendChild(pre);
105
+ panel.appendChild(header);
106
+ panel.appendChild(body);
107
+ root.appendChild(panel);
108
+ requestAnimationFrame(() => {
109
+ root.getElementById(highlightedLineId)?.scrollIntoView({ block: 'center' });
110
+ });
111
+ };
112
+ return { showSourcePanel, removePanel };
113
+ }
@@ -0,0 +1,2 @@
1
+ import type { ClickTarget } from '../shared/index.js';
2
+ export declare function buildTooltipText(tsxSource: string | undefined, cssSource: string | undefined, clickTarget: ClickTarget): string;
@@ -0,0 +1,28 @@
1
+ import { parseSourceLocation } from '../shared/index.js';
2
+ const DEFAULT_LINE = '1';
3
+ const CLICK_PROMPT_ORDER = {
4
+ tsx: ['TSX', 'CSS'],
5
+ css: ['CSS', 'TSX'],
6
+ };
7
+ function formatSourceLabel(source, prefix) {
8
+ const { file, line } = parseSourceLocation(source);
9
+ const name = file.split('/').pop() ?? file;
10
+ const label = line !== DEFAULT_LINE ? `${name}:${line}` : name;
11
+ if (prefix)
12
+ return `${prefix}: ${label}`;
13
+ return label;
14
+ }
15
+ export function buildTooltipText(tsxSource, cssSource, clickTarget) {
16
+ const lines = [];
17
+ if (tsxSource)
18
+ lines.push(formatSourceLabel(tsxSource, 'TSX'));
19
+ if (cssSource)
20
+ lines.push(formatSourceLabel(cssSource, 'CSS'));
21
+ if (!cssSource) {
22
+ lines.push('Click → open TSX');
23
+ return lines.join('\n');
24
+ }
25
+ const [first, second] = CLICK_PROMPT_ORDER[clickTarget];
26
+ lines.push(`Click → open ${first}`, `Click again → open ${second}`);
27
+ return lines.join('\n');
28
+ }
@@ -1,6 +1,6 @@
1
1
  export type { LocatorTheme, LocatorThemeInput, LocatorThemeOverride, LocatorThemePreset, } from './theme.js';
2
2
  export { resolveTheme } from './theme.js';
3
- export type LocatorIde = 'cursor' | 'vscode' | 'webstorm';
3
+ export type LocatorIde = 'auto' | 'cursor' | 'vscode' | 'webstorm';
4
4
  export type SourceLocation = {
5
5
  file: string;
6
6
  line: string;
@@ -9,10 +9,10 @@ export type SourceLocation = {
9
9
  export type ClickTarget = 'tsx' | 'css';
10
10
  export declare const SOURCE_ATTR = "data-source";
11
11
  export declare const OPEN_ENDPOINT = "/__open-in-editor";
12
- export declare const STORAGE_KEY = "locator-ide";
13
12
  export declare const DEFAULT_IDE: LocatorIde;
14
13
  export declare const IDE_ORDER: LocatorIde[];
15
14
  export declare function isLocatorIde(value: string): value is LocatorIde;
15
+ export declare function resolveIde(value: string, allowed: LocatorIde[]): LocatorIde;
16
16
  export declare function nextIde(current: LocatorIde, order?: LocatorIde[]): LocatorIde;
17
17
  export declare function parseSourceLocation(raw: string): SourceLocation;
18
18
  export declare function formatSourceLocation(loc: SourceLocation): string;
@@ -1,12 +1,19 @@
1
1
  export { resolveTheme } from './theme.js';
2
2
  export const SOURCE_ATTR = 'data-source';
3
3
  export const OPEN_ENDPOINT = '/__open-in-editor';
4
- export const STORAGE_KEY = 'locator-ide';
5
- export const DEFAULT_IDE = 'cursor';
6
- export const IDE_ORDER = ['cursor', 'vscode', 'webstorm'];
4
+ export const DEFAULT_IDE = 'auto';
5
+ export const IDE_ORDER = ['auto', 'cursor', 'vscode', 'webstorm'];
7
6
  export function isLocatorIde(value) {
8
7
  return IDE_ORDER.includes(value);
9
8
  }
9
+ export function resolveIde(value, allowed) {
10
+ const fallback = allowed[0] ?? DEFAULT_IDE;
11
+ if (!isLocatorIde(value))
12
+ return fallback;
13
+ if (!allowed.includes(value))
14
+ return fallback;
15
+ return value;
16
+ }
10
17
  export function nextIde(current, order = IDE_ORDER) {
11
18
  const index = order.indexOf(current);
12
19
  const nextIndex = (index + 1) % order.length;
@@ -14,16 +21,21 @@ export function nextIde(current, order = IDE_ORDER) {
14
21
  }
15
22
  export function parseSourceLocation(raw) {
16
23
  const parts = raw.split(':');
17
- const col = parts.pop();
18
- const line = parts.pop();
19
- const file = parts.join(':');
20
- return { file, line, col };
24
+ if (parts.length < 3)
25
+ return { file: raw, line: '1', col: '1' };
26
+ const col = parts.pop() ?? '1';
27
+ const line = parts.pop() ?? '1';
28
+ return { file: parts.join(':'), line, col };
21
29
  }
22
30
  export function formatSourceLocation(loc) {
23
31
  return `${loc.file}:${loc.line}:${loc.col}`;
24
32
  }
33
+ const CLICK_TARGET_TRANSITION = {
34
+ tsx: 'css',
35
+ css: 'tsx',
36
+ };
25
37
  export function nextClickTarget(current, hasCss) {
26
38
  if (!hasCss)
27
39
  return 'tsx';
28
- return current === 'tsx' ? 'css' : 'tsx';
40
+ return CLICK_TARGET_TRANSITION[current];
29
41
  }
@@ -4,10 +4,14 @@ const PRESET_COLORS = {
4
4
  dark: { background: '#000000', text: '#ffffff', accent: '#a3a3a3' },
5
5
  blue: { background: '#1e3a8a', text: '#eff6ff', accent: '#60a5fa' },
6
6
  };
7
+ const HEX_COLOR_LENGTH = 7;
8
+ const RGB_CHANNEL_MAX = 255;
9
+ const HIGHLIGHT_ALPHA = 0.12;
10
+ const SHADOW_ALPHA = 0.8;
7
11
  function withAlpha(hex, alpha) {
8
- if (!hex.startsWith('#') || hex.length !== 7)
12
+ if (!hex.startsWith('#') || hex.length !== HEX_COLOR_LENGTH)
9
13
  return hex;
10
- const channel = Math.round(alpha * 255)
14
+ const channel = Math.round(alpha * RGB_CHANNEL_MAX)
11
15
  .toString(16)
12
16
  .padStart(2, '0');
13
17
  return `${hex}${channel}`;
@@ -24,19 +28,15 @@ function buildTheme(colors) {
24
28
  tooltipText: text,
25
29
  tooltipBorder: accent,
26
30
  highlightBorder: accent,
27
- highlightBackground: withAlpha(accent, 0.12),
28
- highlightShadow: withAlpha(background, 0.8),
31
+ highlightBackground: withAlpha(accent, HIGHLIGHT_ALPHA),
32
+ highlightShadow: withAlpha(background, SHADOW_ALPHA),
29
33
  };
30
34
  }
31
- function isThemePreset(value) {
32
- return typeof value === 'string';
33
- }
34
35
  export function resolveTheme(input) {
36
+ const base = PRESET_COLORS.default;
35
37
  if (!input)
36
- return buildTheme(PRESET_COLORS.default);
37
- if (isThemePreset(input)) {
38
- const preset = PRESET_COLORS[input] ?? PRESET_COLORS.default;
39
- return buildTheme(preset);
40
- }
41
- return buildTheme({ ...PRESET_COLORS.default, ...input });
38
+ return buildTheme(base);
39
+ if (typeof input === 'string')
40
+ return buildTheme(PRESET_COLORS[input] ?? base);
41
+ return buildTheme({ ...base, ...input });
42
42
  }
@@ -3,16 +3,17 @@ import * as t from '@babel/types';
3
3
  type BabelOptions = {
4
4
  attribute: string;
5
5
  };
6
+ type BabelState = {
7
+ file: {
8
+ opts: {
9
+ filename?: string;
10
+ };
11
+ };
12
+ };
6
13
  export declare function babelPluginAddSourceAttr(_: unknown, opts: BabelOptions): {
7
14
  name: string;
8
15
  visitor: {
9
- JSXOpeningElement(path: NodePath<t.JSXOpeningElement>, state: {
10
- file: {
11
- opts: {
12
- filename?: string;
13
- };
14
- };
15
- }): void;
16
+ JSXOpeningElement(path: NodePath<t.JSXOpeningElement>, state: BabelState): void;
16
17
  };
17
18
  };
18
19
  export {};
@@ -1,4 +1,5 @@
1
1
  import * as t from '@babel/types';
2
+ const COLUMN_OFFSET = 1;
2
3
  function hasSourceAttr(attributes, attribute) {
3
4
  return attributes.some((attr) => t.isJSXAttribute(attr) && attr.name.name === attribute);
4
5
  }
@@ -17,7 +18,8 @@ export function babelPluginAddSourceAttr(_, opts) {
17
18
  return;
18
19
  if (hasSourceAttr(path.node.attributes, attribute))
19
20
  return;
20
- const value = `${filename}:${loc.start.line}:${loc.start.column + 1}`;
21
+ // Babel columns are 0-indexed; editors expect 1-indexed columns.
22
+ const value = `${filename}:${loc.start.line}:${loc.start.column + COLUMN_OFFSET}`;
21
23
  path.node.attributes.push(createSourceAttr(attribute, value));
22
24
  },
23
25
  },
@@ -1,2 +1,2 @@
1
- import type { SourceLocation } from '../shared/index.js';
2
- export declare function openInEditor(loc: SourceLocation, ideParam: string): void;
1
+ import type { LocatorIde, SourceLocation } from '../shared/index.js';
2
+ export declare function openInEditor(loc: SourceLocation, ideParam: string, allowed: LocatorIde[]): void;
@@ -1,64 +1,11 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { DEFAULT_IDE, isLocatorIde } from '../shared/index.js';
4
- function createVscodeForkEditor(id, cliCandidates) {
5
- return {
6
- id,
7
- cliCandidates,
8
- openWithCli(cli, loc) {
9
- execFileSync(cli, ['-r', '-g', `${loc.file}:${loc.line}:${loc.col}`], { stdio: 'ignore' });
10
- },
11
- openWithUrl(loc) {
12
- execFileSync('open', [`${id}://file/${loc.file}:${loc.line}:${loc.col}`], { stdio: 'ignore' });
13
- },
14
- };
15
- }
16
- const EDITORS = [
17
- createVscodeForkEditor('cursor', [
18
- '/Applications/Cursor.app/Contents/Resources/app/bin/cursor',
19
- 'cursor',
20
- ]),
21
- createVscodeForkEditor('vscode', [
22
- '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
23
- 'code',
24
- ]),
25
- {
26
- id: 'webstorm',
27
- cliCandidates: ['/Applications/WebStorm.app/Contents/MacOS/webstorm', 'webstorm'],
28
- openWithCli(cli, loc) {
29
- execFileSync(cli, ['--line', loc.line, loc.file], { stdio: 'ignore' });
30
- },
31
- openWithUrl(loc) {
32
- const uri = `webstorm://open?file=${encodeURIComponent(loc.file)}&line=${loc.line}`;
33
- execFileSync('open', [uri], { stdio: 'ignore' });
34
- },
35
- },
36
- ];
37
- function canRun(command) {
38
- try {
39
- execFileSync('which', [command], { stdio: 'ignore' });
40
- return true;
41
- }
42
- catch {
43
- return false;
44
- }
45
- }
46
- function resolveCli(candidates) {
47
- const appPath = candidates.find((path) => path.includes('/') && existsSync(path));
48
- if (appPath)
49
- return appPath;
50
- return candidates.filter((path) => !path.includes('/')).find((path) => canRun(path)) ?? null;
51
- }
52
- function findEditor(ide) {
53
- return EDITORS.find((entry) => entry.id === ide) ?? EDITORS[0];
54
- }
55
- export function openInEditor(loc, ideParam) {
56
- const ide = isLocatorIde(ideParam) ? ideParam : DEFAULT_IDE;
57
- const editor = findEditor(ide);
58
- const cli = resolveCli(editor.cliCandidates);
59
- if (cli) {
60
- editor.openWithCli(cli, loc);
1
+ import launch from 'launch-editor';
2
+ import { resolveIde } from '../shared/index.js';
3
+ export function openInEditor(loc, ideParam, allowed) {
4
+ const ide = resolveIde(ideParam, allowed);
5
+ const spec = `${loc.file}:${loc.line}:${loc.col}`;
6
+ if (ide === 'auto') {
7
+ launch(spec);
61
8
  return;
62
9
  }
63
- editor.openWithUrl(loc);
10
+ launch(spec, ide);
64
11
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { DEFAULT_IDE, IDE_ORDER, OPEN_ENDPOINT, SOURCE_ATTR } from '../shared/index.js';
4
+ import { IDE_ORDER, OPEN_ENDPOINT, SOURCE_ATTR } from '../shared/index.js';
5
5
  import { babelPluginAddSourceAttr } from './babel-plugin.js';
6
6
  import { openInEditor } from './editors.js';
7
7
  const VIRTUAL_CLIENT_ID = 'virtual:source-locator-client';
@@ -16,18 +16,19 @@ function resolveOptions(options = {}) {
16
16
  theme: options.theme,
17
17
  };
18
18
  }
19
- function readQuery(url) {
19
+ function readQuery(url, defaultIde) {
20
20
  const parsed = new URL(url, 'http://localhost');
21
21
  return {
22
22
  file: parsed.searchParams.get('file'),
23
23
  line: parsed.searchParams.get('line') ?? '1',
24
24
  col: parsed.searchParams.get('col') ?? '1',
25
- ide: parsed.searchParams.get('ide') ?? DEFAULT_IDE,
25
+ ide: parsed.searchParams.get('ide') ?? defaultIde,
26
26
  };
27
27
  }
28
28
  function resolveFilePath(file, root) {
29
- if (file.startsWith('/src/'))
30
- return resolve(root, file.slice(1));
29
+ const viteDevMatch = file.match(/^\/src\/(.+)$/);
30
+ if (viteDevMatch)
31
+ return resolve(root, 'src', viteDevMatch[1]);
31
32
  if (isAbsolute(file))
32
33
  return file;
33
34
  return resolve(root, file);
@@ -55,7 +56,7 @@ function sourceLocator(options = {}) {
55
56
  },
56
57
  configureServer(server) {
57
58
  server.middlewares.use(config.endpoint, (req, res) => {
58
- const { file, line, col, ide } = readQuery(req.url ?? '');
59
+ const { file, line, col, ide } = readQuery(req.url ?? '', config.ides[0] ?? 'auto');
59
60
  if (!file) {
60
61
  res.writeHead(400);
61
62
  res.end('missing file');
@@ -68,7 +69,7 @@ function sourceLocator(options = {}) {
68
69
  res.end('file not found');
69
70
  return;
70
71
  }
71
- openInEditor({ file: resolvedFile, line, col }, ide);
72
+ openInEditor({ file: resolvedFile, line, col }, ide, config.ides);
72
73
  res.writeHead(200, { 'Content-Type': 'text/plain' });
73
74
  res.end('ok');
74
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-source-locator",
3
- "version": "1.0.0",
3
+ "version": "1.1.2",
4
4
  "description": "Dev-only Vite plugin: click UI elements to jump to source in your IDE",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,10 +25,11 @@
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.build.json",
28
- "clean": "rm -rf dist",
28
+ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
29
+ "lint": "eslint .",
29
30
  "test": "vitest run",
30
31
  "test:watch": "vitest",
31
- "prepublishOnly": "npm run build && npm run test"
32
+ "prepublishOnly": "npm run lint && npm run build && npm run test"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "vite": "^5.0.0"
@@ -36,14 +37,19 @@
36
37
  "dependencies": {
37
38
  "@babel/core": "^7.29.7",
38
39
  "@babel/traverse": "^7.29.7",
39
- "@babel/types": "^7.29.7"
40
+ "@babel/types": "^7.29.7",
41
+ "launch-editor": "^2.14.1"
40
42
  },
41
43
  "devDependencies": {
44
+ "@eslint/js": "^9.39.4",
42
45
  "@types/babel__core": "^7.20.5",
43
46
  "@types/babel__traverse": "^7.20.7",
44
47
  "@types/node": "^20.12.12",
48
+ "eslint": "^9.39.4",
49
+ "globals": "^15.15.0",
45
50
  "happy-dom": "^15.11.7",
46
51
  "typescript": "^5.4.5",
52
+ "typescript-eslint": "^8.62.0",
47
53
  "vite": "^5.2.11",
48
54
  "vitest": "^2.1.8"
49
55
  },