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
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;
|