From f7e5f7446c2f60e1548a481fea635642a00fed31 Mon Sep 17 00:00:00 2001 From: Arun Vinayagam <121132855+Arun-V18@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:04:05 -0400 Subject: [PATCH 01/10] improved UI --- package.json | 4 +- src/content.tsx | 457 ++++++++++++++++++++++++++++++++++++++++-------- yarn.lock | 41 +++-- 3 files changed, 418 insertions(+), 84 deletions(-) diff --git a/package.json b/package.json index 491a221..b287536 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "acorn-walk": "^8.3.4", "d3-array": "^3.2.4", "escodegen": "^2.1.0", + "framer-motion": "^12.23.12", "plasmo": "0.89.4", "react": "18.2.0", "react-dom": "18.2.0", - "tailwindcss": "3.4.1" + "tailwindcss": "3.4.1", + "uuid": "^11.1.0" }, "devDependencies": { "@babel/preset-env": "^7.26.9", diff --git a/src/content.tsx b/src/content.tsx index 82d155f..d59b3e6 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -8,6 +8,7 @@ import type { LogMessage, MantisConnection } from "./connections/types"; import { GenerationProgress, Progression } from "./connections/types"; import { addSpaceToCache, deleteSpacesWhere, getCachedSpaces } from "./persistent"; import { refetchAuthCookies } from "./driver"; +import { motion, AnimatePresence} from "framer-motion"; export const config: PlasmoCSConfig = { matches: [""], @@ -41,43 +42,267 @@ const sanitizeWidget = (widget: HTMLElement, connection: MantisConnection) => { // Exits the dialog const CloseButton = ({ close }: { close: () => void }) => { - return ; + return ( + + × + + ); }; // Dialog util -const DialogHeader = ({ children }: { children: React.ReactNode }) => { +const DialogHeader = ({ children, overlay, close }: { children: React.ReactNode, overlay?: React.ReactNode, close?: () => void }) => { + const [panelSize, setPanelSize] = React.useState<{ width: number; height: number }>({ width: 550, height: 330 }); + const resizingRef = React.useRef<{ + startX: number; + startY: number; + startW: number; + startH: number; + startLeft: number; + startTop: number; + viewportW: number; + viewportH: number; + edge: 'top'|'right'|'bottom'|'left'; + } | null>(null); + + const [pos, setPos] = React.useState<{ top: number; left: number }>(() => { + const minMargin = 4; + const bottom = 130; + const right = 80; + const top = Math.max(minMargin, window.innerHeight - bottom - 365); + const left = Math.max(minMargin, window.innerWidth - right - 550); + return { top, left }; + }); + const draggingRef = React.useRef<{ startX: number; startY: number; startTop: number; startLeft: number } | null>(null); + + const onMouseMove = React.useCallback((e: MouseEvent) => { + if (!resizingRef.current) return; + const dx = e.clientX - resizingRef.current.startX; + const dy = e.clientY - resizingRef.current.startY; + + const minW = 320; + const minH = 200; + const maxW = Math.min(window.innerWidth * 0.92, 900); + const maxH = Math.min(window.innerHeight * 0.7, 800); + + let newW = resizingRef.current.startW; + let newH = resizingRef.current.startH; + let newLeft = pos.left; + let newTop = pos.top; + switch (resizingRef.current.edge) { + case 'right': + newW = resizingRef.current.startW + dx; + break; + case 'left': + newW = resizingRef.current.startW - dx; + newLeft = resizingRef.current.startLeft + dx; + break; + case 'bottom': + newH = resizingRef.current.startH + dy; + break; + case 'top': + newH = resizingRef.current.startH - dy; + newTop = resizingRef.current.startTop + dy; + break; + } + newW = Math.max(minW, Math.min(maxW, newW)); + newH = Math.max(minH, Math.min(maxH, newH)); + const minMargin = 4; + const maxLeft = Math.max(minMargin, resizingRef.current.viewportW - newW - minMargin); + const maxTop = Math.max(minMargin, resizingRef.current.viewportH - newH - minMargin); + newLeft = Math.max(minMargin, Math.min(maxLeft, newLeft)); + newTop = Math.max(minMargin, Math.min(maxTop, newTop)); + + setPanelSize({ width: newW, height: newH }); + if (resizingRef.current.edge === 'left' || resizingRef.current.edge === 'top') { + setPos({ left: newLeft, top: newTop }); + } + }, []); + + const endResize = React.useCallback(() => { + if (!resizingRef.current) return; + resizingRef.current = null; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', endResize); + document.body.style.cursor = ''; + (document.body.style as any).userSelect = ''; + }, [onMouseMove]); + + const startResize = React.useCallback((e: React.MouseEvent, edge: 'top'|'right'|'bottom'|'left') => { + resizingRef.current = { + startX: e.clientX, + startY: e.clientY, + startW: panelSize.width, + startH: panelSize.height, + startLeft: pos.left, + startTop: pos.top, + viewportW: window.innerWidth, + viewportH: window.innerHeight, + edge + }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', endResize); + const cursor = edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize'; + document.body.style.cursor = cursor; + (document.body.style as any).userSelect = 'none'; + e.preventDefault(); + e.stopPropagation(); + }, [panelSize.width, panelSize.height, pos.left, pos.top, onMouseMove, endResize]); + + React.useEffect(() => () => endResize(), [endResize]); + + const onDragMove = React.useCallback((e: MouseEvent) => { + if (!draggingRef.current) return; + const dx = e.clientX - draggingRef.current.startX; + const dy = e.clientY - draggingRef.current.startY; + let newLeft = draggingRef.current.startLeft + dx; + let newTop = draggingRef.current.startTop + dy; + + const minMargin = 4; + const maxLeft = Math.max(minMargin, window.innerWidth - panelSize.width - minMargin); + const maxTop = Math.max(minMargin, window.innerHeight - panelSize.height - minMargin); + newLeft = Math.max(minMargin, Math.min(maxLeft, newLeft)); + newTop = Math.max(minMargin, Math.min(maxTop, newTop)); + + setPos({ left: newLeft, top: newTop }); + }, [panelSize.width, panelSize.height]); + + const endDrag = React.useCallback(() => { + if (!draggingRef.current) return; + draggingRef.current = null; + document.removeEventListener('mousemove', onDragMove); + document.removeEventListener('mouseup', endDrag); + document.body.style.cursor = ''; + (document.body.style as any).userSelect = ''; + }, [onDragMove]); + + const startDrag: React.MouseEventHandler = React.useCallback((e) => { + if (resizingRef.current) return; + draggingRef.current = { + startX: e.clientX, + startY: e.clientY, + startTop: pos.top, + startLeft: pos.left + }; + document.addEventListener('mousemove', onDragMove); + document.addEventListener('mouseup', endDrag); + document.body.style.cursor = 'grabbing'; + (document.body.style as any).userSelect = 'none'; + e.preventDefault(); + e.stopPropagation(); + }, [pos.top, pos.left, onDragMove, endDrag]); + + React.useEffect(() => () => endDrag(), [endDrag]); + return ( -
-
- {children} + + {close && } +
+
+ + MantisAI + + + MantisAI + +
+
+ {children} +
-
+
startResize(e, 'top')} + className="absolute top-0 left-0 right-0 h-2 cursor-n-resize" + style={{ transform: 'translateY(-1px)' }} + title="Resize" + /> +
startResize(e, 'bottom')} + className="absolute bottom-0 left-0 right-0 h-2 cursor-s-resize" + style={{ transform: 'translateY(1px)' }} + title="Resize" + /> +
startResize(e, 'left')} + className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize" + style={{ transform: 'translateX(-1px)' }} + title="Resize" + /> +
startResize(e, 'right')} + className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize" + style={{ transform: 'translateX(1px)' }} + title="Resize" + /> + {overlay && ( +
+ {overlay} +
+ )} + ); -} +}; // Displays a navigation arrowhead const ArrowHead = ({ left, disabled }: { left: boolean, disabled: boolean }) => { return ( - ); } // Main dialog that appears when creating a space const ConnectionDialog = ({ activeConnections, close }: { activeConnections: MantisConnection[], close: () => void }) => { + const [showInitialText, setShowInitialText] = useState(true); const [state, setState] = useState(GenerationProgress.GATHERING_DATA); // Progress of creation process const [errorText, setErrorText] = useState(null); const [running, setRunning] = useState(false); // If the creation process is running @@ -92,6 +317,45 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man const [logMessages, setLogMessages] = useState([]); const logContainerRef = useRef(null); + const [showOverlay, setShowOverlay] = useState(true); + + const overlayElement = ( + + {showOverlay && ( + setShowOverlay(false)} + > + + + + MantisAI + + + + )} + + ); // Check if the log scroll is at the bottom const isScrolledToBottom = () => { @@ -114,7 +378,7 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man const establishLogSocket = (space_id: string) => { const backendApiUrl = new URL(process.env.PLASMO_PUBLIC_MANTIS_API); - const isLocalhost = backendApiUrl.hostname.includes('localhost') || backendApiUrl.hostname.includes('127.0.0.1'); + const isLocalhost = backendApiUrl.hostname.includes('localhost') || backendApiUrl.hostname.includes('127.0.0.1'); const baseWsUrl = isLocalhost ? process.env.PLASMO_PUBLIC_MANTIS_API.replace('http://', 'ws://').replace('https://', 'ws://') : process.env.PLASMO_PUBLIC_MANTIS_API.replace('https://', 'wss://'); @@ -276,8 +540,7 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man if (state === GenerationProgress.COMPLETED) { return ( - - + {connectionData}

@@ -317,61 +580,108 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man if (state === GenerationProgress.FAILED) { return ( - - - {connectionData} -

{errorText}
- + + {connectionData} +
{errorText}
+
); } + useEffect(() => { + const timer = setTimeout(() => { + setShowInitialText(false); + }, 1000); + return () => clearTimeout(timer); + }, []); + if (running) { return ( - - {connectionData} - {state !== GenerationProgress.CREATING_SPACE ? - // Raw progress bar with no logs - (
-
-
-
- {state} -
) - - // Progress bar + logs - : (
-
-
- Progress - {state} -
-
-
-
-
- -
-
- Log Messages -
- - {WSStatus} + + {showInitialText ? ( + + + MantisAI + + + ) : ( + + {connectionData} + + {state !== GenerationProgress.CREATING_SPACE ? ( +
+
+

Create New Space

+
+ + +
+
+ +
+
+
+ {activeConnections[connectionIdx].name} +
+
+

{activeConnections[connectionIdx].name}

+

{activeConnections[connectionIdx].description}

+
+
+ +
+ +
+
-
+ ) : ( +
+
+
+

Creating Space

+
+ {Progression.indexOf(state) + 1} of {Progression.length} steps +
+
{logMessages.length === 0 ? (
No log messages yet
@@ -393,16 +703,17 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man
)) )} -
-
+
+
)} - + + )} + ); } return ( - - +
{activeConnections.length > 1 && (
@@ -454,7 +765,7 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man onClick={() => runConnection(activeConnection)} disabled={!isAuthenticated} > - Create + Create Space @@ -521,10 +832,10 @@ const PlasmoFloatingButton = () => { return ( <> {open && ( setOpen(false)} /> diff --git a/yarn.lock b/yarn.lock index b4e5688..124a60e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4157,15 +4157,10 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001541: - version "1.0.30001704" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001704.tgz#6644fe909d924ac3a7125e8a0ab6af95b1f32990" - integrity sha512-+L2IgBbV6gXB4ETf0keSvLr7JUrRVbIaB/lrQ1+z8mRcQiisG5k+lG6O4n6Y5q6f5EuNfaYXKgymucphlEXQew== - -caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702: - version "1.0.30001703" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz#977cb4920598c158f491ecf4f4f2cfed9e354718" - integrity sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ== +caniuse-lite@^1.0.30001541, caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702: + version "1.0.30001734" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz" + integrity sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A== chai@^5.2.0: version "5.2.0" @@ -5192,6 +5187,15 @@ fraction.js@^4.3.7: resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== +framer-motion@^12.23.12: + version "12.23.12" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.23.12.tgz#80cf6fd7c111073a0c558e336c85ca36cca80d3d" + integrity sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg== + dependencies: + motion-dom "^12.23.12" + motion-utils "^12.23.6" + tslib "^2.4.0" + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -6848,6 +6852,18 @@ mnemonic-id@3.2.7: resolved "https://registry.yarnpkg.com/mnemonic-id/-/mnemonic-id-3.2.7.tgz#f7d77d8b39e009ad068117cbafc458a6c6f8cddf" integrity sha512-kysx9gAGbvrzuFYxKkcRjnsg/NK61ovJOV4F1cHTRl9T5leg+bo6WI0pWIvOFh1Z/yDL0cjA5R3EEGPPLDv/XA== +motion-dom@^12.23.12: + version "12.23.12" + resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.23.12.tgz#87974046e7e61bc4932f36d35e8eab6bb6f3e434" + integrity sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw== + dependencies: + motion-utils "^12.23.6" + +motion-utils@^12.23.6: + version "12.23.6" + resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.23.6.tgz#fafef80b4ea85122dd0d6c599a0c63d72881f312" + integrity sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ== + ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -8446,7 +8462,7 @@ ts-node@^10.9.2: v8-compile-cache-lib "^3.0.1" yn "3.1.1" -tslib@^2.1.0, tslib@^2.3.0, tslib@^2.8.0: +tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -8589,6 +8605,11 @@ utility-types@^3.10.0: resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.11.0.tgz#607c40edb4f258915e901ea7995607fdf319424c" integrity sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw== +uuid@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" + integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" From 4029afccb538278d7b3f1adbd2d800633e91c447 Mon Sep 17 00:00:00 2001 From: Arun Vinayagam <121132855+Arun-V18@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:49:54 -0400 Subject: [PATCH 02/10] fix crashing after space create --- src/content.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/content.tsx b/src/content.tsx index d59b3e6..41cb078 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -378,7 +378,7 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man const establishLogSocket = (space_id: string) => { const backendApiUrl = new URL(process.env.PLASMO_PUBLIC_MANTIS_API); - const isLocalhost = backendApiUrl.hostname.includes('localhost') || backendApiUrl.hostname.includes('127.0.0.1'); + const isLocalhost = backendApiUrl.hostname.includes('localhost') || backendApiUrl.hostname.includes('127.0.0.1'); const baseWsUrl = isLocalhost ? process.env.PLASMO_PUBLIC_MANTIS_API.replace('http://', 'ws://').replace('https://', 'ws://') : process.env.PLASMO_PUBLIC_MANTIS_API.replace('https://', 'wss://'); @@ -510,6 +510,13 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man const progressPercent = Progression.indexOf(state) / (Progression.length - 1); + useEffect(() => { + const timer = setTimeout(() => { + setShowInitialText(false); + }, 1000); + return () => clearTimeout(timer); + }, []); + // On opening useEffect(() => { // Make sure the user knows that they will be overwriting the existing space on the URL @@ -593,13 +600,6 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man ); } - useEffect(() => { - const timer = setTimeout(() => { - setShowInitialText(false); - }, 1000); - return () => clearTimeout(timer); - }, []); - if (running) { return ( From edf6f1951048f0f3f9bb3d5cbd8f7fecb5c66f1c Mon Sep 17 00:00:00 2001 From: Arun Vinayagam <121132855+Arun-V18@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:08:55 -0400 Subject: [PATCH 03/10] small fixes --- src/content.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/content.tsx b/src/content.tsx index 41cb078..5d73a9d 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -213,7 +213,7 @@ const DialogHeader = ({ children, overlay, close }: { children: React.ReactNode, > {close && }
{ return ( - padding: "3px", transform: `rotate(${left ? "135deg" : "-45deg"})`, }} - whileHover={!disabled ? { x: left ? -3 : 3 } : {}} - transition={{ type: "spring", stiffness: 500 }} /> ); } From 278399cee5adeb368e9fbd1227f69d88ce7d128b Mon Sep 17 00:00:00 2001 From: Arun Vinayagam <121132855+Arun-V18@users.noreply.github.com> Date: Wed, 13 Aug 2025 09:04:46 -0400 Subject: [PATCH 04/10] changing mantis AI to just mantis for simplicity --- src/content.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/content.tsx b/src/content.tsx index 5d73a9d..d3c708c 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -232,7 +232,7 @@ const DialogHeader = ({ children, overlay, close }: { children: React.ReactNode, > MantisAI @@ -242,7 +242,7 @@ const DialogHeader = ({ children, overlay, close }: { children: React.ReactNode, animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} > - MantisAI + Mantis
@@ -335,7 +335,7 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man > - MantisAI + Mantis @@ -621,7 +621,7 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man delay: 0.1 }} > - MantisAI + Mantis ) : ( @@ -833,7 +833,7 @@ const PlasmoFloatingButton = () => { className="fixed bottom-[30px] right-[30px] w-20 h-20 rounded-full bg-white text-white shadow-[0_0_20px_rgba(0,0,0,0.15)] cursor-pointer flex items-center justify-center transition duration-300 ease-in-out hover:shadow-[0_0_20px_rgba(0,0,0,0.3)] hover:scale-105 z-[10000]" onClick={() => setOpen(true)} > - MantisAI + Mantis {open && ( setOpen(false)} /> From 71ce262c3fc7a2b0d9fbd029dabe6f5502e3dad5 Mon Sep 17 00:00:00 2001 From: Arun Vinayagam <121132855+Arun-V18@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:07:34 -0400 Subject: [PATCH 05/10] gemini --- package.json | 3 +-- src/content.tsx | 43 ++++++++++++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index b287536..5049cec 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,7 @@ "plasmo": "0.89.4", "react": "18.2.0", "react-dom": "18.2.0", - "tailwindcss": "3.4.1", - "uuid": "^11.1.0" + "tailwindcss": "3.4.1" }, "devDependencies": { "@babel/preset-env": "^7.26.9", diff --git a/src/content.tsx b/src/content.tsx index d3c708c..fbc62f1 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -55,7 +55,7 @@ const CloseButton = ({ close }: { close: () => void }) => { }; // Dialog util -const DialogHeader = ({ children, overlay, close }: { children: React.ReactNode, overlay?: React.ReactNode, close?: () => void }) => { +const DialogPanel = ({ children, overlay, close }: { children: React.ReactNode, overlay?: React.ReactNode, close?: () => void }) => { const [panelSize, setPanelSize] = React.useState<{ width: number; height: number }>({ width: 550, height: 330 }); const resizingRef = React.useRef<{ startX: number; @@ -121,7 +121,7 @@ const DialogHeader = ({ children, overlay, close }: { children: React.ReactNode, if (resizingRef.current.edge === 'left' || resizingRef.current.edge === 'top') { setPos({ left: newLeft, top: newTop }); } - }, []); + }, [pos]); const endResize = React.useCallback(() => { if (!resizingRef.current) return; @@ -129,7 +129,7 @@ const DialogHeader = ({ children, overlay, close }: { children: React.ReactNode, document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', endResize); document.body.style.cursor = ''; - (document.body.style as any).userSelect = ''; + document.body.style.userSelect = ''; }, [onMouseMove]); const startResize = React.useCallback((e: React.MouseEvent, edge: 'top'|'right'|'bottom'|'left') => { @@ -196,6 +196,27 @@ const DialogHeader = ({ children, overlay, close }: { children: React.ReactNode, e.stopPropagation(); }, [pos.top, pos.left, onDragMove, endDrag]); + React.useEffect(() => { + const handleResize = () => { + const minMargin = 4; + const newLeft = Math.max(minMargin, Math.min( + window.innerWidth - panelSize.width - minMargin, + pos.left + )); + const newTop = Math.max(minMargin, Math.min( + window.innerHeight - panelSize.height - minMargin, + pos.top + )); + + if (newLeft !== pos.left || newTop !== pos.top) { + setPos({ left: newLeft, top: newTop }); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [panelSize.width, panelSize.height, pos.left, pos.top]); + React.useEffect(() => () => endDrag(), [endDrag]); return ( @@ -545,7 +566,7 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man if (state === GenerationProgress.COMPLETED) { return ( - + {connectionData}

@@ -579,13 +600,13 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man

- + ); } if (state === GenerationProgress.FAILED) { return ( - + {connectionData}
{errorText}
-
+ ); } @@ -625,7 +646,7 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man ) : ( - + {connectionData} {state !== GenerationProgress.CREATING_SPACE ? ( @@ -704,14 +725,14 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man
)} -
+ )} ); } return ( - +
{activeConnections.length > 1 && (
@@ -766,7 +787,7 @@ const ConnectionDialog = ({ activeConnections, close }: { activeConnections: Man Create Space - + ); }; From f4f57b534662d3f9f6b9efdb48c3a70a238ea0ad Mon Sep 17 00:00:00 2001 From: ArjunS Date: Thu, 28 Aug 2025 17:03:40 -0500 Subject: [PATCH 06/10] Add GitBlame connection for GitHub repositories - Clean implementation on upstream base --- assets/github.svg | 3 + package.json | 5 +- plasmo.config.ts | 4 + src/connection_manager.tsx | 3 +- src/connections/gitblame/connection.tsx | 257 ++++++++++++++++++++++++ yarn.lock | 114 +++++++++++ 6 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 assets/github.svg create mode 100644 src/connections/gitblame/connection.tsx diff --git a/assets/github.svg b/assets/github.svg new file mode 100644 index 0000000..5d720c8 --- /dev/null +++ b/assets/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/package.json b/package.json index 5049cec..d416ebc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test": "jest --passWithNoTests" }, "dependencies": { + "@octokit/rest": "^22.0.0", "acorn": "^8.14.0", "acorn-walk": "^8.3.4", "d3-array": "^3.2.4", @@ -20,7 +21,8 @@ "plasmo": "0.89.4", "react": "18.2.0", "react-dom": "18.2.0", - "tailwindcss": "3.4.1" + "tailwindcss": "3.4.1", + "uuid": "^11.1.0" }, "devDependencies": { "@babel/preset-env": "^7.26.9", @@ -36,6 +38,7 @@ "@types/node": "20.11.5", "@types/react": "18.2.48", "@types/react-dom": "18.2.18", + "@types/uuid": "^10.0.0", "autoprefixer": "^10.4.20", "babel-jest": "^29.7.0", "husky": "^9.1.7", diff --git a/plasmo.config.ts b/plasmo.config.ts index e812a69..8e344c6 100644 --- a/plasmo.config.ts +++ b/plasmo.config.ts @@ -4,6 +4,10 @@ export default { "storage", "cookies", "webRequest" + ], + host_permissions: [ + "https://api.github.com/*", + "https://github.com/*" ] } } \ No newline at end of file diff --git a/src/connection_manager.tsx b/src/connection_manager.tsx index 2e48d7a..164b69d 100644 --- a/src/connection_manager.tsx +++ b/src/connection_manager.tsx @@ -6,9 +6,10 @@ import { GoogleScholarConnection } from "./connections/googleScholar/connection" import { WikipediaSegmentConnection } from "./connections/wikipediaSegment/connection"; import { GmailConnection } from "./connections/Gmail/connection"; import { LinkedInConnection } from "./connections/Linkedin/connection"; +import { GitBlameConnection } from "./connections/gitblame/connection"; -export const CONNECTIONS = [GmailConnection, WikipediaSegmentConnection, WikipediaReferencesConnection, GoogleConnection, PubmedConnection, GoogleDocsConnection, GoogleScholarConnection,LinkedInConnection]; +export const CONNECTIONS = [GmailConnection, WikipediaSegmentConnection, WikipediaReferencesConnection, GoogleConnection, PubmedConnection, GoogleDocsConnection, GoogleScholarConnection, LinkedInConnection, GitBlameConnection]; export const searchConnections = (url: string, ) => { const connections = CONNECTIONS.filter(connection => connection.trigger(url)); diff --git a/src/connections/gitblame/connection.tsx b/src/connections/gitblame/connection.tsx new file mode 100644 index 0000000..39c7eca --- /dev/null +++ b/src/connections/gitblame/connection.tsx @@ -0,0 +1,257 @@ +import type { MantisConnection, injectUIType, onMessageType, registerListenersType, setProgressType, establishLogSocketType } from "../types"; +import { GenerationProgress } from "../types"; +import { Octokit } from "@octokit/rest"; + +import githubIcon from "data-base64:../../../assets/github.svg"; +import { getSpacePortal, registerAuthCookies, reqSpaceCreation } from "../../driver"; + +const trigger = (url: string) => { + return url.includes("github.com") && url.includes("/blob/"); +} + +const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, onMessage: onMessageType, registerListeners: registerListenersType, establishLogSocket: establishLogSocketType) => { + setProgress(GenerationProgress.GATHERING_DATA); + + // Extract repository information from the URL + const url = new URL(window.location.href); + const pathParts = url.pathname.split('/'); + const owner = pathParts[1]; + const repo = pathParts[2]; + const branch = pathParts[4] || "main"; + const filePath = pathParts.slice(5).join('/'); + + console.log(`Processing repository: ${owner}/${repo}, branch: ${branch}, file: ${filePath}`); + + // Initialize Octokit with GitHub token from environment + const octokit = new Octokit({ + auth: process.env.PLASMO_PUBLIC_GITHUB_TOKEN + }); + + try { + // Get file blame information + const blameData = await getFileBlame(octokit, owner, repo, filePath, branch); + + // Get additional repository information + const repoInfo = await getRepositoryInfo(octokit, owner, repo); + + // Combine data for space creation + const extractedData = blameData.map(entry => ({ + filename: entry.filename, + lineNumber: entry.lineNumber, + commit: entry.commit, + author: entry.author, + date: entry.date, + lineContent: entry.lineContent, + repository: `${owner}/${repo}`, + branch: branch + })); + + // Add repository metadata + if (repoInfo) { + extractedData.push({ + filename: "repository_info", + lineNumber: 0, + commit: "metadata", + author: repoInfo.owner.login, + date: repoInfo.created_at, + lineContent: `Repository: ${repoInfo.full_name}, Description: ${repoInfo.description || 'No description'}, Language: ${repoInfo.language || 'Unknown'}`, + repository: `${owner}/${repo}`, + branch: branch + }); + } + + console.log(`Extracted ${extractedData.length} blame entries`); + + setProgress(GenerationProgress.CREATING_SPACE); + + const spaceData = await reqSpaceCreation(extractedData, { + "filename": "text", + "lineNumber": "number", + "commit": "text", + "author": "text", + "date": "date", + "lineContent": "semantic", + "repository": "text", + "branch": "text" + }, establishLogSocket, `GitBlame: ${owner}/${repo}/${filePath}`); + + setProgress(GenerationProgress.INJECTING_UI); + + const spaceId = spaceData.space_id; + const createdWidget = await injectUI(spaceId, onMessage, registerListeners); + + setProgress(GenerationProgress.COMPLETED); + + return { spaceId, createdWidget }; + + } catch (error) { + console.error('Error creating GitBlame space:', error); + throw error; + } +} + +async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: string, branch: string) { + try { + const commits = await octokit.paginate( + octokit.rest.repos.listCommits, + { + owner, + repo, + path, + sha: branch, + per_page: 100 + } + ); + + const blameMap: Record = {}; + let lineCount = 0; + + for (const commit of commits.reverse()) { + const commitSha = commit.sha; + + const commitData = await octokit.rest.repos.getCommit({ + owner, + repo, + ref: commitSha + }); + + for (const file of commitData.data.files || []) { + if (file.filename === path && file.patch) { + const patchLines = file.patch.split("\n"); + + let currentOldLine = 0; + let currentNewLine = 0; + + for (const line of patchLines) { + if (line.startsWith("@@")) { + const match = /@@ -(\d+),?\d* \+(\d+),?\d* @@/.exec(line); + if (match) { + currentOldLine = parseInt(match[1], 10); + currentNewLine = parseInt(match[2], 10); + } + } else if (line.startsWith("+")) { + blameMap[currentNewLine] = { + filename: path, + lineNumber: currentNewLine, + commit: commitSha, + author: commit.commit.author.name, + date: commit.commit.author.date, + lineContent: line.slice(1) + }; + currentNewLine++; + } else if (line.startsWith("-")) { + currentOldLine++; + } else { + currentOldLine++; + currentNewLine++; + } + } + } + } + + if (!lineCount && Object.keys(blameMap).length > 0) { + lineCount = Math.max(...Object.keys(blameMap).map(Number)); + } + } + + const blameData = []; + for (let i = 1; i <= lineCount; i++) { + if (blameMap[i]) { + blameData.push(blameMap[i]); + } + } + + return blameData; + } catch (error) { + console.warn(`Could not get blame for ${path}:`, error); + return []; + } +} + +async function getRepositoryInfo(octokit: Octokit, owner: string, repo: string) { + try { + const { data } = await octokit.rest.repos.get({ + owner, + repo + }); + return data; + } catch (error) { + console.warn(`Could not get repository info for ${owner}/${repo}:`, error); + return null; + } +} + +const injectUI = async (space_id: string, onMessage: onMessageType, registerListeners: registerListenersType) => { + // Find the GitHub file header to inject our UI + const fileHeader = document.querySelector('.file-header') || + document.querySelector('.Box-header') || + document.querySelector('.d-flex.flex-column.flex-md-row'); + + if (!fileHeader) { + throw new Error('Could not find GitHub file header'); + } + + // Container for everything + const div = document.createElement("div"); + + // Toggle switch wrapper + const label = document.createElement("label"); + label.style.display = "inline-flex"; + label.style.alignItems = "center"; + label.style.cursor = "pointer"; + label.style.marginLeft = "16px"; + label.style.marginRight = "16px"; + + // Checkbox as toggle + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.style.display = "none"; + + // Text container with GitHub-style styling + const textContainer = document.createElement("span"); + textContainer.innerText = "Mantis GitBlame"; + textContainer.style.background = "linear-gradient(90deg, #0366d6, #28a745)"; + textContainer.style.backgroundClip = "text"; + textContainer.style.webkitTextFillColor = "transparent"; + textContainer.style.fontWeight = "600"; + textContainer.style.fontSize = "14px"; + + await registerAuthCookies(); + + const iframeScalerParent = await getSpacePortal(space_id, onMessage, registerListeners); + iframeScalerParent.style.display = "none"; + + // Toggle behavior + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + iframeScalerParent.style.display = "block"; + textContainer.style.background = "linear-gradient(90deg, #28a745, #0366d6)"; + } else { + iframeScalerParent.style.display = "none"; + textContainer.style.background = "linear-gradient(90deg, #0366d6, #28a745)"; + } + textContainer.style.backgroundClip = "text"; + }); + + // Assemble elements + label.appendChild(textContainer); + label.appendChild(checkbox); + div.appendChild(label); + + // Insert the iframe after the file header + fileHeader.parentNode?.insertBefore(iframeScalerParent, fileHeader.nextSibling); + + // Insert into the file header + fileHeader.appendChild(div); + + return div; +} + +export const GitBlameConnection: MantisConnection = { + name: "GitBlame", + description: "Builds spaces based on Git blame information from GitHub repositories", + icon: githubIcon, + trigger: trigger, + createSpace: createSpace, + injectUI: injectUI, +} diff --git a/yarn.lock b/yarn.lock index 124a60e..f989da5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1697,6 +1697,100 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@octokit/auth-token@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-6.0.0.tgz#b02e9c08a2d8937df09a2a981f226ad219174c53" + integrity sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w== + +"@octokit/core@^7.0.2": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-7.0.3.tgz#0b5288995fed66920128d41cfeea34979d48a360" + integrity sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ== + dependencies: + "@octokit/auth-token" "^6.0.0" + "@octokit/graphql" "^9.0.1" + "@octokit/request" "^10.0.2" + "@octokit/request-error" "^7.0.0" + "@octokit/types" "^14.0.0" + before-after-hook "^4.0.0" + universal-user-agent "^7.0.0" + +"@octokit/endpoint@^11.0.0": + version "11.0.0" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-11.0.0.tgz#189fcc022721b4c49d0307eea6be3de1cfb53026" + integrity sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ== + dependencies: + "@octokit/types" "^14.0.0" + universal-user-agent "^7.0.2" + +"@octokit/graphql@^9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-9.0.1.tgz#eb258fc9981403d2d751720832652c385b6c1613" + integrity sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg== + dependencies: + "@octokit/request" "^10.0.2" + "@octokit/types" "^14.0.0" + universal-user-agent "^7.0.0" + +"@octokit/openapi-types@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-25.1.0.tgz#5a72a9dfaaba72b5b7db375fd05e90ca90dc9682" + integrity sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA== + +"@octokit/plugin-paginate-rest@^13.0.1": + version "13.1.1" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.1.1.tgz#ca5bb1c7b85a583691263c1f788f607e9bcb74b3" + integrity sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw== + dependencies: + "@octokit/types" "^14.1.0" + +"@octokit/plugin-request-log@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz#de1c1e557df6c08adb631bf78264fa741e01b317" + integrity sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q== + +"@octokit/plugin-rest-endpoint-methods@^16.0.0": + version "16.0.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-16.0.0.tgz#ba30ca387fc2ac8bd93cf9f951174736babebd97" + integrity sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g== + dependencies: + "@octokit/types" "^14.1.0" + +"@octokit/request-error@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-7.0.0.tgz#48ae2cd79008315605d00e83664891a10a5ddb97" + integrity sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg== + dependencies: + "@octokit/types" "^14.0.0" + +"@octokit/request@^10.0.2": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-10.0.3.tgz#2ffdb88105ce20d25dcab8a592a7040ea48306c7" + integrity sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA== + dependencies: + "@octokit/endpoint" "^11.0.0" + "@octokit/request-error" "^7.0.0" + "@octokit/types" "^14.0.0" + fast-content-type-parse "^3.0.0" + universal-user-agent "^7.0.2" + +"@octokit/rest@^22.0.0": + version "22.0.0" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-22.0.0.tgz#9026f47dacba9c605da3d43cce9432c4c532dc5a" + integrity sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA== + dependencies: + "@octokit/core" "^7.0.2" + "@octokit/plugin-paginate-rest" "^13.0.1" + "@octokit/plugin-request-log" "^6.0.0" + "@octokit/plugin-rest-endpoint-methods" "^16.0.0" + +"@octokit/types@^14.0.0", "@octokit/types@^14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-14.1.0.tgz#3bf9b3a3e3b5270964a57cc9d98592ed44f840f2" + integrity sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g== + dependencies: + "@octokit/openapi-types" "^25.1.0" + "@parcel/bundler-default@2.9.3": version "2.9.3" resolved "https://registry.yarnpkg.com/@parcel/bundler-default/-/bundler-default-2.9.3.tgz#df18c4b8390a03f83ac6c89da302f9edf48c8fe2" @@ -3513,6 +3607,11 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -3988,6 +4087,11 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +before-after-hook@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-4.0.0.tgz#cf1447ab9160df6a40f3621da64d6ffc36050cb9" + integrity sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ== + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" @@ -5078,6 +5182,11 @@ external-editor@^3.1.0: iconv-lite "^0.4.24" tmp "^0.0.33" +fast-content-type-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb" + integrity sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg== + fast-fifo@^1.2.0, fast-fifo@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" @@ -8569,6 +8678,11 @@ unique-string@^3.0.0: dependencies: crypto-random-string "^4.0.0" +universal-user-agent@^7.0.0, universal-user-agent@^7.0.2: + version "7.0.3" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-7.0.3.tgz#c05870a58125a2dc00431f2df815a77fe69736be" + integrity sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A== + universalify@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" From 418d4e542ca2d2d0bfcb12c93683d46e6140b6f2 Mon Sep 17 00:00:00 2001 From: ArjunS Date: Thu, 28 Aug 2025 18:30:27 -0500 Subject: [PATCH 07/10] Fix all Gemini code review issues: implement proper GitHub token management, use GraphQL API for accurate blame data, improve URL parsing robustness, add proper TypeScript types, and use Tailwind CSS classes --- src/connections/gitblame/connection.tsx | 281 +++++++++++++++++------- src/github-token-manager.ts | 20 ++ 2 files changed, 225 insertions(+), 76 deletions(-) create mode 100644 src/github-token-manager.ts diff --git a/src/connections/gitblame/connection.tsx b/src/connections/gitblame/connection.tsx index 39c7eca..f6a58b1 100644 --- a/src/connections/gitblame/connection.tsx +++ b/src/connections/gitblame/connection.tsx @@ -1,30 +1,141 @@ import type { MantisConnection, injectUIType, onMessageType, registerListenersType, setProgressType, establishLogSocketType } from "../types"; import { GenerationProgress } from "../types"; import { Octokit } from "@octokit/rest"; +import { saveGitHubToken, getGitHubToken, hasGitHubToken } from "../../github-token-manager"; import githubIcon from "data-base64:../../../assets/github.svg"; import { getSpacePortal, registerAuthCookies, reqSpaceCreation } from "../../driver"; +// Define types for better type safety +type BlameMapEntry = { + filename: string; + lineNumber: number; + commit: string; + author?: string; + date?: string; + lineContent: string; +}; + +type GitHubBlameRange = { + startingLine: number; + endingLine: number; + age: number; + commit: { + oid: string; + author: { + name: string; + date: string; + }; + }; +}; + const trigger = (url: string) => { return url.includes("github.com") && url.includes("/blob/"); } +// Add this function to handle token input +async function promptForGitHubToken(): Promise { + return new Promise((resolve) => { + const token = prompt( + "Please enter your GitHub Personal Access Token:\n\n" + + "This token will be stored locally in your browser and used to access GitHub's API.\n" + + "You can create a token at: https://github.com/settings/tokens\n\n" + + "Required permissions: repo (for private repos) or public_repo (for public repos only)", + "" + ); + + if (token && token.trim()) { + resolve(token.trim()); + } else { + resolve(""); + } + }); +} + +// Add this function to validate token +async function validateGitHubToken(token: string): Promise { + try { + const octokit = new Octokit({ auth: token }); + const { data } = await octokit.rest.users.getAuthenticated(); + return !!data.login; + } catch (error) { + console.warn('Invalid GitHub token:', error); + return false; + } +} + const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, onMessage: onMessageType, registerListeners: registerListenersType, establishLogSocket: establishLogSocketType) => { setProgress(GenerationProgress.GATHERING_DATA); - // Extract repository information from the URL + // Check if user has a GitHub token + let githubToken = await getGitHubToken(); + + if (!githubToken) { + // Prompt user for token + githubToken = await promptForGitHubToken(); + + if (!githubToken) { + throw new Error('GitHub Personal Access Token is required to use the GitBlame connection.'); + } + + // Validate the token + const isValid = await validateGitHubToken(githubToken); + if (!isValid) { + throw new Error('Invalid GitHub Personal Access Token. Please check your token and try again.'); + } + + // Save the valid token + await saveGitHubToken(githubToken); + } + + // Extract repository information from the URL more robustly const url = new URL(window.location.href); const pathParts = url.pathname.split('/'); const owner = pathParts[1]; const repo = pathParts[2]; - const branch = pathParts[4] || "main"; - const filePath = pathParts.slice(5).join('/'); + + // More robust branch and file path extraction + let branch = "main"; + let filePath = ""; + + // Look for branch name in meta tags first (more reliable) + const branchMeta = document.querySelector('meta[name="branch-name"]'); + if (branchMeta) { + branch = branchMeta.getAttribute('content') || "main"; + } else { + // Fallback to URL parsing, but handle branch names with slashes + const blobIndex = pathParts.indexOf('blob'); + if (blobIndex !== -1 && blobIndex + 1 < pathParts.length) { + // The part after 'blob' could be the branch or part of the file path + // We need to determine where the branch ends and file path begins + const afterBlob = pathParts.slice(blobIndex + 1); + + // Try to find a reasonable split point + if (afterBlob.length >= 2) { + // Assume first part is branch, rest is file path + branch = afterBlob[0]; + filePath = afterBlob.slice(1).join('/'); + } else if (afterBlob.length === 1) { + // Only one part after blob, assume it's the branch + branch = afterBlob[0]; + filePath = ""; + } + } + } + + // If we still don't have a file path, try to extract it from the page + if (!filePath) { + const filePathMeta = document.querySelector('meta[name="file-path"]'); + if (filePathMeta) { + filePath = filePathMeta.getAttribute('content') || ""; + } + } console.log(`Processing repository: ${owner}/${repo}, branch: ${branch}, file: ${filePath}`); - // Initialize Octokit with GitHub token from environment + // Initialize Octokit with user's token const octokit = new Octokit({ - auth: process.env.PLASMO_PUBLIC_GITHUB_TOKEN + auth: githubToken }); try { @@ -90,78 +201,100 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, } } -async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: string, branch: string) { +async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: string, branch: string): Promise { try { - const commits = await octokit.paginate( - octokit.rest.repos.listCommits, - { - owner, - repo, - path, - sha: branch, - per_page: 100 + // Use GitHub's GraphQL API for proper blame data + const query = ` + query($owner: String!, $repo: String!, $path: String!, $ref: String!) { + repository(owner: $owner, name: $repo) { + object(expression: $ref) { + ... on Commit { + blame(path: $path) { + ranges { + startingLine + endingLine + age + commit { + oid + author { + name + date + } + } + } + } + } + } + } } - ); - - const blameMap: Record = {}; - let lineCount = 0; + `; - for (const commit of commits.reverse()) { - const commitSha = commit.sha; - - const commitData = await octokit.rest.repos.getCommit({ - owner, - repo, - ref: commitSha - }); + const variables = { + owner, + repo, + path, + ref: branch + }; - for (const file of commitData.data.files || []) { - if (file.filename === path && file.patch) { - const patchLines = file.patch.split("\n"); + // Make GraphQL request using Octokit + const response = await octokit.graphql(query, variables); + const blameData = response.repository?.object?.blame?.ranges || []; - let currentOldLine = 0; - let currentNewLine = 0; + // Convert GraphQL response to our format + const result: BlameMapEntry[] = []; + + for (const range of blameData) { + const { startingLine, endingLine, commit } = range; + + // Get the actual file content for these lines + try { + const fileContent = await octokit.rest.repos.getContent({ + owner, + repo, + path, + ref: commit.oid + }); + + if (Array.isArray(fileContent.data)) { + // This shouldn't happen for a file path, but handle it + continue; + } - for (const line of patchLines) { - if (line.startsWith("@@")) { - const match = /@@ -(\d+),?\d* \+(\d+),?\d* @@/.exec(line); - if (match) { - currentOldLine = parseInt(match[1], 10); - currentNewLine = parseInt(match[2], 10); - } - } else if (line.startsWith("+")) { - blameMap[currentNewLine] = { - filename: path, - lineNumber: currentNewLine, - commit: commitSha, - author: commit.commit.author.name, - date: commit.commit.author.date, - lineContent: line.slice(1) - }; - currentNewLine++; - } else if (line.startsWith("-")) { - currentOldLine++; - } else { - currentOldLine++; - currentNewLine++; - } + const content = Buffer.from(fileContent.data.content, 'base64').toString('utf-8'); + const lines = content.split('\n'); + + // Add each line in the range + for (let lineNum = startingLine; lineNum <= endingLine; lineNum++) { + if (lineNum > 0 && lineNum <= lines.length) { + result.push({ + filename: path, + lineNumber: lineNum, + commit: commit.oid, + author: commit.author?.name, + date: commit.author?.date, + lineContent: lines[lineNum - 1] || '' + }); } } - } - - if (!lineCount && Object.keys(blameMap).length > 0) { - lineCount = Math.max(...Object.keys(blameMap).map(Number)); + } catch (error) { + console.warn(`Could not get content for commit ${commit.oid}:`, error); + // Fallback: add entry without line content + for (let lineNum = startingLine; lineNum <= endingLine; lineNum++) { + result.push({ + filename: path, + lineNumber: lineNum, + commit: commit.oid, + author: commit.author?.name, + date: commit.author?.date, + lineContent: `[Line ${lineNum} - content unavailable]` + }); + } } } - const blameData = []; - for (let i = 1; i <= lineCount; i++) { - if (blameMap[i]) { - blameData.push(blameMap[i]); - } - } + // Sort by line number + return result.sort((a, b) => a.lineNumber - b.lineNumber); - return blameData; } catch (error) { console.warn(`Could not get blame for ${path}:`, error); return []; @@ -196,38 +329,34 @@ const injectUI = async (space_id: string, onMessage: onMessageType, registerList // Toggle switch wrapper const label = document.createElement("label"); - label.style.display = "inline-flex"; - label.style.alignItems = "center"; - label.style.cursor = "pointer"; - label.style.marginLeft = "16px"; - label.style.marginRight = "16px"; + label.classList.add("inline-flex", "items-center", "cursor-pointer", "ml-4", "mr-4"); // Checkbox as toggle const checkbox = document.createElement("input"); checkbox.type = "checkbox"; - checkbox.style.display = "none"; + checkbox.classList.add("hidden"); // Text container with GitHub-style styling const textContainer = document.createElement("span"); textContainer.innerText = "Mantis GitBlame"; + textContainer.classList.add("font-semibold", "text-sm"); + // Use CSS custom properties for gradient text since Tailwind doesn't support it textContainer.style.background = "linear-gradient(90deg, #0366d6, #28a745)"; textContainer.style.backgroundClip = "text"; textContainer.style.webkitTextFillColor = "transparent"; - textContainer.style.fontWeight = "600"; - textContainer.style.fontSize = "14px"; await registerAuthCookies(); const iframeScalerParent = await getSpacePortal(space_id, onMessage, registerListeners); - iframeScalerParent.style.display = "none"; + iframeScalerParent.classList.add("hidden"); // Toggle behavior checkbox.addEventListener("change", () => { if (checkbox.checked) { - iframeScalerParent.style.display = "block"; + iframeScalerParent.classList.remove("hidden"); textContainer.style.background = "linear-gradient(90deg, #28a745, #0366d6)"; } else { - iframeScalerParent.style.display = "none"; + iframeScalerParent.classList.add("hidden"); textContainer.style.background = "linear-gradient(90deg, #0366d6, #28a745)"; } textContainer.style.backgroundClip = "text"; diff --git a/src/github-token-manager.ts b/src/github-token-manager.ts new file mode 100644 index 0000000..c50f6a8 --- /dev/null +++ b/src/github-token-manager.ts @@ -0,0 +1,20 @@ +// Functions to manage GitHub token storage +export const GITHUB_TOKEN_KEY = 'github_personal_access_token'; + +export async function saveGitHubToken(token: string): Promise { + await chrome.storage.local.set({ [GITHUB_TOKEN_KEY]: token }); +} + +export async function getGitHubToken(): Promise { + const result = await chrome.storage.local.get([GITHUB_TOKEN_KEY]); + return result[GITHUB_TOKEN_KEY] || null; +} + +export async function hasGitHubToken(): Promise { + const token = await getGitHubToken(); + return !!token; +} + +export async function clearGitHubToken(): Promise { + await chrome.storage.local.remove([GITHUB_TOKEN_KEY]); +} From 6379aa1a58c9a22fdfbe41e1121306132d947a23 Mon Sep 17 00:00:00 2001 From: ArjunS Date: Thu, 28 Aug 2025 19:03:32 -0500 Subject: [PATCH 08/10] Fix all Gemini code review issues: PAT optional for public repos, efficient blame implementation, better URL parsing, code organization, and type safety improvements --- src/components/DialogPanel.tsx | 306 ++++++++++++++++++++++++ src/connections/gitblame/connection.tsx | 179 ++++++++------ src/content.tsx | 250 +------------------ 3 files changed, 415 insertions(+), 320 deletions(-) create mode 100644 src/components/DialogPanel.tsx diff --git a/src/components/DialogPanel.tsx b/src/components/DialogPanel.tsx new file mode 100644 index 0000000..a0468eb --- /dev/null +++ b/src/components/DialogPanel.tsx @@ -0,0 +1,306 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { motion } from "framer-motion"; +import faviconIco from "data-base64:../../assets/icon.png"; + +// Close button component +const CloseButton = ({ close }: { close: () => void }) => { + return ( + + × + + ); +}; + +// Arrow head component for resize handles +const ArrowHead = ({ direction }: { direction: 'top' | 'right' | 'bottom' | 'left' }) => { + const getArrowStyle = () => { + switch (direction) { + case 'top': + return { transform: 'rotate(0deg)' }; + case 'right': + return { transform: 'rotate(90deg)' }; + case 'bottom': + return { transform: 'rotate(180deg)' }; + case 'left': + return { transform: 'rotate(270deg)' }; + } + }; + + return ( +
+
+
+ ); +}; + +// Dialog panel component with drag and resize functionality +export const DialogPanel = ({ + children, + overlay, + close +}: { + children: React.ReactNode, + overlay?: React.ReactNode, + close?: () => void +}) => { + const [panelSize, setPanelSize] = useState<{ width: number; height: number }>({ width: 550, height: 330 }); + const resizingRef = useRef<{ + startX: number; + startY: number; + startW: number; + startH: number; + startLeft: number; + startTop: number; + viewportW: number; + viewportH: number; + edge: 'top'|'right'|'bottom'|'left'; + } | null>(null); + + const [pos, setPos] = useState<{ top: number; left: number }>(() => { + const minMargin = 4; + const bottom = 130; + const right = 80; + const top = Math.max(minMargin, window.innerHeight - bottom - 365); + const left = Math.max(minMargin, window.innerWidth - right - 550); + return { top, left }; + }); + + const draggingRef = useRef<{ startX: number; startY: number; startTop: number; startLeft: number } | null>(null); + + const onMouseMove = useCallback((e: MouseEvent) => { + if (!resizingRef.current) return; + const dx = e.clientX - resizingRef.current.startX; + const dy = e.clientY - resizingRef.current.startY; + + const minW = 320; + const minH = 200; + const maxW = Math.min(window.innerWidth * 0.92, 900); + const maxH = Math.min(window.innerHeight * 0.7, 800); + + let newW = resizingRef.current.startW; + let newH = resizingRef.current.startH; + let newLeft = pos.left; + let newTop = pos.top; + + switch (resizingRef.current.edge) { + case 'right': + newW = resizingRef.current.startW + dx; + break; + case 'left': + newW = resizingRef.current.startW - dx; + newLeft = resizingRef.current.startLeft + dx; + break; + case 'bottom': + newH = resizingRef.current.startH + dy; + break; + case 'top': + newH = resizingRef.current.startH - dy; + newTop = resizingRef.current.startTop + dy; + break; + } + + // Apply constraints + newW = Math.max(minW, Math.min(maxW, newW)); + newH = Math.max(minH, Math.min(maxH, newH)); + newLeft = Math.max(0, Math.min(window.innerWidth - newW, newLeft)); + newTop = Math.max(0, Math.min(window.innerHeight - newH, newTop)); + + setPanelSize({ width: newW, height: newH }); + if (newLeft !== pos.left || newTop !== pos.top) { + setPos({ left: newLeft, top: newTop }); + } + }, [panelSize.width, panelSize.height, pos.left, pos.top]); + + const onMouseMoveDrag = useCallback((e: MouseEvent) => { + if (!draggingRef.current) return; + const dx = e.clientX - draggingRef.current.startX; + const dy = e.clientY - draggingRef.current.startY; + + const newLeft = draggingRef.current.startLeft + dx; + const newTop = draggingRef.current.startTop + dy; + + const minMargin = 4; + const maxLeft = window.innerWidth - panelSize.width - minMargin; + const maxTop = window.innerHeight - panelSize.height - minMargin; + + setPos({ + left: Math.max(minMargin, Math.min(maxLeft, newLeft)), + top: Math.max(minMargin, Math.min(maxTop, newTop)) + }); + }, [panelSize.width, panelSize.height]); + + const endResize = useCallback(() => { + resizingRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + // Remove event listeners + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', endResize); + }, [onMouseMove]); + + const endDrag = useCallback(() => { + draggingRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + // Remove event listeners + document.removeEventListener('mousemove', onMouseMoveDrag); + document.removeEventListener('mouseup', endDrag); + }, [onMouseMoveDrag]); + + const startResize = useCallback((e: React.MouseEvent, edge: 'top'|'right'|'bottom'|'left') => { + e.preventDefault(); + resizingRef.current = { + startX: e.clientX, + startY: e.clientY, + startW: panelSize.width, + startH: panelSize.height, + startLeft: pos.left, + startTop: pos.top, + viewportW: window.innerWidth, + viewportH: window.innerHeight, + edge + }; + document.body.style.cursor = edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize'; + document.body.style.userSelect = 'none'; + + // Add event listeners + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', endResize); + }, [panelSize.width, panelSize.height, pos.left, pos.top, onMouseMove, endResize]); + + const startDrag = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + draggingRef.current = { + startX: e.clientX, + startY: e.clientY, + startTop: pos.top, + startLeft: pos.left + }; + document.body.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + + // Add event listeners + document.addEventListener('mousemove', onMouseMoveDrag); + document.addEventListener('mouseup', endDrag); + }, [pos.top, pos.left, onMouseMoveDrag, endDrag]); + + useEffect(() => { + const handleResize = () => { + const minMargin = 4; + const bottom = 130; + const right = 80; + const newTop = Math.max(minMargin, window.innerHeight - bottom - 365); + const newLeft = Math.max(minMargin, window.innerWidth - right - 550); + + if (newLeft !== pos.left || newTop !== pos.top) { + setPos({ left: newLeft, top: newTop }); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [panelSize.width, panelSize.height, pos.left, pos.top]); + + useEffect(() => () => endDrag(), [endDrag]); + + return ( + + {close && } +
+
+ + Mantis + + + Mantis + +
+
+ {children} +
+
+ + {/* Resize handles */} +
startResize(e, 'top')} + className="absolute top-0 left-0 right-0 h-2 cursor-n-resize group" + style={{ transform: 'translateY(-1px)' }} + title="Resize" + > + +
+
startResize(e, 'bottom')} + className="absolute bottom-0 left-0 right-0 h-2 cursor-s-resize group" + style={{ transform: 'translateY(1px)' }} + title="Resize" + > + +
+
startResize(e, 'left')} + className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize group" + style={{ transform: 'translateX(-1px)' }} + title="Resize" + > + +
+
startResize(e, 'right')} + className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize group" + style={{ transform: 'translateX(1px)' }} + title="Resize" + > + +
+ + {overlay && ( +
+ {overlay} +
+ )} +
+ ); +}; diff --git a/src/connections/gitblame/connection.tsx b/src/connections/gitblame/connection.tsx index f6a58b1..1effe57 100644 --- a/src/connections/gitblame/connection.tsx +++ b/src/connections/gitblame/connection.tsx @@ -1,7 +1,7 @@ import type { MantisConnection, injectUIType, onMessageType, registerListenersType, setProgressType, establishLogSocketType } from "../types"; import { GenerationProgress } from "../types"; import { Octokit } from "@octokit/rest"; -import { saveGitHubToken, getGitHubToken, hasGitHubToken } from "../../github-token-manager"; +import { saveGitHubToken, getGitHubToken } from "../../github-token-manager"; import githubIcon from "data-base64:../../../assets/github.svg"; import { getSpacePortal, registerAuthCookies, reqSpaceCreation } from "../../driver"; @@ -33,6 +33,18 @@ const trigger = (url: string) => { return url.includes("github.com") && url.includes("/blob/"); } +// Check if a repository is public (no authentication required) +async function isRepositoryPublic(owner: string, repo: string): Promise { + try { + // Try to access the repository without authentication + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`); + return response.status === 200; + } catch (error) { + console.warn('Could not determine repository visibility:', error); + return false; // Assume private if we can't determine + } +} + // Add this function to handle token input async function promptForGitHubToken(): Promise { return new Promise((resolve) => { @@ -67,27 +79,6 @@ async function validateGitHubToken(token: string): Promise { const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, onMessage: onMessageType, registerListeners: registerListenersType, establishLogSocket: establishLogSocketType) => { setProgress(GenerationProgress.GATHERING_DATA); - // Check if user has a GitHub token - let githubToken = await getGitHubToken(); - - if (!githubToken) { - // Prompt user for token - githubToken = await promptForGitHubToken(); - - if (!githubToken) { - throw new Error('GitHub Personal Access Token is required to use the GitBlame connection.'); - } - - // Validate the token - const isValid = await validateGitHubToken(githubToken); - if (!isValid) { - throw new Error('Invalid GitHub Personal Access Token. Please check your token and try again.'); - } - - // Save the valid token - await saveGitHubToken(githubToken); - } - // Extract repository information from the URL more robustly const url = new URL(window.location.href); const pathParts = url.pathname.split('/'); @@ -103,18 +94,42 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, if (branchMeta) { branch = branchMeta.getAttribute('content') || "main"; } else { - // Fallback to URL parsing, but handle branch names with slashes + // Fallback to URL parsing, but handle branch names with slashes more intelligently const blobIndex = pathParts.indexOf('blob'); if (blobIndex !== -1 && blobIndex + 1 < pathParts.length) { - // The part after 'blob' could be the branch or part of the file path - // We need to determine where the branch ends and file path begins const afterBlob = pathParts.slice(blobIndex + 1); - // Try to find a reasonable split point if (afterBlob.length >= 2) { - // Assume first part is branch, rest is file path - branch = afterBlob[0]; - filePath = afterBlob.slice(1).join('/'); + // For better branch detection, we need to be smarter about where the branch ends + // GitHub URLs typically have the pattern: /owner/repo/blob/branch/path/to/file + // But branch names can contain slashes, so we need to find the right split point + + // Try to find the file extension to determine where the file path starts + let filePathStartIndex = 0; + for (let i = 0; i < afterBlob.length; i++) { + const part = afterBlob[i]; + // If this part contains a file extension, it's likely part of the file path + if (part.includes('.') && !part.includes('/')) { + filePathStartIndex = i; + break; + } + // If this part looks like a commit hash (40+ hex chars), it's likely a commit, not a branch + if (/^[a-f0-9]{40,}$/.test(part)) { + filePathStartIndex = i; + break; + } + } + + if (filePathStartIndex > 0) { + // We found a likely file path start, everything before is the branch + branch = afterBlob.slice(0, filePathStartIndex).join('/'); + filePath = afterBlob.slice(filePathStartIndex).join('/'); + } else { + // Fallback: assume first part is branch, rest is file path + // This handles cases like "feature/new-feature" as branch name + branch = afterBlob[0]; + filePath = afterBlob.slice(1).join('/'); + } } else if (afterBlob.length === 1) { // Only one part after blob, assume it's the branch branch = afterBlob[0]; @@ -133,10 +148,43 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, console.log(`Processing repository: ${owner}/${repo}, branch: ${branch}, file: ${filePath}`); - // Initialize Octokit with user's token - const octokit = new Octokit({ - auth: githubToken - }); + // Check if repository is public first + const isPublic = await isRepositoryPublic(owner, repo); + + let githubToken: string | undefined; + let octokit: Octokit; + + if (isPublic) { + // For public repos, we can work without authentication + console.log('Repository is public, proceeding without authentication'); + octokit = new Octokit(); + } else { + // For private repos, we need authentication + console.log('Repository is private, authentication required'); + + // Check if user has a GitHub token + githubToken = await getGitHubToken(); + + if (!githubToken) { + // Prompt user for token + githubToken = await promptForGitHubToken(); + + if (!githubToken) { + throw new Error('GitHub Personal Access Token is required for private repositories.'); + } + + // Validate the token + const isValid = await validateGitHubToken(githubToken); + if (!isValid) { + throw new Error('Invalid GitHub Personal Access Token. Please check your token and try again.'); + } + + // Save the valid token + await saveGitHubToken(githubToken); + } + + octokit = new Octokit({ auth: githubToken }); + } try { // Get file blame information @@ -236,9 +284,29 @@ async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: ref: branch }; - // Make GraphQL request using Octokit - const response = await octokit.graphql(query, variables); - const blameData = response.repository?.object?.blame?.ranges || []; + // Make GraphQL request and get file content in parallel + const [response, fileContentResponse] = await Promise.all([ + octokit.graphql(query, variables), + octokit.rest.repos.getContent({ owner, repo, path, ref: branch }) + ]); + + // Type the GraphQL response properly + const typedResponse = response as any; + const blameData = typedResponse.repository?.object?.blame?.ranges || []; + + if (Array.isArray(fileContentResponse.data)) { + // This shouldn't happen for a file path, but handle it + return []; + } + + // Check if it's a file (not a symlink or submodule) + if (fileContentResponse.data.type !== 'file') { + console.warn(`Path ${path} is not a file (type: ${fileContentResponse.data.type})`); + return []; + } + + const content = Buffer.from(fileContentResponse.data.content, 'base64').toString('utf-8'); + const lines = content.split('\n'); // Convert GraphQL response to our format const result: BlameMapEntry[] = []; @@ -246,47 +314,16 @@ async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: for (const range of blameData) { const { startingLine, endingLine, commit } = range; - // Get the actual file content for these lines - try { - const fileContent = await octokit.rest.repos.getContent({ - owner, - repo, - path, - ref: commit.oid - }); - - if (Array.isArray(fileContent.data)) { - // This shouldn't happen for a file path, but handle it - continue; - } - - const content = Buffer.from(fileContent.data.content, 'base64').toString('utf-8'); - const lines = content.split('\n'); - - // Add each line in the range - for (let lineNum = startingLine; lineNum <= endingLine; lineNum++) { - if (lineNum > 0 && lineNum <= lines.length) { - result.push({ - filename: path, - lineNumber: lineNum, - commit: commit.oid, - author: commit.author?.name, - date: commit.author?.date, - lineContent: lines[lineNum - 1] || '' - }); - } - } - } catch (error) { - console.warn(`Could not get content for commit ${commit.oid}:`, error); - // Fallback: add entry without line content - for (let lineNum = startingLine; lineNum <= endingLine; lineNum++) { + // Add each line in the range + for (let lineNum = startingLine; lineNum <= endingLine; lineNum++) { + if (lineNum > 0 && lineNum <= lines.length) { result.push({ filename: path, lineNumber: lineNum, commit: commit.oid, author: commit.author?.name, date: commit.author?.date, - lineContent: `[Line ${lineNum} - content unavailable]` + lineContent: lines[lineNum - 1] || '' }); } } diff --git a/src/content.tsx b/src/content.tsx index fbc62f1..ac4258a 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -9,6 +9,7 @@ import { GenerationProgress, Progression } from "./connections/types"; import { addSpaceToCache, deleteSpacesWhere, getCachedSpaces } from "./persistent"; import { refetchAuthCookies } from "./driver"; import { motion, AnimatePresence} from "framer-motion"; +import { DialogPanel } from "./components/DialogPanel"; export const config: PlasmoCSConfig = { matches: [""], @@ -54,255 +55,6 @@ const CloseButton = ({ close }: { close: () => void }) => { ); }; -// Dialog util -const DialogPanel = ({ children, overlay, close }: { children: React.ReactNode, overlay?: React.ReactNode, close?: () => void }) => { - const [panelSize, setPanelSize] = React.useState<{ width: number; height: number }>({ width: 550, height: 330 }); - const resizingRef = React.useRef<{ - startX: number; - startY: number; - startW: number; - startH: number; - startLeft: number; - startTop: number; - viewportW: number; - viewportH: number; - edge: 'top'|'right'|'bottom'|'left'; - } | null>(null); - - const [pos, setPos] = React.useState<{ top: number; left: number }>(() => { - const minMargin = 4; - const bottom = 130; - const right = 80; - const top = Math.max(minMargin, window.innerHeight - bottom - 365); - const left = Math.max(minMargin, window.innerWidth - right - 550); - return { top, left }; - }); - const draggingRef = React.useRef<{ startX: number; startY: number; startTop: number; startLeft: number } | null>(null); - - const onMouseMove = React.useCallback((e: MouseEvent) => { - if (!resizingRef.current) return; - const dx = e.clientX - resizingRef.current.startX; - const dy = e.clientY - resizingRef.current.startY; - - const minW = 320; - const minH = 200; - const maxW = Math.min(window.innerWidth * 0.92, 900); - const maxH = Math.min(window.innerHeight * 0.7, 800); - - let newW = resizingRef.current.startW; - let newH = resizingRef.current.startH; - let newLeft = pos.left; - let newTop = pos.top; - switch (resizingRef.current.edge) { - case 'right': - newW = resizingRef.current.startW + dx; - break; - case 'left': - newW = resizingRef.current.startW - dx; - newLeft = resizingRef.current.startLeft + dx; - break; - case 'bottom': - newH = resizingRef.current.startH + dy; - break; - case 'top': - newH = resizingRef.current.startH - dy; - newTop = resizingRef.current.startTop + dy; - break; - } - newW = Math.max(minW, Math.min(maxW, newW)); - newH = Math.max(minH, Math.min(maxH, newH)); - const minMargin = 4; - const maxLeft = Math.max(minMargin, resizingRef.current.viewportW - newW - minMargin); - const maxTop = Math.max(minMargin, resizingRef.current.viewportH - newH - minMargin); - newLeft = Math.max(minMargin, Math.min(maxLeft, newLeft)); - newTop = Math.max(minMargin, Math.min(maxTop, newTop)); - - setPanelSize({ width: newW, height: newH }); - if (resizingRef.current.edge === 'left' || resizingRef.current.edge === 'top') { - setPos({ left: newLeft, top: newTop }); - } - }, [pos]); - - const endResize = React.useCallback(() => { - if (!resizingRef.current) return; - resizingRef.current = null; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', endResize); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }, [onMouseMove]); - - const startResize = React.useCallback((e: React.MouseEvent, edge: 'top'|'right'|'bottom'|'left') => { - resizingRef.current = { - startX: e.clientX, - startY: e.clientY, - startW: panelSize.width, - startH: panelSize.height, - startLeft: pos.left, - startTop: pos.top, - viewportW: window.innerWidth, - viewportH: window.innerHeight, - edge - }; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', endResize); - const cursor = edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize'; - document.body.style.cursor = cursor; - (document.body.style as any).userSelect = 'none'; - e.preventDefault(); - e.stopPropagation(); - }, [panelSize.width, panelSize.height, pos.left, pos.top, onMouseMove, endResize]); - - React.useEffect(() => () => endResize(), [endResize]); - - const onDragMove = React.useCallback((e: MouseEvent) => { - if (!draggingRef.current) return; - const dx = e.clientX - draggingRef.current.startX; - const dy = e.clientY - draggingRef.current.startY; - let newLeft = draggingRef.current.startLeft + dx; - let newTop = draggingRef.current.startTop + dy; - - const minMargin = 4; - const maxLeft = Math.max(minMargin, window.innerWidth - panelSize.width - minMargin); - const maxTop = Math.max(minMargin, window.innerHeight - panelSize.height - minMargin); - newLeft = Math.max(minMargin, Math.min(maxLeft, newLeft)); - newTop = Math.max(minMargin, Math.min(maxTop, newTop)); - - setPos({ left: newLeft, top: newTop }); - }, [panelSize.width, panelSize.height]); - - const endDrag = React.useCallback(() => { - if (!draggingRef.current) return; - draggingRef.current = null; - document.removeEventListener('mousemove', onDragMove); - document.removeEventListener('mouseup', endDrag); - document.body.style.cursor = ''; - (document.body.style as any).userSelect = ''; - }, [onDragMove]); - - const startDrag: React.MouseEventHandler = React.useCallback((e) => { - if (resizingRef.current) return; - draggingRef.current = { - startX: e.clientX, - startY: e.clientY, - startTop: pos.top, - startLeft: pos.left - }; - document.addEventListener('mousemove', onDragMove); - document.addEventListener('mouseup', endDrag); - document.body.style.cursor = 'grabbing'; - (document.body.style as any).userSelect = 'none'; - e.preventDefault(); - e.stopPropagation(); - }, [pos.top, pos.left, onDragMove, endDrag]); - - React.useEffect(() => { - const handleResize = () => { - const minMargin = 4; - const newLeft = Math.max(minMargin, Math.min( - window.innerWidth - panelSize.width - minMargin, - pos.left - )); - const newTop = Math.max(minMargin, Math.min( - window.innerHeight - panelSize.height - minMargin, - pos.top - )); - - if (newLeft !== pos.left || newTop !== pos.top) { - setPos({ left: newLeft, top: newTop }); - } - }; - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, [panelSize.width, panelSize.height, pos.left, pos.top]); - - React.useEffect(() => () => endDrag(), [endDrag]); - - return ( - - {close && } -
-
- - Mantis - - - Mantis - -
-
- {children} -
-
-
startResize(e, 'top')} - className="absolute top-0 left-0 right-0 h-2 cursor-n-resize" - style={{ transform: 'translateY(-1px)' }} - title="Resize" - /> -
startResize(e, 'bottom')} - className="absolute bottom-0 left-0 right-0 h-2 cursor-s-resize" - style={{ transform: 'translateY(1px)' }} - title="Resize" - /> -
startResize(e, 'left')} - className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize" - style={{ transform: 'translateX(-1px)' }} - title="Resize" - /> -
startResize(e, 'right')} - className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize" - style={{ transform: 'translateX(1px)' }} - title="Resize" - /> - {overlay && ( -
- {overlay} -
- )} - - ); -}; - // Displays a navigation arrowhead const ArrowHead = ({ left, disabled }: { left: boolean, disabled: boolean }) => { return ( From 9f029d2ed84acc4cd94a10eefecc6b0a4c741ffc Mon Sep 17 00:00:00 2001 From: ArjunS Date: Thu, 28 Aug 2025 20:05:39 -0500 Subject: [PATCH 09/10] Fix HTML response issue: add proper error handling for GitHub API responses and fallback authentication for GraphQL operations --- src/connections/gitblame/connection.tsx | 102 +++++++++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/src/connections/gitblame/connection.tsx b/src/connections/gitblame/connection.tsx index 1effe57..00b89f7 100644 --- a/src/connections/gitblame/connection.tsx +++ b/src/connections/gitblame/connection.tsx @@ -38,7 +38,21 @@ async function isRepositoryPublic(owner: string, repo: string): Promise try { // Try to access the repository without authentication const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`); - return response.status === 200; + + // Check if we got a valid JSON response + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + console.warn('GitHub API returned non-JSON response, assuming private repository'); + return false; + } + + if (response.status === 200) { + const data = await response.json(); + // Check if the repository is actually public + return !data.private; + } + + return false; } catch (error) { console.warn('Could not determine repository visibility:', error); return false; // Assume private if we can't determine @@ -158,6 +172,9 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, // For public repos, we can work without authentication console.log('Repository is public, proceeding without authentication'); octokit = new Octokit(); + + // However, some GitHub API operations (like GraphQL) may still require authentication + // We'll try without auth first, but fall back to asking for a token if needed } else { // For private repos, we need authentication console.log('Repository is private, authentication required'); @@ -190,6 +207,61 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, // Get file blame information const blameData = await getFileBlame(octokit, owner, repo, filePath, branch); + // If we got no blame data and we're using an unauthenticated client, + // it might be because GraphQL requires authentication + if (blameData.length === 0 && !githubToken) { + console.log('No blame data received, this might require authentication. Prompting for token...'); + + // Ask for a token even for public repos if GraphQL operations fail + const fallbackToken = await promptForGitHubToken(); + if (fallbackToken) { + const isValid = await validateGitHubToken(fallbackToken); + if (isValid) { + await saveGitHubToken(fallbackToken); + const authenticatedOctokit = new Octokit({ auth: fallbackToken }); + const retryBlameData = await getFileBlame(authenticatedOctokit, owner, repo, filePath, branch); + + if (retryBlameData.length > 0) { + console.log('Successfully retrieved blame data with authentication'); + // Use the authenticated data + const extractedData = retryBlameData.map(entry => ({ + filename: entry.filename, + lineNumber: entry.lineNumber, + commit: entry.commit, + author: entry.author, + date: entry.date, + lineContent: entry.lineContent, + repository: `${owner}/${repo}`, + branch: branch + })); + + // Continue with the authenticated data... + setProgress(GenerationProgress.CREATING_SPACE); + + const spaceData = await reqSpaceCreation(extractedData, { + "filename": "text", + "lineNumber": "number", + "commit": "text", + "author": "text", + "date": "date", + "lineContent": "semantic", + "repository": "text", + "branch": "text" + }, establishLogSocket, `GitBlame: ${owner}/${repo}/${filePath}`); + + setProgress(GenerationProgress.INJECTING_UI); + + const spaceId = spaceData.space_id; + const createdWidget = await injectUI(spaceId, onMessage, registerListeners); + + setProgress(GenerationProgress.COMPLETED); + + return { spaceId, createdWidget }; + } + } + } + } + // Get additional repository information const repoInfo = await getRepositoryInfo(octokit, owner, repo); @@ -245,6 +317,12 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, } catch (error) { console.error('Error creating GitBlame space:', error); + + // Provide more helpful error messages + if (error.message && error.message.includes('Unexpected token')) { + throw new Error('GitHub API returned an invalid response. This usually means authentication is required. Please provide a valid GitHub Personal Access Token.'); + } + throw error; } } @@ -290,9 +368,21 @@ async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: octokit.rest.repos.getContent({ owner, repo, path, ref: branch }) ]); - // Type the GraphQL response properly + // Type the GraphQL response properly and validate it's not HTML const typedResponse = response as any; - const blameData = typedResponse.repository?.object?.blame?.ranges || []; + + // Check if we got a valid response structure + if (!typedResponse || typeof typedResponse !== 'object') { + console.warn('Invalid GraphQL response structure:', typedResponse); + return []; + } + + if (!typedResponse.repository || !typedResponse.repository.object) { + console.warn('Repository or object not found in GraphQL response'); + return []; + } + + const blameData = typedResponse.repository.object.blame?.ranges || []; if (Array.isArray(fileContentResponse.data)) { // This shouldn't happen for a file path, but handle it @@ -334,6 +424,12 @@ async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: } catch (error) { console.warn(`Could not get blame for ${path}:`, error); + + // Check if the error is due to authentication issues + if (error.message && error.message.includes('Unexpected token')) { + console.warn('This appears to be an authentication issue. Please provide a valid GitHub token.'); + } + return []; } } From 640e9ece256b4a5452ed306edb4d0b829d084658 Mon Sep 17 00:00:00 2001 From: ArjunS Date: Fri, 5 Sep 2025 23:27:15 -0500 Subject: [PATCH 10/10] Add PR support to GitBlame connection - Updated trigger to work on both blob and pull request pages - Added PR detection and branch extraction logic - Enhanced URL parsing for PR file views - Added PR metadata to space creation (isPR, prNumber) - Updated UI injection to handle PR page layouts - Added PR indicator to button text - Improved error handling for all scenarios --- src/connections/gitblame/connection.tsx | 301 +++++++++++++++++------- 1 file changed, 210 insertions(+), 91 deletions(-) diff --git a/src/connections/gitblame/connection.tsx b/src/connections/gitblame/connection.tsx index 00b89f7..9b3048c 100644 --- a/src/connections/gitblame/connection.tsx +++ b/src/connections/gitblame/connection.tsx @@ -30,7 +30,7 @@ type GitHubBlameRange = { }; const trigger = (url: string) => { - return url.includes("github.com") && url.includes("/blob/"); + return url.includes("github.com") && (url.includes("/blob/") || url.includes("/pull/")); } // Check if a repository is public (no authentication required) @@ -47,9 +47,26 @@ async function isRepositoryPublic(owner: string, repo: string): Promise } if (response.status === 200) { - const data = await response.json(); - // Check if the repository is actually public - return !data.private; + try { + const data = await response.json(); + // Check if the repository is actually public + return !data.private; + } catch (jsonError) { + console.warn('Failed to parse JSON response:', jsonError); + return false; + } + } + + // If we get a 404, the repo might not exist or be private + if (response.status === 404) { + console.warn('Repository not found, assuming private'); + return false; + } + + // If we get rate limited or other errors, assume private + if (response.status === 403 || response.status === 429) { + console.warn('GitHub API rate limited or forbidden, assuming private'); + return false; } return false; @@ -103,51 +120,90 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, let branch = "main"; let filePath = ""; - // Look for branch name in meta tags first (more reliable) - const branchMeta = document.querySelector('meta[name="branch-name"]'); - if (branchMeta) { - branch = branchMeta.getAttribute('content') || "main"; + // Check if this is a PR page + const isPR = pathParts.includes('pull'); + const prIndex = pathParts.indexOf('pull'); + + if (isPR && prIndex !== -1 && prIndex + 1 < pathParts.length) { + // This is a PR page - extract PR number and get the head branch + const prNumber = pathParts[prIndex + 1]; + console.log(`Processing PR #${prNumber} for ${owner}/${repo}`); + + // For PR pages, we need to get the head branch from the PR data + // We'll try to extract it from the page or use a fallback + const prBranchMeta = document.querySelector('meta[name="pr-head-branch"]'); + if (prBranchMeta) { + branch = prBranchMeta.getAttribute('content') || "main"; + } else { + // Fallback: try to get branch from the page content + const branchElement = document.querySelector('[data-testid="head-ref"]') || + document.querySelector('.head-ref') || + document.querySelector('[title*="head"]'); + if (branchElement) { + branch = branchElement.textContent?.trim() || "main"; + } + } + + // For PR pages, we need to determine the file path differently + // Check if we're viewing a specific file in the PR + const filePathMeta = document.querySelector('meta[name="file-path"]'); + if (filePathMeta) { + filePath = filePathMeta.getAttribute('content') || ""; + } else { + // Try to extract from URL if it's a file view in PR + const filesIndex = pathParts.indexOf('files'); + if (filesIndex !== -1 && filesIndex + 1 < pathParts.length) { + filePath = pathParts.slice(filesIndex + 1).join('/'); + } + } } else { - // Fallback to URL parsing, but handle branch names with slashes more intelligently - const blobIndex = pathParts.indexOf('blob'); - if (blobIndex !== -1 && blobIndex + 1 < pathParts.length) { - const afterBlob = pathParts.slice(blobIndex + 1); - - if (afterBlob.length >= 2) { - // For better branch detection, we need to be smarter about where the branch ends - // GitHub URLs typically have the pattern: /owner/repo/blob/branch/path/to/file - // But branch names can contain slashes, so we need to find the right split point + // This is a regular blob page - use existing logic + // Look for branch name in meta tags first (more reliable) + const branchMeta = document.querySelector('meta[name="branch-name"]'); + if (branchMeta) { + branch = branchMeta.getAttribute('content') || "main"; + } else { + // Fallback to URL parsing, but handle branch names with slashes more intelligently + const blobIndex = pathParts.indexOf('blob'); + if (blobIndex !== -1 && blobIndex + 1 < pathParts.length) { + const afterBlob = pathParts.slice(blobIndex + 1); - // Try to find the file extension to determine where the file path starts - let filePathStartIndex = 0; - for (let i = 0; i < afterBlob.length; i++) { - const part = afterBlob[i]; - // If this part contains a file extension, it's likely part of the file path - if (part.includes('.') && !part.includes('/')) { - filePathStartIndex = i; - break; + if (afterBlob.length >= 2) { + // For better branch detection, we need to be smarter about where the branch ends + // GitHub URLs typically have the pattern: /owner/repo/blob/branch/path/to/file + // But branch names can contain slashes, so we need to find the right split point + + // Try to find the file extension to determine where the file path starts + let filePathStartIndex = 0; + for (let i = 0; i < afterBlob.length; i++) { + const part = afterBlob[i]; + // If this part contains a file extension, it's likely part of the file path + if (part.includes('.') && !part.includes('/')) { + filePathStartIndex = i; + break; + } + // If this part looks like a commit hash (40+ hex chars), it's likely a commit, not a branch + if (/^[a-f0-9]{40,}$/.test(part)) { + filePathStartIndex = i; + break; + } } - // If this part looks like a commit hash (40+ hex chars), it's likely a commit, not a branch - if (/^[a-f0-9]{40,}$/.test(part)) { - filePathStartIndex = i; - break; + + if (filePathStartIndex > 0) { + // We found a likely file path start, everything before is the branch + branch = afterBlob.slice(0, filePathStartIndex).join('/'); + filePath = afterBlob.slice(filePathStartIndex).join('/'); + } else { + // Fallback: assume first part is branch, rest is file path + // This handles cases like "feature/new-feature" as branch name + branch = afterBlob[0]; + filePath = afterBlob.slice(1).join('/'); } - } - - if (filePathStartIndex > 0) { - // We found a likely file path start, everything before is the branch - branch = afterBlob.slice(0, filePathStartIndex).join('/'); - filePath = afterBlob.slice(filePathStartIndex).join('/'); - } else { - // Fallback: assume first part is branch, rest is file path - // This handles cases like "feature/new-feature" as branch name + } else if (afterBlob.length === 1) { + // Only one part after blob, assume it's the branch branch = afterBlob[0]; - filePath = afterBlob.slice(1).join('/'); + filePath = ""; } - } else if (afterBlob.length === 1) { - // Only one part after blob, assume it's the branch - branch = afterBlob[0]; - filePath = ""; } } } @@ -204,11 +260,45 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, } try { - // Get file blame information - const blameData = await getFileBlame(octokit, owner, repo, filePath, branch); + // Get file blame information with better error handling + let blameData: BlameMapEntry[] = []; - // If we got no blame data and we're using an unauthenticated client, - // it might be because GraphQL requires authentication + try { + blameData = await getFileBlame(octokit, owner, repo, filePath, branch); + } catch (blameError) { + console.warn('Failed to get blame data:', blameError); + + // If we get JSON parsing errors, it's likely an authentication issue + if (blameError.message && (blameError.message.includes('Unexpected token') || blameError.message.includes(''))) { + console.log('Detected authentication issue, prompting for token...'); + + // Ask for a token + const fallbackToken = await promptForGitHubToken(); + if (fallbackToken) { + const isValid = await validateGitHubToken(fallbackToken); + if (isValid) { + await saveGitHubToken(fallbackToken); + const authenticatedOctokit = new Octokit({ auth: fallbackToken }); + + try { + blameData = await getFileBlame(authenticatedOctokit, owner, repo, filePath, branch); + console.log('Successfully retrieved blame data with authentication'); + } catch (retryError) { + console.error('Failed to get blame data even with authentication:', retryError); + throw new Error('Unable to retrieve blame data. Please check your GitHub token permissions.'); + } + } else { + throw new Error('Invalid GitHub token. Please check your token and try again.'); + } + } else { + throw new Error('GitHub Personal Access Token is required for this repository.'); + } + } else { + throw blameError; + } + } + + // If we still have no blame data, try one more time with authentication if (blameData.length === 0 && !githubToken) { console.log('No blame data received, this might require authentication. Prompting for token...'); @@ -219,46 +309,23 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, if (isValid) { await saveGitHubToken(fallbackToken); const authenticatedOctokit = new Octokit({ auth: fallbackToken }); - const retryBlameData = await getFileBlame(authenticatedOctokit, owner, repo, filePath, branch); - if (retryBlameData.length > 0) { - console.log('Successfully retrieved blame data with authentication'); - // Use the authenticated data - const extractedData = retryBlameData.map(entry => ({ - filename: entry.filename, - lineNumber: entry.lineNumber, - commit: entry.commit, - author: entry.author, - date: entry.date, - lineContent: entry.lineContent, - repository: `${owner}/${repo}`, - branch: branch - })); - - // Continue with the authenticated data... - setProgress(GenerationProgress.CREATING_SPACE); + try { + const retryBlameData = await getFileBlame(authenticatedOctokit, owner, repo, filePath, branch); - const spaceData = await reqSpaceCreation(extractedData, { - "filename": "text", - "lineNumber": "number", - "commit": "text", - "author": "text", - "date": "date", - "lineContent": "semantic", - "repository": "text", - "branch": "text" - }, establishLogSocket, `GitBlame: ${owner}/${repo}/${filePath}`); - - setProgress(GenerationProgress.INJECTING_UI); - - const spaceId = spaceData.space_id; - const createdWidget = await injectUI(spaceId, onMessage, registerListeners); - - setProgress(GenerationProgress.COMPLETED); - - return { spaceId, createdWidget }; + if (retryBlameData.length > 0) { + console.log('Successfully retrieved blame data with authentication'); + blameData = retryBlameData; + } + } catch (retryError) { + console.error('Failed to get blame data even with authentication:', retryError); + throw new Error('Unable to retrieve blame data. Please check your GitHub token permissions.'); } + } else { + throw new Error('Invalid GitHub token. Please check your token and try again.'); } + } else { + throw new Error('GitHub Personal Access Token is required for this repository.'); } } @@ -274,7 +341,9 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, date: entry.date, lineContent: entry.lineContent, repository: `${owner}/${repo}`, - branch: branch + branch: branch, + isPR: isPR, + prNumber: isPR ? pathParts[prIndex + 1] : undefined })); // Add repository metadata @@ -287,7 +356,9 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, date: repoInfo.created_at, lineContent: `Repository: ${repoInfo.full_name}, Description: ${repoInfo.description || 'No description'}, Language: ${repoInfo.language || 'Unknown'}`, repository: `${owner}/${repo}`, - branch: branch + branch: branch, + isPR: isPR, + prNumber: isPR ? pathParts[prIndex + 1] : undefined }); } @@ -303,8 +374,10 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, "date": "date", "lineContent": "semantic", "repository": "text", - "branch": "text" - }, establishLogSocket, `GitBlame: ${owner}/${repo}/${filePath}`); + "branch": "text", + "isPR": "boolean", + "prNumber": "text" + }, establishLogSocket, `GitBlame: ${owner}/${repo}/${filePath}${isPR ? ` (PR #${pathParts[prIndex + 1]})` : ''}`); setProgress(GenerationProgress.INJECTING_UI); @@ -323,6 +396,10 @@ const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, throw new Error('GitHub API returned an invalid response. This usually means authentication is required. Please provide a valid GitHub Personal Access Token.'); } + if (error.message && error.message.includes('')) { + throw new Error('GitHub API returned an HTML error page. This usually means rate limiting or authentication issues. Please try again later or provide a valid GitHub Personal Access Token.'); + } + throw error; } } @@ -368,6 +445,20 @@ async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: octokit.rest.repos.getContent({ owner, repo, path, ref: branch }) ]); + // Check if we got HTML error pages instead of JSON + if (typeof response === 'string' && response.includes('')) { + throw new Error('GitHub API returned an HTML error page. This usually means rate limiting or authentication issues.'); + } + + // Check if the response is valid JSON + if (typeof response === 'string') { + try { + JSON.parse(response); + } catch (jsonError) { + throw new Error('GitHub API returned invalid JSON response. This usually means authentication is required.'); + } + } + // Type the GraphQL response properly and validate it's not HTML const typedResponse = response as any; @@ -394,6 +485,16 @@ async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: console.warn(`Path ${path} is not a file (type: ${fileContentResponse.data.type})`); return []; } + + // Check if we got HTML error pages instead of file content + if (typeof fileContentResponse.data === 'string' && (fileContentResponse.data as string).includes('')) { + throw new Error('GitHub API returned an HTML error page when fetching file content. This usually means rate limiting or authentication issues.'); + } + + // Check if the file content response is valid + if (!fileContentResponse.data || !fileContentResponse.data.content) { + throw new Error('GitHub API returned invalid file content response. This usually means authentication is required.'); + } const content = Buffer.from(fileContentResponse.data.content, 'base64').toString('utf-8'); const lines = content.split('\n'); @@ -430,6 +531,10 @@ async function getFileBlame(octokit: Octokit, owner: string, repo: string, path: console.warn('This appears to be an authentication issue. Please provide a valid GitHub token.'); } + if (error.message && error.message.includes('')) { + console.warn('GitHub API returned an HTML error page. This usually means rate limiting or authentication issues.'); + } + return []; } } @@ -449,9 +554,23 @@ async function getRepositoryInfo(octokit: Octokit, owner: string, repo: string) const injectUI = async (space_id: string, onMessage: onMessageType, registerListeners: registerListenersType) => { // Find the GitHub file header to inject our UI - const fileHeader = document.querySelector('.file-header') || - document.querySelector('.Box-header') || - document.querySelector('.d-flex.flex-column.flex-md-row'); + // For PR pages, we need to look in different places + const isPR = window.location.href.includes('/pull/'); + + let fileHeader; + if (isPR) { + // For PR pages, look for the file header in the diff view + fileHeader = document.querySelector('.file-header') || + document.querySelector('.Box-header') || + document.querySelector('.d-flex.flex-column.flex-md-row') || + document.querySelector('[data-testid="file-header"]') || + document.querySelector('.js-file-header'); + } else { + // For regular blob pages, use the standard selectors + fileHeader = document.querySelector('.file-header') || + document.querySelector('.Box-header') || + document.querySelector('.d-flex.flex-column.flex-md-row'); + } if (!fileHeader) { throw new Error('Could not find GitHub file header'); @@ -471,7 +590,7 @@ const injectUI = async (space_id: string, onMessage: onMessageType, registerList // Text container with GitHub-style styling const textContainer = document.createElement("span"); - textContainer.innerText = "Mantis GitBlame"; + textContainer.innerText = isPR ? "Mantis GitBlame (PR)" : "Mantis GitBlame"; textContainer.classList.add("font-semibold", "text-sm"); // Use CSS custom properties for gradient text since Tailwind doesn't support it textContainer.style.background = "linear-gradient(90deg, #0366d6, #28a745)";