commit
e340d55f7e
7 changed files with 1156 additions and 0 deletions
-
10.babelrc.example
-
221README.md
-
40add-data-locator.cjs
-
577client/dev-click-to-component.tsx
-
20package.json
-
221read.md
-
67server/route.ts
@ -0,0 +1,10 @@ |
|||||
|
{ |
||||
|
"presets": ["next/babel"], |
||||
|
"env": { |
||||
|
"development": { |
||||
|
"plugins": [ |
||||
|
"./add-data-locator.cjs" |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,221 @@ |
|||||
|
# ⚡ Click-to-Component |
||||
|
|
||||
|
A zero-config, high-precision component inspector and **Click-to-Component** developer tool for Next.js and React applications. |
||||
|
|
||||
|
Hover over any UI element on your web page while holding <kbd>Alt</kbd> (or <kbd>Option</kbd>) to highlight component boundaries in real time, and **click to instantly jump to the exact file, line, and column in your IDE** (Antigravity IDE, VS Code, Cursor, WebStorm, Sublime Text, etc.). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ✨ Features |
||||
|
|
||||
|
- 🎯 **Direct IDE Jump**: Opens your active IDE and focuses the exact file and cursor position (`file.tsx:line:col`). |
||||
|
- 🔍 **Live Hover Highlighting**: Holding <kbd>Alt</kbd> outlines the component under your mouse with dynamic bounding box tracking and smooth scroll support. |
||||
|
- 🔀 **Dual Hierarchy Modes**: |
||||
|
- **Outer Component Mode (<kbd>Alt</kbd>)**: Selects the outer component or section container. |
||||
|
- **Inner Leaf Mode (<kbd>Alt</kbd> + <kbd>Ctrl</kbd> / <kbd>Cmd</kbd>)**: Drills down directly to the exact innermost child element (e.g. `<svg>`, `<button>`, `<span>`). |
||||
|
- **Live Dynamic Toggle**: Press or release <kbd>Ctrl</kbd> while hovering to switch between Outer and Inner mode in real time! |
||||
|
- 🏷️ **Smart Component Badge**: Displays the JSX tag name (e.g. `<section>`, `<button>`), short relative file path, line number, and active inspection mode pill (`OUTER` / `INNER`). |
||||
|
- 📱 **Mobile & Tablet Ready**: Floating draggable inspector button with tap-to-inspect on touch devices. |
||||
|
- 🛡️ **Zero Production Overhead**: Completely disabled, tree-shaken, and stripped out in production builds. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🏗️ Architecture & How It Works |
||||
|
|
||||
|
The system operates across three interconnected layers: |
||||
|
|
||||
|
```mermaid |
||||
|
flowchart TD |
||||
|
subgraph BuildTime["1. Build-Time AST Transform"] |
||||
|
A[Babel / Compiler] -->|add-data-locator.cjs| B[Injects data-locator attribute on JSX elements] |
||||
|
end |
||||
|
|
||||
|
subgraph ClientSide["2. Client Runtime"] |
||||
|
B --> C[DevClickToComponent] |
||||
|
C -->|Alt + Hover| D[Draws Emerald Bounding Box for Outer Component] |
||||
|
C -->|Alt + Ctrl + Hover| E[Draws Cyan Bounding Box for Inner Element] |
||||
|
C -->|Alt + Click| F[Sends locator to API] |
||||
|
end |
||||
|
|
||||
|
subgraph ServerSide["3. Local Server API"] |
||||
|
F -->|POST /api/open-in-ide| G[route.ts] |
||||
|
G -->|CLI Command Execution| H[antigravity-ide / code -r -g filepath:line:col] |
||||
|
H --> I[IDE Opens & Focuses Line] |
||||
|
end |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📁 Repository Structure |
||||
|
|
||||
|
``` |
||||
|
click-to-component/ |
||||
|
├── add-data-locator.cjs # Babel AST plugin to inject data-locator attributes |
||||
|
├── .babelrc.example # Babel configuration example |
||||
|
├── client/ |
||||
|
│ └── dev-click-to-component.tsx # React Client Component (Inspector UI & Listeners) |
||||
|
├── server/ |
||||
|
│ └── route.ts # Next.js App Router API Route (/api/open-in-ide) |
||||
|
├── package.json # Standalone package metadata |
||||
|
└── README.md # Documentation |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🚀 Quick Setup & Installation |
||||
|
|
||||
|
### Step 1: Install Babel Plugin |
||||
|
|
||||
|
Copy `add-data-locator.cjs` to your project root. |
||||
|
|
||||
|
Create or update `.babelrc` in your project root: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"presets": ["next/babel"], |
||||
|
"env": { |
||||
|
"development": { |
||||
|
"plugins": [ |
||||
|
"./add-data-locator.cjs" |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
> **Note**: Scoping the plugin inside `"env": { "development": ... }` guarantees that no `data-locator` attributes will be added to your production builds. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Step 2: Add the Server API Route |
||||
|
|
||||
|
Create the file `app/api/open-in-ide/route.ts` and copy `server/route.ts` into it: |
||||
|
|
||||
|
```ts |
||||
|
import { NextResponse } from "next/server"; |
||||
|
import { exec } from "child_process"; |
||||
|
import os from "os"; |
||||
|
|
||||
|
export async function POST(req: Request) { |
||||
|
if (process.env.NODE_ENV !== "development") { |
||||
|
return NextResponse.json( |
||||
|
{ message: "Not available in production" }, |
||||
|
{ status: 404 } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const { locator } = await req.json(); |
||||
|
if (!locator) { |
||||
|
return NextResponse.json({ message: "Locator is required" }, { status: 400 }); |
||||
|
} |
||||
|
|
||||
|
const isWindows = os.platform() === "win32"; |
||||
|
const bin = isWindows ? "antigravity-ide.cmd" : "antigravity-ide"; |
||||
|
const cmd = `${bin} -r -g "${locator}"`; |
||||
|
|
||||
|
return new Promise<Response>((resolve) => { |
||||
|
exec(cmd, (error) => { |
||||
|
if (error) { |
||||
|
const fallbackBin = isWindows ? "code.cmd" : "code"; |
||||
|
const fallbackCmd = `${fallbackBin} -r -g "${locator}"`; |
||||
|
|
||||
|
exec(fallbackCmd, (fallbackError) => { |
||||
|
if (fallbackError) { |
||||
|
resolve( |
||||
|
NextResponse.json( |
||||
|
{ message: "Failed to open file in IDE", error: error.message }, |
||||
|
{ status: 500 } |
||||
|
) |
||||
|
); |
||||
|
} else { |
||||
|
resolve(NextResponse.json({ success: true, via: "code" })); |
||||
|
} |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
resolve(NextResponse.json({ success: true, via: "antigravity" })); |
||||
|
}); |
||||
|
}); |
||||
|
} catch (err: unknown) { |
||||
|
const message = err instanceof Error ? err.message : String(err); |
||||
|
return NextResponse.json( |
||||
|
{ message: "Internal server error", error: message }, |
||||
|
{ status: 500 } |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Step 3: Add the Client Component |
||||
|
|
||||
|
Copy `client/dev-click-to-component.tsx` to `app/components/dev-click-to-component.tsx` (or `components/dev-click-to-component.tsx`). |
||||
|
|
||||
|
Include it in your root layout (`app/layout.tsx`): |
||||
|
|
||||
|
```tsx |
||||
|
import { DevClickToComponent } from "./components/dev-click-to-component"; |
||||
|
|
||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) { |
||||
|
return ( |
||||
|
<html lang="en"> |
||||
|
<body> |
||||
|
{children} |
||||
|
{process.env.NODE_ENV === "development" && <DevClickToComponent />} |
||||
|
</body> |
||||
|
</html> |
||||
|
); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🎮 How to Use |
||||
|
|
||||
|
### Desktop Shortcuts |
||||
|
|
||||
|
| Shortcut | Mode | Highlight Color | Target | |
||||
|
| :--- | :--- | :--- | :--- | |
||||
|
| **<kbd>Alt</kbd> + Hover** | **`OUTER`** | 🟩 Emerald Green (`#2dd9a4`) | Outer Component / Section Container | |
||||
|
| **<kbd>Alt</kbd> + <kbd>Ctrl</kbd> + Hover** *(or <kbd>Alt</kbd>+<kbd>Cmd</kbd> on Mac)* | **`INNER`** | 🟦 Electric Cyan (`#38bdf8`) | Innermost leaf element under mouse cursor | |
||||
|
| **<kbd>Alt</kbd> + Click** | **Open** | ⚡ Flash Pulse | Opens the currently highlighted element in your IDE | |
||||
|
|
||||
|
### Mobile / Touch Devices |
||||
|
- A floating draggable button appears on screens $\le 768\text{px}$. |
||||
|
- Tap the button to toggle **Inspect Mode**. |
||||
|
- Tap any element on screen to highlight it and open its source code in the IDE. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 💻 Supported IDEs & Protocol Fallbacks |
||||
|
|
||||
|
When the local CLI is invoked, the system opens your existing active IDE window using `-r -g <filepath>:<line>:<col>`: |
||||
|
|
||||
|
- **Antigravity IDE** (`antigravity-ide -r -g`) |
||||
|
- **VS Code / VS Code Insiders** (`code -r -g`) |
||||
|
- **Cursor** (`cursor -r -g`) |
||||
|
|
||||
|
If the API route is unreachable (e.g. running inside certain sandboxed iframe environments), the client automatically falls back to browser URL protocol schemes: |
||||
|
- `antigravity://file/<locator>` |
||||
|
- `cursor://file/<locator>` |
||||
|
- `vscode://file/<locator>` |
||||
|
- `webstorm://open?file=<locator>` |
||||
|
- `subl://open?url=file://<locator>` |
||||
|
- `atom://open?url=file://<locator>` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🛡️ Production Safety & Performance |
||||
|
|
||||
|
1. **Compiler Level**: In `NODE_ENV === "production"`, `add-data-locator.cjs` returns early without modifying the AST. |
||||
|
2. **Bundle Level**: In `app/layout.tsx`, `{process.env.NODE_ENV === "development" && <DevClickToComponent />}` evaluates to `false` during `next build`, allowing bundlers to completely tree-shake the client component out of the production bundle. |
||||
|
3. **Runtime Level**: Event listeners and rendering are guarded with `process.env.NODE_ENV !== "development"`. |
||||
|
4. **Security Level**: The `/api/open-in-ide` route returns a `404` in production environments, preventing arbitrary command execution. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 License |
||||
|
|
||||
|
MIT |
||||
@ -0,0 +1,40 @@ |
|||||
|
module.exports = function addDataLocator({ types: t }) { |
||||
|
return { |
||||
|
name: "add-data-locator", |
||||
|
visitor: { |
||||
|
JSXOpeningElement(path, state) { |
||||
|
if (process.env.NODE_ENV !== "development") { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const filePath = state.file.opts.filename; |
||||
|
|
||||
|
if (!filePath || filePath.includes("node_modules")) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const attributeExists = path.node.attributes.some( |
||||
|
(attribute) => |
||||
|
t.isJSXAttribute(attribute) && |
||||
|
t.isJSXIdentifier(attribute.name) && |
||||
|
attribute.name.name === "data-locator", |
||||
|
); |
||||
|
|
||||
|
if (attributeExists) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const lineNumber = path.node.loc?.start.line ?? "unknown"; |
||||
|
const columnNumber = path.node.loc?.start.column ?? "unknown"; |
||||
|
const locatorValue = `${filePath}:${lineNumber}:${columnNumber}`; |
||||
|
|
||||
|
path.node.attributes.push( |
||||
|
t.jsxAttribute( |
||||
|
t.jsxIdentifier("data-locator"), |
||||
|
t.stringLiteral(locatorValue), |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
}, |
||||
|
}; |
||||
|
}; |
||||
@ -0,0 +1,577 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { useEffect, useState, useRef, useCallback } from "react"; |
||||
|
|
||||
|
const IDE_SCHEMES = [ |
||||
|
{ |
||||
|
matches: ["antigravity"], |
||||
|
createUrl: (locator: string) => `antigravity://file/${locator}`, |
||||
|
}, |
||||
|
{ |
||||
|
matches: ["cursor"], |
||||
|
createUrl: (locator: string) => `cursor://file/${locator}`, |
||||
|
}, |
||||
|
{ |
||||
|
matches: ["vscode", "code"], |
||||
|
createUrl: (locator: string) => `vscode://file/${locator}`, |
||||
|
}, |
||||
|
{ |
||||
|
matches: ["webstorm", "intellij"], |
||||
|
createUrl: (locator: string) => `webstorm://open?file=${locator}`, |
||||
|
}, |
||||
|
{ |
||||
|
matches: ["sublime"], |
||||
|
createUrl: (locator: string) => `subl://open?url=file://${locator}`, |
||||
|
}, |
||||
|
{ |
||||
|
matches: ["atom", "nova"], |
||||
|
createUrl: (locator: string) => `atom://open?url=file://${locator}`, |
||||
|
}, |
||||
|
] as const; |
||||
|
|
||||
|
function parseLocator(locator: string) { |
||||
|
const match = locator.match(/^(.*):(\d+|unknown):(\d+|unknown)$/); |
||||
|
|
||||
|
if (!match) { |
||||
|
return { filePath: locator, line: null, column: null }; |
||||
|
} |
||||
|
|
||||
|
const [, filePath, line, column] = match; |
||||
|
|
||||
|
return { |
||||
|
filePath, |
||||
|
line: line === "unknown" ? null : Number(line), |
||||
|
column: column === "unknown" ? null : Number(column), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function formatLocatorLabel(locator: string) { |
||||
|
const { filePath, line } = parseLocator(locator); |
||||
|
const parts = filePath.split(/[/\\]/); |
||||
|
const shortPath = parts.slice(-2).join("/"); |
||||
|
const lineInfo = line !== null ? `:${line}` : ""; |
||||
|
return `${shortPath}${lineInfo}`; |
||||
|
} |
||||
|
|
||||
|
function getHierarchy(startEl: HTMLElement | null): HTMLElement[] { |
||||
|
const list: HTMLElement[] = []; |
||||
|
let curr: HTMLElement | null = startEl; |
||||
|
while (curr && curr !== document.body && curr !== document.documentElement) { |
||||
|
if (curr.hasAttribute("data-locator")) { |
||||
|
list.push(curr); |
||||
|
} |
||||
|
curr = curr.parentElement; |
||||
|
} |
||||
|
return list; // [0: innermost, ..., N-1: outermost]
|
||||
|
} |
||||
|
|
||||
|
function selectTargetElement( |
||||
|
hierarchy: HTMLElement[], |
||||
|
isInnerMode: boolean |
||||
|
): HTMLElement | null { |
||||
|
if (hierarchy.length === 0) return null; |
||||
|
if (hierarchy.length === 1) return hierarchy[0]; |
||||
|
|
||||
|
if (isInnerMode) { |
||||
|
// Inner Mode (Alt + Ctrl): target the deep inner leaf element
|
||||
|
return hierarchy[0]; |
||||
|
} |
||||
|
|
||||
|
// Outer Mode (Alt): target the outermost component/section container
|
||||
|
const nonRootPage = hierarchy.filter((el) => { |
||||
|
const loc = el.getAttribute("data-locator") || ""; |
||||
|
return !loc.includes("app/layout.") && !loc.includes("app/page."); |
||||
|
}); |
||||
|
|
||||
|
if (nonRootPage.length > 0) { |
||||
|
return nonRootPage[nonRootPage.length - 1]; |
||||
|
} |
||||
|
|
||||
|
return hierarchy[hierarchy.length - 1]; |
||||
|
} |
||||
|
|
||||
|
interface HoveredTargetState { |
||||
|
rect: DOMRect; |
||||
|
locator: string; |
||||
|
borderRadius: string; |
||||
|
tagName: string; |
||||
|
isInner: boolean; |
||||
|
hasMultipleLevels: boolean; |
||||
|
} |
||||
|
|
||||
|
export function DevClickToComponent() { |
||||
|
const [isInspecting, setIsInspecting] = useState(false); |
||||
|
const [isMobile, setIsMobile] = useState(false); |
||||
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null); |
||||
|
const [isDragging, setIsDragging] = useState(false); |
||||
|
const [hoveredTarget, setHoveredTarget] = useState<HoveredTargetState | null>(null); |
||||
|
const [isOpening, setIsOpening] = useState(false); |
||||
|
|
||||
|
const currentElementRef = useRef<HTMLElement | null>(null); |
||||
|
const lastPointerPos = useRef<{ x: number; y: number } | null>(null); |
||||
|
const dragStart = useRef({ x: 0, y: 0, buttonX: 0, buttonY: 0, hasMoved: false }); |
||||
|
|
||||
|
// Detect mobile / small screen size
|
||||
|
useEffect(() => { |
||||
|
const checkSize = () => { |
||||
|
setIsMobile(window.innerWidth <= 768); |
||||
|
}; |
||||
|
checkSize(); |
||||
|
window.addEventListener("resize", checkSize); |
||||
|
return () => window.removeEventListener("resize", checkSize); |
||||
|
}, []); |
||||
|
|
||||
|
// Keep button in bounds on resize
|
||||
|
useEffect(() => { |
||||
|
const handleResize = () => { |
||||
|
if (!isMobile) return; |
||||
|
setPosition((prev) => { |
||||
|
if (!prev) return null; |
||||
|
return { |
||||
|
x: Math.max(16, Math.min(window.innerWidth - 66, prev.x)), |
||||
|
y: Math.max(16, Math.min(window.innerHeight - 66, prev.y)), |
||||
|
}; |
||||
|
}); |
||||
|
}; |
||||
|
window.addEventListener("resize", handleResize); |
||||
|
return () => window.removeEventListener("resize", handleResize); |
||||
|
}, [isMobile]); |
||||
|
|
||||
|
const openInIde = useCallback(async (locator: string) => { |
||||
|
const userAgent = navigator.userAgent.toLowerCase(); |
||||
|
const { filePath, line, column } = parseLocator(locator); |
||||
|
const positionSuffix = |
||||
|
line === null ? "" : `:${line}${column === null ? "" : `:${column}`}`; |
||||
|
const locatorString = `${filePath}${positionSuffix}`; |
||||
|
|
||||
|
setIsOpening(true); |
||||
|
setTimeout(() => setIsOpening(false), 600); |
||||
|
|
||||
|
try { |
||||
|
await fetch("/api/open-in-ide", { |
||||
|
method: "POST", |
||||
|
headers: { "Content-Type": "application/json" }, |
||||
|
body: JSON.stringify({ locator: locatorString, userAgent }), |
||||
|
}); |
||||
|
} catch (err) { |
||||
|
console.error("[DevClickToComponent] Error sending API request:", err); |
||||
|
|
||||
|
// Fallback to URL scheme
|
||||
|
const ideUrl = |
||||
|
IDE_SCHEMES.find(({ matches }) => |
||||
|
matches.some((match) => userAgent.includes(match)) |
||||
|
)?.createUrl(locatorString) ?? `antigravity://file/${locatorString}`; |
||||
|
|
||||
|
try { |
||||
|
window.location.href = ideUrl; |
||||
|
} catch { |
||||
|
window.open(`file://${filePath}`, "_blank", "noopener,noreferrer"); |
||||
|
} |
||||
|
} |
||||
|
}, []); |
||||
|
|
||||
|
// Desktop: Alt (Outer) vs Alt + Ctrl (Inner) inspection & Click handler
|
||||
|
useEffect(() => { |
||||
|
if (process.env.NODE_ENV !== "development") { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const updateHoverTarget = (el: HTMLElement | null, isInnerMode: boolean) => { |
||||
|
const hierarchy = getHierarchy(el); |
||||
|
const targetEl = selectTargetElement(hierarchy, isInnerMode); |
||||
|
|
||||
|
if (targetEl) { |
||||
|
currentElementRef.current = targetEl; |
||||
|
const rect = targetEl.getBoundingClientRect(); |
||||
|
const computed = window.getComputedStyle(targetEl); |
||||
|
setHoveredTarget({ |
||||
|
rect, |
||||
|
locator: targetEl.getAttribute("data-locator") || "", |
||||
|
borderRadius: computed.borderRadius, |
||||
|
tagName: targetEl.tagName.toLowerCase(), |
||||
|
isInner: isInnerMode || hierarchy.length === 1, |
||||
|
hasMultipleLevels: hierarchy.length > 1, |
||||
|
}); |
||||
|
} else { |
||||
|
currentElementRef.current = null; |
||||
|
setHoveredTarget(null); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handlePointerMove = (e: PointerEvent) => { |
||||
|
lastPointerPos.current = { x: e.clientX, y: e.clientY }; |
||||
|
|
||||
|
if (e.altKey) { |
||||
|
const target = e.target instanceof HTMLElement ? e.target : null; |
||||
|
const isInnerMode = Boolean(e.ctrlKey || e.metaKey); |
||||
|
updateHoverTarget(target, isInnerMode); |
||||
|
} else if (currentElementRef.current) { |
||||
|
currentElementRef.current = null; |
||||
|
setHoveredTarget(null); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleKeyDown = (e: KeyboardEvent) => { |
||||
|
const isAlt = e.altKey || e.key === "Alt"; |
||||
|
const isInnerMode = Boolean( |
||||
|
e.ctrlKey || e.metaKey || e.key === "Control" || e.key === "Meta" |
||||
|
); |
||||
|
|
||||
|
if (isAlt && lastPointerPos.current) { |
||||
|
const el = document.elementFromPoint( |
||||
|
lastPointerPos.current.x, |
||||
|
lastPointerPos.current.y |
||||
|
) as HTMLElement | null; |
||||
|
updateHoverTarget(el, isInnerMode); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleKeyUp = (e: KeyboardEvent) => { |
||||
|
if (e.key === "Alt" || !e.altKey) { |
||||
|
currentElementRef.current = null; |
||||
|
setHoveredTarget(null); |
||||
|
} else if (e.altKey && (e.key === "Control" || e.key === "Meta")) { |
||||
|
// Released Ctrl while keeping Alt pressed -> switch back to Outer Mode
|
||||
|
if (lastPointerPos.current) { |
||||
|
const el = document.elementFromPoint( |
||||
|
lastPointerPos.current.x, |
||||
|
lastPointerPos.current.y |
||||
|
) as HTMLElement | null; |
||||
|
updateHoverTarget(el, false); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleWindowBlur = () => { |
||||
|
currentElementRef.current = null; |
||||
|
setHoveredTarget(null); |
||||
|
}; |
||||
|
|
||||
|
const handleScrollOrResize = () => { |
||||
|
if (currentElementRef.current) { |
||||
|
const rect = currentElementRef.current.getBoundingClientRect(); |
||||
|
const computed = window.getComputedStyle(currentElementRef.current); |
||||
|
setHoveredTarget((prev) => |
||||
|
prev |
||||
|
? { |
||||
|
...prev, |
||||
|
rect, |
||||
|
borderRadius: computed.borderRadius, |
||||
|
} |
||||
|
: null |
||||
|
); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleDesktopClick = (event: MouseEvent) => { |
||||
|
if (!event.altKey) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const target = event.target instanceof HTMLElement ? event.target : null; |
||||
|
const isInnerMode = Boolean(event.ctrlKey || event.metaKey); |
||||
|
const hierarchy = getHierarchy(target); |
||||
|
const targetEl = selectTargetElement(hierarchy, isInnerMode); |
||||
|
const locator = targetEl?.getAttribute("data-locator"); |
||||
|
|
||||
|
if (!locator) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
openInIde(locator); |
||||
|
}; |
||||
|
|
||||
|
window.addEventListener("pointermove", handlePointerMove, true); |
||||
|
window.addEventListener("keydown", handleKeyDown, true); |
||||
|
window.addEventListener("keyup", handleKeyUp, true); |
||||
|
window.addEventListener("blur", handleWindowBlur); |
||||
|
document.addEventListener("mouseleave", handleWindowBlur); |
||||
|
window.addEventListener("scroll", handleScrollOrResize, true); |
||||
|
window.addEventListener("resize", handleScrollOrResize, true); |
||||
|
document.addEventListener("click", handleDesktopClick, true); |
||||
|
|
||||
|
return () => { |
||||
|
window.removeEventListener("pointermove", handlePointerMove, true); |
||||
|
window.removeEventListener("keydown", handleKeyDown, true); |
||||
|
window.removeEventListener("keyup", handleKeyUp, true); |
||||
|
window.removeEventListener("blur", handleWindowBlur); |
||||
|
document.removeEventListener("mouseleave", handleWindowBlur); |
||||
|
window.removeEventListener("scroll", handleScrollOrResize, true); |
||||
|
window.removeEventListener("resize", handleScrollOrResize, true); |
||||
|
document.removeEventListener("click", handleDesktopClick, true); |
||||
|
}; |
||||
|
}, [openInIde]); |
||||
|
|
||||
|
// Mobile: Inspect Mode click interception
|
||||
|
useEffect(() => { |
||||
|
if (process.env.NODE_ENV !== "development" || !isMobile || !isInspecting) return; |
||||
|
|
||||
|
const handleInspectClick = async (event: MouseEvent) => { |
||||
|
const target = event.target; |
||||
|
if (!(target instanceof Element)) return; |
||||
|
|
||||
|
// Ignore clicks on the inspect button itself
|
||||
|
if (target.closest(".dev-inspect-btn")) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
const element = target.closest<HTMLElement>("[data-locator]"); |
||||
|
const locator = element?.getAttribute("data-locator"); |
||||
|
|
||||
|
if (locator && element) { |
||||
|
console.log(`[DevClickToComponent] Inspect Mode matched element. Opening in IDE:`, locator); |
||||
|
|
||||
|
// Flash target element outline briefly as visual feedback
|
||||
|
const originalOutline = element.style.outline; |
||||
|
element.style.outline = "3px solid #2dd9a4"; |
||||
|
setTimeout(() => { |
||||
|
element.style.outline = originalOutline; |
||||
|
}, 400); |
||||
|
|
||||
|
openInIde(locator); |
||||
|
} |
||||
|
|
||||
|
setIsInspecting(false); |
||||
|
}; |
||||
|
|
||||
|
document.addEventListener("click", handleInspectClick, true); |
||||
|
|
||||
|
return () => { |
||||
|
document.removeEventListener("click", handleInspectClick, true); |
||||
|
}; |
||||
|
}, [isMobile, isInspecting, openInIde]); |
||||
|
|
||||
|
// Dragging event handlers for mobile floating button
|
||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => { |
||||
|
e.currentTarget.setPointerCapture(e.pointerId); |
||||
|
setIsDragging(true); |
||||
|
const startX = position?.x ?? window.innerWidth - 66; |
||||
|
const startY = position?.y ?? window.innerHeight - 66; |
||||
|
dragStart.current = { |
||||
|
x: e.clientX, |
||||
|
y: e.clientY, |
||||
|
buttonX: startX, |
||||
|
buttonY: startY, |
||||
|
hasMoved: false, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
const handlePointerMoveButton = (e: React.PointerEvent<HTMLButtonElement>) => { |
||||
|
if (!isDragging) return; |
||||
|
const dx = e.clientX - dragStart.current.x; |
||||
|
const dy = e.clientY - dragStart.current.y; |
||||
|
|
||||
|
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { |
||||
|
dragStart.current.hasMoved = true; |
||||
|
} |
||||
|
|
||||
|
const newX = Math.max(16, Math.min(window.innerWidth - 66, dragStart.current.buttonX + dx)); |
||||
|
const newY = Math.max(16, Math.min(window.innerHeight - 66, dragStart.current.buttonY + dy)); |
||||
|
|
||||
|
setPosition({ x: newX, y: newY }); |
||||
|
}; |
||||
|
|
||||
|
const handlePointerUpButton = (e: React.PointerEvent<HTMLButtonElement>) => { |
||||
|
if (!isDragging) return; |
||||
|
e.currentTarget.releasePointerCapture(e.pointerId); |
||||
|
setIsDragging(false); |
||||
|
}; |
||||
|
|
||||
|
const handleToggleInspect = () => { |
||||
|
if (!dragStart.current.hasMoved) { |
||||
|
setIsInspecting(!isInspecting); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Color scheme: Emerald (#2dd9a4) for Outer, Cyan (#38bdf8) for Inner
|
||||
|
const accentColor = hoveredTarget?.isInner ? "#38bdf8" : "#2dd9a4"; |
||||
|
const accentBg = hoveredTarget?.isInner |
||||
|
? "rgba(56, 189, 248, 0.08)" |
||||
|
: "rgba(45, 217, 164, 0.08)"; |
||||
|
const accentShadow = hoveredTarget?.isInner |
||||
|
? "0 0 14px rgba(56, 189, 248, 0.5), inset 0 0 6px rgba(56, 189, 248, 0.2)" |
||||
|
: "0 0 14px rgba(45, 217, 164, 0.45), inset 0 0 6px rgba(45, 217, 164, 0.15)"; |
||||
|
|
||||
|
if (process.env.NODE_ENV !== "development") { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
{/* Alt (Outer) / Alt + Ctrl (Inner) Highlight Overlay Box */} |
||||
|
{hoveredTarget && ( |
||||
|
<div |
||||
|
style={{ |
||||
|
position: "fixed", |
||||
|
top: `${hoveredTarget.rect.top}px`, |
||||
|
left: `${hoveredTarget.rect.left}px`, |
||||
|
width: `${hoveredTarget.rect.width}px`, |
||||
|
height: `${hoveredTarget.rect.height}px`, |
||||
|
borderRadius: hoveredTarget.borderRadius || "4px", |
||||
|
border: isOpening ? "2px solid #ffffff" : `2px solid ${accentColor}`, |
||||
|
backgroundColor: isOpening ? "rgba(255, 255, 255, 0.2)" : accentBg, |
||||
|
boxShadow: isOpening |
||||
|
? `0 0 22px ${accentColor}, inset 0 0 12px ${accentColor}` |
||||
|
: accentShadow, |
||||
|
pointerEvents: "none", |
||||
|
zIndex: 999999, |
||||
|
transition: |
||||
|
"top 0.06s ease-out, left 0.06s ease-out, width 0.06s ease-out, height 0.06s ease-out, border 0.15s, background-color 0.15s, box-shadow 0.15s", |
||||
|
}} |
||||
|
> |
||||
|
{/* Component Info Tag */} |
||||
|
<div |
||||
|
style={{ |
||||
|
position: "absolute", |
||||
|
top: hoveredTarget.rect.top < 30 ? "6px" : "-28px", |
||||
|
left: Math.max(0, -hoveredTarget.rect.left), |
||||
|
display: "inline-flex", |
||||
|
alignItems: "center", |
||||
|
gap: "6px", |
||||
|
backgroundColor: "#020d09", |
||||
|
color: accentColor, |
||||
|
fontSize: "11px", |
||||
|
fontWeight: 600, |
||||
|
fontFamily: |
||||
|
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", |
||||
|
padding: "3px 8px", |
||||
|
borderRadius: "5px", |
||||
|
whiteSpace: "nowrap", |
||||
|
boxShadow: "0 4px 14px rgba(0,0,0,0.6)", |
||||
|
border: `1px solid ${accentColor}66`, |
||||
|
pointerEvents: "none", |
||||
|
}} |
||||
|
> |
||||
|
{/* Outer / Inner Mode Pill */} |
||||
|
<span |
||||
|
style={{ |
||||
|
backgroundColor: hoveredTarget.isInner |
||||
|
? "rgba(56, 189, 248, 0.25)" |
||||
|
: "rgba(45, 217, 164, 0.2)", |
||||
|
color: accentColor, |
||||
|
padding: "1px 5px", |
||||
|
borderRadius: "3px", |
||||
|
fontSize: "10px", |
||||
|
fontWeight: 700, |
||||
|
letterSpacing: "0.03em", |
||||
|
}} |
||||
|
> |
||||
|
{hoveredTarget.isInner ? "INNER" : "OUTER"} |
||||
|
</span> |
||||
|
|
||||
|
<span style={{ color: "#ffffff", opacity: 0.65 }}> |
||||
|
<{hoveredTarget.tagName}> |
||||
|
</span> |
||||
|
<span>{formatLocatorLabel(hoveredTarget.locator)}</span> |
||||
|
|
||||
|
<span |
||||
|
style={{ |
||||
|
color: "#ffffff", |
||||
|
opacity: 0.5, |
||||
|
fontSize: "10px", |
||||
|
fontWeight: 400, |
||||
|
}} |
||||
|
> |
||||
|
{isOpening |
||||
|
? "⚡ Opening IDE..." |
||||
|
: hoveredTarget.hasMultipleLevels |
||||
|
? hoveredTarget.isInner |
||||
|
? "⌥+Click | Release Ctrl for Outer" |
||||
|
: "⌥+Click | Hold Ctrl for Inner" |
||||
|
: "⌥+Click to open"} |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Floating Action Draggable Button (Inspect Mode for Mobile/Tablet) */} |
||||
|
{isMobile && ( |
||||
|
<div |
||||
|
style={{ |
||||
|
position: "fixed", |
||||
|
left: position ? `${position.x}px` : "auto", |
||||
|
top: position ? `${position.y}px` : "auto", |
||||
|
bottom: position ? "auto" : "16px", |
||||
|
right: position ? "auto" : "16px", |
||||
|
display: "flex", |
||||
|
flexDirection: "column", |
||||
|
gap: "10px", |
||||
|
zIndex: 99998, |
||||
|
touchAction: "none", |
||||
|
}} |
||||
|
> |
||||
|
<button |
||||
|
className="dev-inspect-btn" |
||||
|
onPointerDown={handlePointerDown} |
||||
|
onPointerMove={handlePointerMoveButton} |
||||
|
onPointerUp={handlePointerUpButton} |
||||
|
onClick={handleToggleInspect} |
||||
|
style={{ |
||||
|
width: "50px", |
||||
|
height: "50px", |
||||
|
borderRadius: "25px", |
||||
|
backgroundColor: isInspecting ? "#2dd9a4" : "#020d09", |
||||
|
color: isInspecting ? "#020d09" : "#ffffff", |
||||
|
border: isInspecting |
||||
|
? "2px solid #2dd9a4" |
||||
|
: "1px solid rgba(255,255,255,0.15)", |
||||
|
boxShadow: isInspecting |
||||
|
? "0 0 16px rgba(45, 217, 164, 0.7)" |
||||
|
: "0 4px 14px rgba(0,0,0,0.4)", |
||||
|
display: "flex", |
||||
|
alignItems: "center", |
||||
|
justifyContent: "center", |
||||
|
cursor: isDragging ? "grabbing" : "pointer", |
||||
|
transition: isDragging |
||||
|
? "none" |
||||
|
: "background-color 0.25s, transform 0.25s, box-shadow 0.25s", |
||||
|
outline: "none", |
||||
|
transform: isInspecting ? "scale(1.1)" : "scale(1)", |
||||
|
}} |
||||
|
title="Toggle Dev Inspect Mode (Drag to move, tap to activate)" |
||||
|
> |
||||
|
{isInspecting ? ( |
||||
|
<svg |
||||
|
xmlns="http://www.w3.org/2000/svg" |
||||
|
fill="none" |
||||
|
viewBox="0 0 24 24" |
||||
|
strokeWidth={2.5} |
||||
|
stroke="currentColor" |
||||
|
style={{ width: "22px", height: "22px", pointerEvents: "none" }} |
||||
|
> |
||||
|
<path |
||||
|
strokeLinecap="round" |
||||
|
strokeLinejoin="round" |
||||
|
d="M6 18 18 6M6 6l12 12" |
||||
|
/> |
||||
|
</svg> |
||||
|
) : ( |
||||
|
<svg |
||||
|
xmlns="http://www.w3.org/2000/svg" |
||||
|
fill="none" |
||||
|
viewBox="0 0 24 24" |
||||
|
strokeWidth={2} |
||||
|
stroke="currentColor" |
||||
|
style={{ width: "22px", height: "22px", pointerEvents: "none" }} |
||||
|
> |
||||
|
<path |
||||
|
strokeLinecap="round" |
||||
|
strokeLinejoin="round" |
||||
|
d="m15.75 15.75-2.489-2.489m0 0a3.375 3.375 0 1 0-4.773-4.773 3.375 3.375 0 0 0 4.774 4.774ZM21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" |
||||
|
/> |
||||
|
</svg> |
||||
|
)} |
||||
|
</button> |
||||
|
</div> |
||||
|
)} |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export default DevClickToComponent; |
||||
@ -0,0 +1,20 @@ |
|||||
|
{ |
||||
|
"name": "click-to-component", |
||||
|
"version": "1.0.0", |
||||
|
"description": "Seamless click-to-component and hover inspector for Next.js & React to open source code directly in your IDE.", |
||||
|
"main": "client/dev-click-to-component.tsx", |
||||
|
"keywords": [ |
||||
|
"click-to-component", |
||||
|
"react", |
||||
|
"nextjs", |
||||
|
"inspector", |
||||
|
"devtools", |
||||
|
"developer-experience", |
||||
|
"ide", |
||||
|
"antigravity", |
||||
|
"vscode", |
||||
|
"cursor" |
||||
|
], |
||||
|
"author": "", |
||||
|
"license": "MIT" |
||||
|
} |
||||
@ -0,0 +1,221 @@ |
|||||
|
# ⚡ Click-to-Component |
||||
|
|
||||
|
A zero-config, high-precision component inspector and **Click-to-Component** developer tool for Next.js and React applications. |
||||
|
|
||||
|
Hover over any UI element on your web page while holding <kbd>Alt</kbd> (or <kbd>Option</kbd>) to highlight component boundaries in real time, and **click to instantly jump to the exact file, line, and column in your IDE** (Antigravity IDE, VS Code, Cursor, WebStorm, Sublime Text, etc.). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ✨ Features |
||||
|
|
||||
|
- 🎯 **Direct IDE Jump**: Opens your active IDE and focuses the exact file and cursor position (`file.tsx:line:col`). |
||||
|
- 🔍 **Live Hover Highlighting**: Holding <kbd>Alt</kbd> outlines the component under your mouse with dynamic bounding box tracking and smooth scroll support. |
||||
|
- 🔀 **Dual Hierarchy Modes**: |
||||
|
- **Outer Component Mode (<kbd>Alt</kbd>)**: Selects the outer component or section container. |
||||
|
- **Inner Leaf Mode (<kbd>Alt</kbd> + <kbd>Ctrl</kbd> / <kbd>Cmd</kbd>)**: Drills down directly to the exact innermost child element (e.g. `<svg>`, `<button>`, `<span>`). |
||||
|
- **Live Dynamic Toggle**: Press or release <kbd>Ctrl</kbd> while hovering to switch between Outer and Inner mode in real time! |
||||
|
- 🏷️ **Smart Component Badge**: Displays the JSX tag name (e.g. `<section>`, `<button>`), short relative file path, line number, and active inspection mode pill (`OUTER` / `INNER`). |
||||
|
- 📱 **Mobile & Tablet Ready**: Floating draggable inspector button with tap-to-inspect on touch devices. |
||||
|
- 🛡️ **Zero Production Overhead**: Completely disabled, tree-shaken, and stripped out in production builds. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🏗️ Architecture & How It Works |
||||
|
|
||||
|
The system operates across three interconnected layers: |
||||
|
|
||||
|
```mermaid |
||||
|
flowchart TD |
||||
|
subgraph BuildTime["1. Build-Time AST Transform"] |
||||
|
A[Babel / Compiler] -->|add-data-locator.cjs| B[Injects data-locator attribute on JSX elements] |
||||
|
end |
||||
|
|
||||
|
subgraph ClientSide["2. Client Runtime"] |
||||
|
B --> C[DevClickToComponent] |
||||
|
C -->|Alt + Hover| D[Draws Emerald Bounding Box for Outer Component] |
||||
|
C -->|Alt + Ctrl + Hover| E[Draws Cyan Bounding Box for Inner Element] |
||||
|
C -->|Alt + Click| F[Sends locator to API] |
||||
|
end |
||||
|
|
||||
|
subgraph ServerSide["3. Local Server API"] |
||||
|
F -->|POST /api/open-in-ide| G[route.ts] |
||||
|
G -->|CLI Command Execution| H[antigravity-ide / code -r -g filepath:line:col] |
||||
|
H --> I[IDE Opens & Focuses Line] |
||||
|
end |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📁 Repository Structure |
||||
|
|
||||
|
``` |
||||
|
click-to-component/ |
||||
|
├── add-data-locator.cjs # Babel AST plugin to inject data-locator attributes |
||||
|
├── .babelrc.example # Babel configuration example |
||||
|
├── client/ |
||||
|
│ └── dev-click-to-component.tsx # React Client Component (Inspector UI & Listeners) |
||||
|
├── server/ |
||||
|
│ └── route.ts # Next.js App Router API Route (/api/open-in-ide) |
||||
|
├── package.json # Standalone package metadata |
||||
|
└── README.md # Documentation |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🚀 Quick Setup & Installation |
||||
|
|
||||
|
### Step 1: Install Babel Plugin |
||||
|
|
||||
|
Copy `add-data-locator.cjs` to your project root. |
||||
|
|
||||
|
Create or update `.babelrc` in your project root: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"presets": ["next/babel"], |
||||
|
"env": { |
||||
|
"development": { |
||||
|
"plugins": [ |
||||
|
"./add-data-locator.cjs" |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
> **Note**: Scoping the plugin inside `"env": { "development": ... }` guarantees that no `data-locator` attributes will be added to your production builds. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Step 2: Add the Server API Route |
||||
|
|
||||
|
Create the file `app/api/open-in-ide/route.ts` and copy `server/route.ts` into it: |
||||
|
|
||||
|
```ts |
||||
|
import { NextResponse } from "next/server"; |
||||
|
import { exec } from "child_process"; |
||||
|
import os from "os"; |
||||
|
|
||||
|
export async function POST(req: Request) { |
||||
|
if (process.env.NODE_ENV !== "development") { |
||||
|
return NextResponse.json( |
||||
|
{ message: "Not available in production" }, |
||||
|
{ status: 404 } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const { locator } = await req.json(); |
||||
|
if (!locator) { |
||||
|
return NextResponse.json({ message: "Locator is required" }, { status: 400 }); |
||||
|
} |
||||
|
|
||||
|
const isWindows = os.platform() === "win32"; |
||||
|
const bin = isWindows ? "antigravity-ide.cmd" : "antigravity-ide"; |
||||
|
const cmd = `${bin} -r -g "${locator}"`; |
||||
|
|
||||
|
return new Promise<Response>((resolve) => { |
||||
|
exec(cmd, (error) => { |
||||
|
if (error) { |
||||
|
const fallbackBin = isWindows ? "code.cmd" : "code"; |
||||
|
const fallbackCmd = `${fallbackBin} -r -g "${locator}"`; |
||||
|
|
||||
|
exec(fallbackCmd, (fallbackError) => { |
||||
|
if (fallbackError) { |
||||
|
resolve( |
||||
|
NextResponse.json( |
||||
|
{ message: "Failed to open file in IDE", error: error.message }, |
||||
|
{ status: 500 } |
||||
|
) |
||||
|
); |
||||
|
} else { |
||||
|
resolve(NextResponse.json({ success: true, via: "code" })); |
||||
|
} |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
resolve(NextResponse.json({ success: true, via: "antigravity" })); |
||||
|
}); |
||||
|
}); |
||||
|
} catch (err: unknown) { |
||||
|
const message = err instanceof Error ? err.message : String(err); |
||||
|
return NextResponse.json( |
||||
|
{ message: "Internal server error", error: message }, |
||||
|
{ status: 500 } |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Step 3: Add the Client Component |
||||
|
|
||||
|
Copy `client/dev-click-to-component.tsx` to `app/components/dev-click-to-component.tsx` (or `components/dev-click-to-component.tsx`). |
||||
|
|
||||
|
Include it in your root layout (`app/layout.tsx`): |
||||
|
|
||||
|
```tsx |
||||
|
import { DevClickToComponent } from "./components/dev-click-to-component"; |
||||
|
|
||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) { |
||||
|
return ( |
||||
|
<html lang="en"> |
||||
|
<body> |
||||
|
{children} |
||||
|
{process.env.NODE_ENV === "development" && <DevClickToComponent />} |
||||
|
</body> |
||||
|
</html> |
||||
|
); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🎮 How to Use |
||||
|
|
||||
|
### Desktop Shortcuts |
||||
|
|
||||
|
| Shortcut | Mode | Highlight Color | Target | |
||||
|
| :--- | :--- | :--- | :--- | |
||||
|
| **<kbd>Alt</kbd> + Hover** | **`OUTER`** | 🟩 Emerald Green (`#2dd9a4`) | Outer Component / Section Container | |
||||
|
| **<kbd>Alt</kbd> + <kbd>Ctrl</kbd> + Hover** *(or <kbd>Alt</kbd>+<kbd>Cmd</kbd> on Mac)* | **`INNER`** | 🟦 Electric Cyan (`#38bdf8`) | Innermost leaf element under mouse cursor | |
||||
|
| **<kbd>Alt</kbd> + Click** | **Open** | ⚡ Flash Pulse | Opens the currently highlighted element in your IDE | |
||||
|
|
||||
|
### Mobile / Touch Devices |
||||
|
- A floating draggable button appears on screens $\le 768\text{px}$. |
||||
|
- Tap the button to toggle **Inspect Mode**. |
||||
|
- Tap any element on screen to highlight it and open its source code in the IDE. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 💻 Supported IDEs & Protocol Fallbacks |
||||
|
|
||||
|
When the local CLI is invoked, the system opens your existing active IDE window using `-r -g <filepath>:<line>:<col>`: |
||||
|
|
||||
|
- **Antigravity IDE** (`antigravity-ide -r -g`) |
||||
|
- **VS Code / VS Code Insiders** (`code -r -g`) |
||||
|
- **Cursor** (`cursor -r -g`) |
||||
|
|
||||
|
If the API route is unreachable (e.g. running inside certain sandboxed iframe environments), the client automatically falls back to browser URL protocol schemes: |
||||
|
- `antigravity://file/<locator>` |
||||
|
- `cursor://file/<locator>` |
||||
|
- `vscode://file/<locator>` |
||||
|
- `webstorm://open?file=<locator>` |
||||
|
- `subl://open?url=file://<locator>` |
||||
|
- `atom://open?url=file://<locator>` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🛡️ Production Safety & Performance |
||||
|
|
||||
|
1. **Compiler Level**: In `NODE_ENV === "production"`, `add-data-locator.cjs` returns early without modifying the AST. |
||||
|
2. **Bundle Level**: In `app/layout.tsx`, `{process.env.NODE_ENV === "development" && <DevClickToComponent />}` evaluates to `false` during `next build`, allowing bundlers to completely tree-shake the client component out of the production bundle. |
||||
|
3. **Runtime Level**: Event listeners and rendering are guarded with `process.env.NODE_ENV !== "development"`. |
||||
|
4. **Security Level**: The `/api/open-in-ide` route returns a `404` in production environments, preventing arbitrary command execution. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 License |
||||
|
|
||||
|
MIT |
||||
@ -0,0 +1,67 @@ |
|||||
|
import { NextResponse } from "next/server"; |
||||
|
import { exec } from "child_process"; |
||||
|
import os from "os"; |
||||
|
|
||||
|
export async function POST(req: Request) { |
||||
|
// Never run in production
|
||||
|
if (process.env.NODE_ENV !== "development") { |
||||
|
return NextResponse.json( |
||||
|
{ message: "Not available in production" }, |
||||
|
{ status: 404 } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const { locator } = await req.json(); |
||||
|
|
||||
|
if (!locator) { |
||||
|
return NextResponse.json( |
||||
|
{ message: "Locator is required" }, |
||||
|
{ status: 400 } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const isWindows = os.platform() === "win32"; |
||||
|
const bin = isWindows ? "antigravity-ide.cmd" : "antigravity-ide"; |
||||
|
const cmd = `${bin} -r -g "${locator}"`; |
||||
|
|
||||
|
return new Promise<Response>((resolve) => { |
||||
|
exec(cmd, (error) => { |
||||
|
if (error) { |
||||
|
// If primary IDE launcher isn't available, try fallback to code/cursor
|
||||
|
const fallbackBin = isWindows ? "code.cmd" : "code"; |
||||
|
const fallbackCmd = `${fallbackBin} -r -g "${locator}"`; |
||||
|
|
||||
|
exec(fallbackCmd, (fallbackError) => { |
||||
|
if (fallbackError) { |
||||
|
console.error( |
||||
|
`[open-in-ide] Error opening file: ${error.message} / Fallback error: ${fallbackError.message}` |
||||
|
); |
||||
|
resolve( |
||||
|
NextResponse.json( |
||||
|
{ |
||||
|
message: "Failed to open file in IDE", |
||||
|
error: error.message, |
||||
|
}, |
||||
|
{ status: 500 } |
||||
|
) |
||||
|
); |
||||
|
} else { |
||||
|
resolve(NextResponse.json({ success: true, via: "code" })); |
||||
|
} |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
resolve(NextResponse.json({ success: true, via: "antigravity" })); |
||||
|
}); |
||||
|
}); |
||||
|
} catch (err: unknown) { |
||||
|
const message = err instanceof Error ? err.message : String(err); |
||||
|
console.error("[open-in-ide] Handler error:", err); |
||||
|
return NextResponse.json( |
||||
|
{ message: "Internal server error", error: message }, |
||||
|
{ status: 500 } |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue