You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

701 lines
20 KiB

"use client";
import { useEffect, useRef, useState } from "react";
import { LoadingThreeDot } from "./loading-three-dot";
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),
};
}
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 dragStart = useRef({
x: 0,
y: 0,
buttonX: 0,
buttonY: 0,
hasMoved: false,
});
// Detect mobile size
useEffect(() => {
const checkSize = () => {
setIsMobile(window.innerWidth <= 768);
};
checkSize();
window.addEventListener("resize", checkSize);
return () => window.removeEventListener("resize", checkSize);
}, []);
// Initialize button position
useEffect(() => {
if (typeof window !== "undefined") {
setPosition({
x: window.innerWidth - 66, // 50px width + 16px margin
y: window.innerHeight - 66, // 50px height + 16px margin
});
}
}, [isMobile]);
// 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]);
useEffect(() => {
const userAgent = navigator.userAgent.toLowerCase();
// Desktop: Alt + Click logic
const handleDesktopClick = (event: MouseEvent) => {
if (!event.altKey) {
return;
}
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const locator = target
.closest<HTMLElement>("[data-locator]")
?.getAttribute("data-locator");
if (!locator) {
return;
}
event.preventDefault();
event.stopPropagation();
const { filePath, line, column } = parseLocator(locator);
const positionSuffix =
line === null ? "" : `:${line}${column === null ? "" : `:${column}`}`;
const locatorString = `${filePath}${positionSuffix}`;
// Use the API route to execute the shell command which reuses the existing window
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");
}
});
};
document.addEventListener("click", handleDesktopClick, true);
return () => {
document.removeEventListener("click", handleDesktopClick, true);
};
}, []);
// Mobile: Inspect Mode click interception
useEffect(() => {
if (!isMobile || !isInspecting) return;
const userAgent = navigator.userAgent.toLowerCase();
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 locator = target
.closest("[data-locator]")
?.getAttribute("data-locator");
if (locator) {
console.log(
`[DevClickToComponent] Inspect Mode matched element. Opening in IDE:`,
locator,
);
// Flash target element outline briefly as visual feedback
const element = target.closest<HTMLElement>("[data-locator]");
if (element) {
const originalOutline = element.style.outline;
element.style.outline = "3px solid #3b82f6";
setTimeout(() => {
element.style.outline = originalOutline;
}, 400);
}
try {
await fetch("/api/open-in-ide", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ locator, userAgent }),
});
} catch (err) {
console.error(
"[DevClickToComponent] Error sending API request:",
err,
);
}
}
setIsInspecting(false);
};
// Use capturing phase to intercept clicks/taps before they trigger other actions
document.addEventListener("click", handleInspectClick, true);
return () => {
document.removeEventListener("click", handleInspectClick, true);
};
}, [isMobile, isInspecting]);
// Dev mode: Intercept and forward console logs globally
useEffect(() => {
const originalLog = console.log;
const originalWarn = console.warn;
const originalError = console.error;
let isSending = false;
const forwardLog = async (type: "log" | "warn" | "error", args: any[]) => {
if (isSending) return;
isSending = true;
try {
await fetch("/api/remote-logs", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ type, args }),
});
} catch (err) {
originalError.call(
console,
"[DevClickToComponent] Failed to forward log to server:",
err,
);
} finally {
isSending = false;
}
};
console.log = (...args: any[]) => {
originalLog.apply(console, args);
forwardLog("log", args);
};
console.warn = (...args: any[]) => {
originalWarn.apply(console, args);
forwardLog("warn", args);
};
console.error = (...args: any[]) => {
originalError.apply(console, args);
forwardLog("error", args);
};
return () => {
console.log = originalLog;
console.warn = originalWarn;
console.error = originalError;
};
}, []);
// Dev mode: Intercept and forward fetch requests globally
useEffect(() => {
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input instanceof Request
? input.url
: "";
// Ignore logging requests to prevent infinite recursion
if (
url.includes("/api/remote-logs") ||
url.includes("/api/remote-network") ||
url.includes("/api/open-in-ide")
) {
return originalFetch(input, init);
}
const method =
init?.method ?? (input instanceof Request ? input.method : "GET");
const startTime = Date.now();
let requestBody: string | null = null;
if (init?.body) {
if (typeof init.body === "string") {
requestBody = init.body;
} else {
requestBody = "[Non-string payload]";
}
} else if (input instanceof Request) {
try {
const clonedReq = input.clone();
requestBody = await clonedReq.text();
} catch {
requestBody = null;
}
}
try {
const response = await originalFetch(input, init);
const duration = Date.now() - startTime;
const status = response.status;
let responseBody: string | null = null;
try {
const clonedRes = response.clone();
responseBody = await clonedRes.text();
} catch {
responseBody = "[Unreadable response body]";
}
originalFetch("/api/remote-network", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
method,
url,
status,
requestBody,
responseBody,
duration,
}),
}).catch(() => {});
return response;
} catch (err) {
const duration = Date.now() - startTime;
originalFetch("/api/remote-network", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
method,
url,
status: 0,
requestBody,
responseBody: String(err),
duration,
}),
}).catch(() => {});
throw err;
}
};
return () => {
window.fetch = originalFetch;
};
}, []);
// Dev mode: Intercept and forward XMLHttpRequest (XHR) requests globally
useEffect(() => {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (
this: any,
method: any,
url: any,
...args: any[]
) {
this._method = method;
this._url = typeof url === "string" ? url : url.toString();
return originalOpen.apply(this, [method, url, ...args] as any);
};
XMLHttpRequest.prototype.send = function (this: any, body?: any) {
const url = this._url || "";
const method = this._method || "GET";
const startTime = Date.now();
// Avoid infinite recursion on dev logging endpoints
if (
url.includes("/api/remote-logs") ||
url.includes("/api/remote-network") ||
url.includes("/api/open-in-ide")
) {
return originalSend.apply(this, [body]);
}
this.addEventListener("loadend", () => {
const duration = Date.now() - startTime;
const status = this.status;
let responseBody = "";
try {
responseBody = this.responseText;
} catch {
try {
if (typeof this.response === "string") {
responseBody = this.response;
} else if (this.response) {
responseBody = JSON.stringify(this.response);
}
} catch {
responseBody = "[Binary or unreadable response]";
}
}
let requestBody: string | null = null;
if (body) {
if (typeof body === "string") {
requestBody = body;
} else {
requestBody = "[Non-string payload]";
}
}
window
.fetch("/api/remote-network", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
method,
url,
status,
requestBody,
responseBody,
duration,
}),
})
.catch(() => {});
});
return originalSend.apply(this, [body]);
};
return () => {
XMLHttpRequest.prototype.open = originalOpen;
XMLHttpRequest.prototype.send = originalSend;
};
}, []);
// Dragging event handlers
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 - 126;
dragStart.current = {
x: e.clientX,
y: e.clientY,
buttonX: startX,
buttonY: startY,
hasMoved: false,
};
};
const handlePointerMove = (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 - 126, dragStart.current.buttonY + dy),
);
setPosition({ x: newX, y: newY });
};
const handlePointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!isDragging) return;
e.currentTarget.releasePointerCapture(e.pointerId);
setIsDragging(false);
};
const handleInspectClick = () => {
if (!dragStart.current.hasMoved) {
setIsInspecting(!isInspecting);
}
};
const [isSendingStorage, setIsSendingStorage] = useState(false);
const [sendSuccess, setSendSuccess] = useState(false);
const handleSendStorageClick = async () => {
if (dragStart.current.hasMoved) return;
if (isSendingStorage) return;
setIsSendingStorage(true);
setSendSuccess(false);
try {
const data: Record<string, any> = {};
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key) {
const val = window.localStorage.getItem(key);
try {
data[key] = JSON.parse(val || "");
} catch {
data[key] = val;
}
}
}
const response = await fetch("/api/dev-storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (response.ok) {
setSendSuccess(true);
setTimeout(() => setSendSuccess(false), 2000);
}
} catch (err) {
console.error(
"[DevClickToComponent] Failed to send localStorage values:",
err,
);
} finally {
setIsSendingStorage(false);
}
};
return (
<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",
}}
>
{/* Send LocalStorage Button */}
<button
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={handleSendStorageClick}
style={{
width: "50px",
height: "50px",
borderRadius: "25px",
backgroundColor: sendSuccess ? "#10b981" : "#a855f7",
color: "#ffffff",
border: "none",
boxShadow: sendSuccess
? "0 0 15px rgba(16, 185, 129, 0.6)"
: "0 4px 12px rgba(0,0,0,0.35)",
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: isSendingStorage ? "scale(0.95)" : "scale(1)",
}}
title="Send LocalStorage to Terminal API"
>
{isSendingStorage ? (
<LoadingThreeDot className="scale-75" />
) : sendSuccess ? (
<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="m4.5 12.75 6 6 9-13.5"
/>
</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="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z"
/>
</svg>
)}
</button>
{/* Floating Action Draggable Button (Inspect Mode) */}
{isMobile && (
<button
className="dev-inspect-btn"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={handleInspectClick}
style={{
width: "50px",
height: "50px",
borderRadius: "25px",
backgroundColor: isInspecting ? "#3b82f6" : "#1f2937",
color: "#ffffff",
border: "none",
boxShadow: isInspecting
? "0 0 15px rgba(59, 130, 246, 0.6)"
: "0 4px 12px rgba(0,0,0,0.35)",
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)"
>
{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;