Repo Gist

Provides GitHub repositories as additional context.

  1. // ==UserScript==
  2. // @name Repo Gist
  3. // @namespace https://github.com/prudentbird
  4. // @version 0.0.4
  5. // @description Provides GitHub repositories as additional context.
  6. // @author Prudent Bird
  7. // @match https://t3.chat/*
  8. // @match https://beta.t3.chat/*
  9. // @icon https://www.google.com/s2/favicons?sz=64&domain=t3.chat
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // @grant unsafeWindow
  13. // @grant GM_xmlhttpRequest
  14. // @grant GM_registerMenuCommand
  15. // @run-at document-idle
  16. // @connect *
  17. // @license MIT
  18. // ==/UserScript==
  19.  
  20. (function () {
  21. "use strict";
  22.  
  23. // --- Configuration and State ---
  24. let debugMode = false;
  25. const DB_VERSION = 1;
  26. const SCRIPT_VERSION = "0.1.0";
  27. const SCRIPT_NAME = "Repo Gist";
  28. const DB_NAME = "t3chat_repogist_db";
  29. const STORE_NAME = "repogist_states";
  30. const githubSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>`;
  31.  
  32. const GM_STORAGE_KEYS = {
  33. DEBUG: "debug",
  34. API_URL: "apiUrl",
  35. GEMINI_API_KEY: "geminiApiKey",
  36. };
  37.  
  38. // Utility function to handle GM_getValue safely
  39. const safeGMGetValue = (key, defaultValue = null) => {
  40. try {
  41. const result = GM_getValue(key, defaultValue);
  42. if (result && typeof result.then === "function") {
  43. return result;
  44. } else {
  45. return Promise.resolve(result);
  46. }
  47. } catch (error) {
  48. Logger.error(`Error getting GM value for ${key}:`, error);
  49. return Promise.resolve(defaultValue);
  50. }
  51. };
  52.  
  53. // Utility function to handle GM_setValue safely
  54. const safeGMSetValue = (key, value) => {
  55. try {
  56. const result = GM_setValue(key, value);
  57. if (result && typeof result.then === "function") {
  58. return result;
  59. } else {
  60. return Promise.resolve(result);
  61. }
  62. } catch (error) {
  63. Logger.error(`Error setting GM value for ${key}:`, error);
  64. return Promise.reject(error);
  65. }
  66. };
  67.  
  68. // --- Utility: Logger ---
  69. const Logger = {
  70. log: (...args) => {
  71. if (debugMode) console.log(`[${SCRIPT_NAME}]`, ...args);
  72. },
  73. error: (...args) => console.error(`[${SCRIPT_NAME}]`, ...args),
  74. };
  75.  
  76. const getChatId = () => {
  77. const currentUrl = window.location.href;
  78. const match = currentUrl.match(/\/chat\/([^/?#]+)/);
  79. const chatId = match ? match[1] : null;
  80. if (!chatId) {
  81. Logger.log("getChatId: No chat ID found in URL", currentUrl);
  82. }
  83. return chatId;
  84. };
  85.  
  86. let apiUrl = null;
  87. let geminiApiKey = null;
  88.  
  89. const ApiKeyModal = {
  90. _isShown: false,
  91. _isValidURL: (url) => {
  92. try {
  93. new URL(url);
  94. return true;
  95. } catch (e) {
  96. return false;
  97. }
  98. },
  99. show: () => {
  100. if (document.getElementById(UI_IDS.apiKeyModal) || ApiKeyModal._isShown)
  101. return;
  102. ApiKeyModal._isShown = true;
  103. const wrapper = document.createElement("div");
  104. wrapper.id = UI_IDS.apiKeyModal;
  105. wrapper.innerHTML = `
  106. <div id="${UI_IDS.apiKeyModalContent}">
  107. <div id="${UI_IDS.apiKeyModalHeader}">
  108. <div><!-- Icon container -->
  109. <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cog-icon lucide-cog"><path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"/><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/><path d="M12 2v2"/><path d="M12 22v-2"/><path d="m17 20.66-1-1.73"/><path d="M11 10.27 7 3.34"/><path d="m20.66 17-1.73-1"/><path d="m3.34 7 1.73 1"/><path d="M14 12h8"/><path d="M2 12h2"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m17 3.34-1 1.73"/><path d="m11 13.73-4 6.93"/></svg>
  110. </div>
  111. <div>Enter API Configuration</div><!-- Title -->
  112. <button id="${UI_IDS.apiKeyModalCloseButton}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
  113. </div>
  114. <div id="${UI_IDS.apiKeyModalInputContainer}">
  115. <label for="${UI_IDS.apiKeyModalInput}">RepoGist API URL</label>
  116. <input id="${UI_IDS.apiKeyModalInput}" type="text" placeholder="https://api.repogist.com/ingest" />
  117. <button id="${UI_IDS.apiKeyModalClearButton}" aria-label="Clear input"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></button>
  118. </div>
  119. <div id="${UI_IDS.apiKeyModalInputContainer}">
  120. <label for="${UI_IDS.geminiApiKeyInput}">Gemini API Key</label>
  121. <input id="${UI_IDS.geminiApiKeyInput}" type="text" placeholder="Enter your Gemini API key" />
  122. <button id="${UI_IDS.geminiApiKeyClearButton}" aria-label="Clear input"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></button>
  123. </div>
  124. <button id="${UI_IDS.apiKeyModalSaveButton}">Save</button>
  125. </div>`;
  126. document.body.appendChild(wrapper);
  127. const input = wrapper.querySelector(`#${UI_IDS.apiKeyModalInput}`);
  128. if (input) {
  129. input.focus();
  130. }
  131. ApiKeyModal._attachEventListeners(wrapper);
  132. },
  133. _attachEventListeners: (modalElement) => {
  134. const urlInput = modalElement.querySelector(
  135. `#${UI_IDS.apiKeyModalInput}`
  136. );
  137. const geminiKeyInput = modalElement.querySelector(
  138. `#${UI_IDS.geminiApiKeyInput}`
  139. );
  140. const saveButton = modalElement.querySelector(
  141. `#${UI_IDS.apiKeyModalSaveButton}`
  142. );
  143. const closeButton = modalElement.querySelector(
  144. `#${UI_IDS.apiKeyModalCloseButton}`
  145. );
  146. const clearButton = modalElement.querySelector(
  147. `#${UI_IDS.apiKeyModalClearButton}`
  148. );
  149. const geminiClearButton = modalElement.querySelector(
  150. `#${UI_IDS.geminiApiKeyClearButton}`
  151. );
  152.  
  153. modalElement.addEventListener("click", (e) => {
  154. if (e.target === modalElement) {
  155. ApiKeyModal._isShown = false;
  156. modalElement.remove();
  157. }
  158. });
  159.  
  160. const updateClearButtonVisibility = (input, button) => {
  161. if (button) {
  162. button.style.display = input.value ? "flex" : "none";
  163. }
  164. };
  165.  
  166. updateClearButtonVisibility(urlInput, clearButton);
  167. updateClearButtonVisibility(geminiKeyInput, geminiClearButton);
  168.  
  169. if (clearButton) {
  170. clearButton.addEventListener("click", () => {
  171. urlInput.value = "";
  172. urlInput.focus();
  173. updateClearButtonVisibility(urlInput, clearButton);
  174. });
  175. }
  176.  
  177. if (geminiClearButton) {
  178. geminiClearButton.addEventListener("click", () => {
  179. geminiKeyInput.value = "";
  180. geminiKeyInput.focus();
  181. updateClearButtonVisibility(geminiKeyInput, geminiClearButton);
  182. });
  183. }
  184.  
  185. urlInput.addEventListener("input", () =>
  186. updateClearButtonVisibility(urlInput, clearButton)
  187. );
  188. geminiKeyInput.addEventListener("input", () =>
  189. updateClearButtonVisibility(geminiKeyInput, geminiClearButton)
  190. );
  191.  
  192. const handleSave = () => {
  193. const url = urlInput.value.trim();
  194. const geminiKey = geminiKeyInput.value.trim();
  195.  
  196. if (url && !ApiKeyModal._isValidURL(url)) {
  197. alert("Invalid RepoGist API URL");
  198. return;
  199. }
  200.  
  201. if (url) {
  202. safeGMSetValue(GM_STORAGE_KEYS.API_URL, url)
  203. .then(() => {
  204. apiUrl = url;
  205. if (geminiKey) {
  206. return safeGMSetValue(
  207. GM_STORAGE_KEYS.GEMINI_API_KEY,
  208. geminiKey
  209. );
  210. }
  211. })
  212. .then(() => {
  213. if (geminiKey) {
  214. geminiApiKey = geminiKey;
  215. }
  216. ApiKeyModal._isShown = false;
  217. modalElement.remove();
  218. })
  219. .catch((err) => {
  220. Logger.error("Failed to save API configuration:", err);
  221. });
  222. } else {
  223. ApiKeyModal._isShown = false;
  224. modalElement.remove();
  225. }
  226. };
  227.  
  228. saveButton.addEventListener("click", handleSave);
  229.  
  230. urlInput.addEventListener("keydown", (e) => {
  231. if (e.key === "Enter") {
  232. handleSave();
  233. }
  234. });
  235.  
  236. geminiKeyInput.addEventListener("keydown", (e) => {
  237. if (e.key === "Enter") {
  238. handleSave();
  239. }
  240. });
  241.  
  242. closeButton.addEventListener("click", () => {
  243. ApiKeyModal._isShown = false;
  244. modalElement.remove();
  245. });
  246. },
  247. };
  248.  
  249. const getRepoNamefromURL = (url) => {
  250. if (typeof url !== "string") {
  251. Logger.log("getRepoNamefromURL: Invalid URL type", typeof url);
  252. return null;
  253. }
  254. url = url.replace(/\/+$/, "");
  255. let match = url.match(/[:\/]([^\/]+)\.git$/);
  256. if (match) {
  257. Logger.log("getRepoNamefromURL: Found .git URL match", match[1]);
  258. return match[1];
  259. }
  260. match = url.match(/\/([^\/]+)(?:\/tree\/[^\/]+)?$/);
  261. if (match) {
  262. Logger.log("getRepoNamefromURL: Found standard URL match", match[1]);
  263. return match[1];
  264. }
  265. Logger.log("getRepoNamefromURL: No match found for URL", url);
  266. return null;
  267. };
  268.  
  269. // Updated selector to match the exact HTML structure provided
  270. const selectors = {
  271. messageActions: "div.ml-\\[-7px\\].flex.items-center.gap-1",
  272. searchButton: 'button#search-toggle[aria-label="Enable search"]',
  273. };
  274.  
  275. const UI_IDS = {
  276. apiKeyModal: "api-key-modal",
  277. apiKeyModalContent: "api-key-modal-content",
  278. apiKeyModalHeader: "api-key-modal-header",
  279. apiKeyModalInput: "api-key-modal-input",
  280. apiKeyModalInputContainer: "api-key-modal-input-container",
  281. apiKeyModalSaveButton: "api-key-modal-save-button",
  282. apiKeyModalCloseButton: "api-key-modal-close-button",
  283. apiKeyModalClearButton: "api-key-modal-clear-button",
  284. geminiApiKeyInput: "gemini-api-key-input",
  285. geminiApiKeyClearButton: "gemini-api-key-clear-button",
  286. importButton: "import-button",
  287. searchToggle: "search-toggle",
  288. repoUrlModal: "repo-url-modal",
  289. repoUrlModalContent: "repo-url-modal-content",
  290. repoUrlModalHeader: "repo-url-modal-header",
  291. repoUrlModalDescription: "repo-url-modal-description",
  292. repoUrlModalInput: "repo-url-modal-input",
  293. repoUrlModalSaveButton: "repo-url-modal-save-button",
  294. repoUrlModalCloseButton: "repo-url-modal-close-button",
  295. repoUrlModalClearButton: "repo-url-modal-clear-button",
  296. repoUrlModalInputContainer: "repo-url-modal-input-container",
  297. styleElement: "repo-gist-style",
  298. };
  299.  
  300. const CSS_CLASSES = {
  301. // Updated button classes to match the attach button style exactly
  302. button:
  303. "inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 disabled:cursor-not-allowed hover:bg-muted/40 hover:text-foreground disabled:hover:bg-transparent disabled:hover:text-foreground/50 text-xs cursor-pointer -mb-1.5 h-auto gap-2 rounded-full border border-solid border-secondary-foreground/10 px-2 py-1.5 pr-2.5 text-muted-foreground max-sm:p-2",
  304. importButtonLoading: "loading",
  305. importButtonOn: "on",
  306. };
  307.  
  308. const StyleManager = {
  309. injectGlobalStyles: () => {
  310. if (document.getElementById(UI_IDS.styleElement)) return;
  311. const styleEl = document.createElement("style");
  312. styleEl.id = UI_IDS.styleElement;
  313. styleEl.textContent = `
  314. /* Button toggle animation */
  315. #${UI_IDS.importButton} { position: relative; overflow: hidden; transition: color 0.3s ease; }
  316. #${UI_IDS.importButton}::before { content: ''; position: absolute; inset: 0; background-color: rgba(219,39,119,0.15); transform: scaleX(0); transform-origin: left; transition: transform 0.3s ease; z-index:-1; }
  317. #${UI_IDS.importButton}.${CSS_CLASSES.importButtonOn}::before { transform: scaleX(1); }
  318. #${UI_IDS.importButton} svg { transition: transform 0.3s ease; }
  319. #${UI_IDS.importButton}.${CSS_CLASSES.importButtonOn} svg { transform: rotate(360deg); }
  320.  
  321. /* Loading state */
  322. #${UI_IDS.importButton}.${CSS_CLASSES.importButtonLoading} { opacity: 0.6; position: relative; }
  323. #${UI_IDS.importButton}.${CSS_CLASSES.importButtonLoading}::after { content: ''; position: absolute; top:50%; left:50%; width:12px; height:12px; margin:-6px 0 0 -6px; border:2px solid currentColor; border-radius:50%; border-top-color:transparent; animation:spin 1s linear infinite; }
  324. @keyframes spin { to { transform: rotate(360deg); } }
  325.  
  326. /* Repo URL Modal Styles */
  327. #${UI_IDS.repoUrlModal} {
  328. position: fixed;
  329. inset: 0;
  330. background: rgba(0,0,0,0.7);
  331. display: flex;
  332. align-items: center;
  333. justify-content: center;
  334. z-index: 9999;
  335. }
  336. #${UI_IDS.repoUrlModalContent} {
  337. background: #1c1c1e;
  338. padding: 24px;
  339. border-radius: 12px;
  340. width: 500px;
  341. max-width: 95vw;
  342. box-sizing: border-box;
  343. box-shadow: 0 8px 24px rgba(0,0,0,0.3);
  344. }
  345. @media (max-width: 600px) {
  346. #${UI_IDS.repoUrlModalContent} {
  347. width: 95vw;
  348. padding: 16px;
  349. }
  350. }
  351. #${UI_IDS.repoUrlModalHeader} {
  352. display: flex;
  353. align-items: center;
  354. margin-bottom: 20px;
  355. position: relative;
  356. }
  357. #${UI_IDS.repoUrlModalHeader} > div:first-child { /* Icon container */
  358. color: #c62a88;
  359. margin-right: 12px;
  360. }
  361. #${UI_IDS.repoUrlModalHeader} > div:last-child { /* Title container */
  362. font-size: 22px;
  363. font-weight: 600;
  364. color: #fff;
  365. }
  366. #${UI_IDS.repoUrlModalCloseButton} {
  367. position: absolute;
  368. top: 0;
  369. right: 0;
  370. background: none;
  371. border: none;
  372. cursor: pointer;
  373. color: #fff;
  374. font-size: 24px;
  375. transition: color 0.3s ease;
  376. }
  377. #${UI_IDS.repoUrlModalDescription} {
  378. color: #999;
  379. font-size: 14px;
  380. margin-bottom: 16px;
  381. }
  382. #${UI_IDS.repoUrlModalInputContainer} {
  383. position: relative;
  384. width: 100%;
  385. margin-bottom: 16px;
  386. }
  387. #${UI_IDS.repoUrlModalInput} {
  388. width: 100%;
  389. padding: 12px 36px 12px 12px;
  390. box-sizing: border-box;
  391. background: #2a2a2c;
  392. color: #fff;
  393. border: 1px solid #333;
  394. border-radius: 6px;
  395. outline: none;
  396. font-size: 14px;
  397. }
  398. #${UI_IDS.repoUrlModalClearButton} {
  399. position: absolute;
  400. top: 50%;
  401. right: 10px;
  402. transform: translateY(-50%);
  403. width: 20px;
  404. height: 20px;
  405. display: flex;
  406. align-items: center;
  407. justify-content: center;
  408. background: none;
  409. border: none;
  410. cursor: pointer;
  411. font-size: 16px;
  412. font-weight: 500;
  413. color: #aaa;
  414. transition: color 0.2s ease;
  415. padding: 0;
  416. }
  417. #${UI_IDS.repoUrlModalClearButton}:hover {
  418. color: #c62a88;
  419. }
  420. #${UI_IDS.repoUrlModalSaveButton} {
  421. width: 100%;
  422. padding: 12px;
  423. background: #a02553;
  424. border: none;
  425. border-radius: 6px;
  426. color: white;
  427. cursor: pointer;
  428. font-size: 15px;
  429. font-weight: 500;
  430. transition: all 0.2s ease;
  431. }
  432. #${UI_IDS.repoUrlModalSaveButton}:hover {
  433. background: #c62a88;
  434. }
  435.  
  436. /* API Key Modal Styles */
  437. #${UI_IDS.apiKeyModal} {
  438. position: fixed;
  439. inset: 0;
  440. background: rgba(0,0,0,0.7);
  441. display: flex;
  442. align-items: center;
  443. justify-content: center;
  444. z-index: 9999;
  445. }
  446. #${UI_IDS.apiKeyModalContent} {
  447. background: #1c1c1e;
  448. padding: 24px;
  449. border-radius: 12px;
  450. width: 500px;
  451. max-width: 95vw;
  452. box-sizing: border-box;
  453. box-shadow: 0 8px 24px rgba(0,0,0,0.3);
  454. }
  455. @media (max-width: 600px) {
  456. #${UI_IDS.apiKeyModalContent} {
  457. width: 95vw;
  458. padding: 16px;
  459. }
  460. }
  461. #${UI_IDS.apiKeyModalHeader} {
  462. display: flex;
  463. align-items: center;
  464. margin-bottom: 20px;
  465. position: relative;
  466. }
  467. #${UI_IDS.apiKeyModalHeader} > div:first-child { /* Icon container */
  468. color: #c62a88;
  469. margin-right: 12px;
  470. }
  471. #${UI_IDS.apiKeyModalHeader} > div:last-child { /* Title container */
  472. font-size: 22px;
  473. font-weight: 600;
  474. color: #fff;
  475. }
  476. #${UI_IDS.apiKeyModalCloseButton} {
  477. position: absolute;
  478. top: 0;
  479. right: 0;
  480. background: none;
  481. border: none;
  482. cursor: pointer;
  483. color: #fff;
  484. font-size: 24px;
  485. transition: color 0.3s ease;
  486. }
  487. #${UI_IDS.apiKeyModalInputContainer} {
  488. position: relative;
  489. width: 100%;
  490. margin-bottom: 16px;
  491. }
  492. #${UI_IDS.apiKeyModalInputContainer} label {
  493. display: block;
  494. color: #999;
  495. font-size: 14px;
  496. margin-bottom: 8px;
  497. }
  498. #${UI_IDS.apiKeyModalInput} {
  499. width: 100%;
  500. padding: 12px 36px 12px 12px;
  501. box-sizing: border-box;
  502. background: #2a2a2c;
  503. color: #fff;
  504. border: 1px solid #333;
  505. border-radius: 6px;
  506. outline: none;
  507. font-size: 14px;
  508. }
  509. #${UI_IDS.apiKeyModalInput}:focus {
  510. border-color: #c62a88;
  511. }
  512. #${UI_IDS.geminiApiKeyInput} {
  513. width: 100%;
  514. padding: 12px 36px 12px 12px;
  515. box-sizing: border-box;
  516. background: #2a2a2c;
  517. color: #fff;
  518. border: 1px solid #333;
  519. border-radius: 6px;
  520. outline: none;
  521. font-size: 14px;
  522. }
  523. #${UI_IDS.geminiApiKeyInput}:focus {
  524. border-color: #c62a88;
  525. }
  526. #${UI_IDS.apiKeyModalClearButton}, #${UI_IDS.geminiApiKeyClearButton} {
  527. position: absolute;
  528. top: 68%;
  529. right: 10px;
  530. transform: translateY(-50%);
  531. width: 20px;
  532. height: 20px;
  533. display: flex;
  534. align-items: center;
  535. justify-content: center;
  536. background: none;
  537. border: none;
  538. cursor: pointer;
  539. font-size: 16px;
  540. font-weight: 500;
  541. color: #aaa;
  542. transition: color 0.2s ease;
  543. padding: 0;
  544. }
  545. #${UI_IDS.apiKeyModalClearButton}:hover, #${UI_IDS.geminiApiKeyClearButton}:hover {
  546. color: #c62a88;
  547. }
  548. #${UI_IDS.apiKeyModalSaveButton} {
  549. width: 100%;
  550. padding: 12px;
  551. background: #a02553;
  552. border: none;
  553. border-radius: 6px;
  554. color: white;
  555. cursor: pointer;
  556. font-size: 15px;
  557. font-weight: 500;
  558. transition: all 0.2s ease;
  559. }
  560. #${UI_IDS.apiKeyModalSaveButton}:hover {
  561. background: #c62a88;
  562. }`;
  563. document.head.appendChild(styleEl);
  564. },
  565. };
  566.  
  567. const UIManager = {
  568. importButton: null,
  569. _createImportButton: () => {
  570. return new Promise((resolve) => {
  571. const chatId = getChatId();
  572. IngestDBManager.getState(chatId)
  573. .then((state) => {
  574. const button = document.createElement("button");
  575. button.type = "button";
  576. button.id = UI_IDS.importButton;
  577. button.className = CSS_CLASSES.button;
  578. button.setAttribute("data-state", "closed");
  579.  
  580. if (state && state.repoUrl) {
  581. const repoName =
  582. getRepoNamefromURL(state.repoUrl)
  583. ?.split("/")
  584. ?.pop()
  585. ?.slice(0, 10)
  586. ?.replace(/^./, (c) => c.toUpperCase()) || "Repo";
  587.  
  588. button.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">${repoName}</span></div>`;
  589. button.setAttribute("aria-label", "Repository imported");
  590. button.dataset.mode = "on";
  591. button.classList.add(CSS_CLASSES.importButtonOn);
  592. } else {
  593. button.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
  594. button.setAttribute("aria-label", "Import repository");
  595. button.dataset.mode = "off";
  596. }
  597.  
  598. button.addEventListener("click", (e) => {
  599. e.preventDefault();
  600. e.stopPropagation();
  601.  
  602. if (!apiUrl || !geminiApiKey) {
  603. ApiKeyModal.show();
  604. return;
  605. }
  606.  
  607. const chatId = getChatId();
  608. if (!chatId) {
  609. alert("No chat ID found, can't import repo");
  610. Logger.log("No chat ID found, skipping import button click");
  611. return;
  612. }
  613.  
  614. if (RepoUrlModal._isShown) {
  615. RepoUrlModal._isShown = false;
  616. return;
  617. }
  618.  
  619. RepoUrlModal.show();
  620. });
  621. resolve(button);
  622. })
  623. .catch((err) => {
  624. Logger.error("Failed to create import button:", err);
  625. resolve(null);
  626. });
  627. });
  628. },
  629. injectImportButton: () => {
  630. return new Promise((resolve) => {
  631. // Find the exact container with model selector, thinking level, and attach buttons
  632. const messageActionsContainer = document.querySelector(
  633. selectors.messageActions
  634. );
  635.  
  636. if (!messageActionsContainer) {
  637. Logger.log(
  638. "Message actions container not found with selector:",
  639. selectors.messageActions
  640. );
  641. resolve(false);
  642. return;
  643. }
  644.  
  645. // Check if button already exists
  646. if (messageActionsContainer.querySelector(`#${UI_IDS.importButton}`)) {
  647. Logger.log("Import button already exists");
  648. resolve(true);
  649. return;
  650. }
  651.  
  652. UIManager._createImportButton()
  653. .then((button) => {
  654. if (!button) {
  655. Logger.error("Import button creation failed.");
  656. resolve(false);
  657. return;
  658. }
  659.  
  660. UIManager.importButton = button;
  661.  
  662. // Insert the button as the last child (after attach button)
  663. messageActionsContainer.appendChild(button);
  664. Logger.log(
  665. "Import button injected successfully in message actions container"
  666. );
  667. resolve(true);
  668. })
  669. .catch((err) => {
  670. Logger.error("Failed to inject import button:", err);
  671. resolve(false);
  672. });
  673. });
  674. },
  675. };
  676.  
  677. const RepoUrlModal = {
  678. _isShown: false,
  679. _isValidRepoUrl: (url) => {
  680. const patterns = [
  681. /^git@[^:]+:.+\.git$/,
  682. /^https:\/\/[^/]+\/.+\.git$/,
  683. /^https:\/\/github\.com\/[^/]+\/[^/]+(\/tree\/[^/]+)?$/,
  684. /^https:\/\/gitlab\.com\/[^/]+\/[^/]+(\/-\/tree\/[^/]+)?$/,
  685. ];
  686.  
  687. return patterns.some((pattern) => pattern.test(url));
  688. },
  689. show: () => {
  690. return new Promise((resolve) => {
  691. const chatId = getChatId();
  692. IngestDBManager.getState(chatId)
  693. .then((state) => {
  694. if (
  695. document.getElementById(UI_IDS.repoUrlModal) ||
  696. RepoUrlModal._isShown
  697. ) {
  698. resolve();
  699. return;
  700. }
  701. RepoUrlModal._isShown = true;
  702. const wrapper = document.createElement("div");
  703. wrapper.id = UI_IDS.repoUrlModal;
  704. wrapper.innerHTML = `
  705. <div id="${UI_IDS.repoUrlModalContent}">
  706. <div id="${UI_IDS.repoUrlModalHeader}">
  707. <div><!-- Icon container -->
  708. <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-git2-icon lucide-folder-git-2"><path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5"/><circle cx="13" cy="12" r="2"/><path d="M18 19c-2.8 0-5-2.2-5-5v8"/><circle cx="20" cy="19" r="2"/></svg>
  709. </div>
  710. <div>Enter Repo URL</div><!-- Title -->
  711. <button id="${UI_IDS.repoUrlModalCloseButton}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
  712. </div>
  713. <div id="${UI_IDS.repoUrlModalDescription}">Enter the URL of the GitHub repository you want to import.</div>
  714. <div id="${UI_IDS.repoUrlModalInputContainer}">
  715. <input id="${UI_IDS.repoUrlModalInput}" type="text" placeholder="https://github.com/username/repo" />
  716. <button id="${UI_IDS.repoUrlModalClearButton}" aria-label="Clear input"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></button>
  717. </div>
  718. <button id="${UI_IDS.repoUrlModalSaveButton}">Import</button>
  719. </div>`;
  720. document.body.appendChild(wrapper);
  721. const input = wrapper.querySelector(`#${UI_IDS.repoUrlModalInput}`);
  722. if (input) {
  723. input.focus();
  724. input.value = (state && state.repoUrl) || "";
  725. }
  726. RepoUrlModal._attachEventListeners(wrapper);
  727. resolve();
  728. })
  729. .catch((err) => {
  730. Logger.error("Failed to show repo URL modal:", err);
  731. resolve();
  732. });
  733. });
  734. },
  735. _attachEventListeners: (modalElement) => {
  736. const urlInput = modalElement.querySelector(
  737. `#${UI_IDS.repoUrlModalInput}`
  738. );
  739. const saveButton = modalElement.querySelector(
  740. `#${UI_IDS.repoUrlModalSaveButton}`
  741. );
  742. const closeButton = modalElement.querySelector(
  743. `#${UI_IDS.repoUrlModalCloseButton}`
  744. );
  745. const clearButton = modalElement.querySelector(
  746. `#${UI_IDS.repoUrlModalClearButton}`
  747. );
  748.  
  749. modalElement.addEventListener("click", (e) => {
  750. if (e.target === modalElement) {
  751. const urlInput = modalElement.querySelector(
  752. `#${UI_IDS.repoUrlModalInput}`
  753. );
  754. if (urlInput && !urlInput.value) {
  755. const importButton = document.getElementById(UI_IDS.importButton);
  756. if (importButton) {
  757. importButton.classList.remove(CSS_CLASSES.importButtonOn);
  758. importButton.setAttribute("aria-label", "Import repository");
  759. importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
  760. importButton.dataset.mode = "off";
  761. }
  762.  
  763. IngestDBManager.deleteState(getChatId());
  764. }
  765. RepoUrlModal._isShown = false;
  766. modalElement.remove();
  767. }
  768. });
  769.  
  770. const updateClearButtonVisibility = () => {
  771. if (clearButton) {
  772. clearButton.style.display = urlInput.value ? "flex" : "none";
  773. }
  774. };
  775.  
  776. updateClearButtonVisibility();
  777.  
  778. if (clearButton) {
  779. clearButton.addEventListener("click", () => {
  780. urlInput.value = "";
  781. urlInput.focus();
  782. updateClearButtonVisibility();
  783. });
  784. }
  785.  
  786. urlInput.addEventListener("input", updateClearButtonVisibility);
  787.  
  788. const handleSave = () => {
  789. const url = urlInput.value.trim();
  790. if (url) {
  791. if (!RepoUrlModal._isValidRepoUrl(url)) {
  792. alert("Please enter a valid git repository URL.");
  793. return;
  794. }
  795.  
  796. RepoUrlModal._isShown = false;
  797. modalElement.remove();
  798.  
  799. const importButton = document.getElementById(UI_IDS.importButton);
  800. if (importButton) {
  801. importButton.classList.add(CSS_CLASSES.importButtonLoading);
  802. importButton.setAttribute("aria-label", "Importing repository...");
  803. importButton.disabled = true;
  804. }
  805.  
  806. new Promise((resolve, reject) => {
  807. GM_xmlhttpRequest({
  808. method: "POST",
  809. url: apiUrl,
  810. headers: {
  811. "Content-Type": "application/json",
  812. },
  813. data: JSON.stringify({
  814. url,
  815. }),
  816. onload: (res) => {
  817. if (res.status < 200 || res.status >= 300) {
  818. reject(
  819. new Error(`API Error (${res.status}): ${res.responseText}`)
  820. );
  821. return;
  822. }
  823. try {
  824. const data = JSON.parse(res.responseText);
  825. if (
  826. !data ||
  827. typeof data !== "object" ||
  828. !data.data ||
  829. !data.data.content ||
  830. !data.data.normalized ||
  831. !data.data.tree ||
  832. !data.data.index
  833. ) {
  834. reject(new Error("Invalid response data from API"));
  835. return;
  836. }
  837.  
  838. IngestDBManager.saveState({
  839. chatId: getChatId(),
  840. repoUrl: url,
  841. repoTree: data.data.tree,
  842. repoIndex: data.data.index,
  843. repoContent: data.data.content,
  844. repoNormalizedContent: data.data.normalized,
  845. })
  846. .then(() => {
  847. if (importButton) {
  848. const repoName =
  849. getRepoNamefromURL(url)
  850. ?.split("/")
  851. ?.pop()
  852. ?.slice(0, 10)
  853. ?.replace(/^./, (c) => c.toUpperCase()) || "Repo";
  854.  
  855. importButton.classList.add(CSS_CLASSES.importButtonOn);
  856. importButton.setAttribute(
  857. "aria-label",
  858. "Repository imported"
  859. );
  860. importButton.dataset.mode = "on";
  861. importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">${repoName}</span></div>`;
  862. }
  863. resolve();
  864. })
  865. .catch((err) => {
  866. reject(err);
  867. });
  868. } catch (err) {
  869. reject(err);
  870. }
  871. },
  872. onerror: (err) => {
  873. reject(err);
  874. },
  875. ontimeout: () => {
  876. reject(new Error("Request timed out"));
  877. },
  878. timeout: 30000,
  879. });
  880. })
  881. .catch((err) => {
  882. if (importButton) {
  883. importButton.classList.remove(CSS_CLASSES.importButtonLoading);
  884. importButton.classList.remove(CSS_CLASSES.importButtonOn);
  885. importButton.setAttribute("aria-label", "Import repository");
  886. importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
  887. importButton.dataset.mode = "off";
  888. importButton.disabled = false;
  889. }
  890. Logger.error("Error during repo import:", err);
  891. alert(`Failed to import repository: ${err.message}`);
  892. })
  893. .finally(() => {
  894. if (importButton) {
  895. importButton.classList.remove(CSS_CLASSES.importButtonLoading);
  896. importButton.disabled = false;
  897. }
  898. });
  899. } else {
  900. alert("Repo URL cannot be empty");
  901. }
  902. };
  903.  
  904. saveButton.addEventListener("click", handleSave);
  905.  
  906. urlInput.addEventListener("keydown", (e) => {
  907. if (e.key === "Enter") {
  908. handleSave();
  909. }
  910. });
  911.  
  912. closeButton.addEventListener("click", () => {
  913. const urlInput = modalElement.querySelector(
  914. `#${UI_IDS.repoUrlModalInput}`
  915. );
  916. if (urlInput && !urlInput.value) {
  917. const importButton = document.getElementById(UI_IDS.importButton);
  918. if (importButton) {
  919. importButton.classList.remove(CSS_CLASSES.importButtonOn);
  920. importButton.setAttribute("aria-label", "Import repository");
  921. importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
  922. importButton.dataset.mode = "off";
  923. }
  924.  
  925. IngestDBManager.deleteState(getChatId());
  926. }
  927. RepoUrlModal._isShown = false;
  928. modalElement.remove();
  929. });
  930. },
  931. };
  932.  
  933. const getAllFileNames = (index) => {
  934. Logger.log(
  935. "getAllFileNames: Processing index with",
  936. (index && index.length) || 0,
  937. "files"
  938. );
  939. return index.map((file, idx) => ({
  940. fileName: file.fileName,
  941. index: idx,
  942. }));
  943. };
  944.  
  945. const getFileContents = (index, indices) => {
  946. Logger.log("getFileContents: Requesting contents for indices", indices);
  947. if (!index || !Array.isArray(indices)) {
  948. Logger.log("getFileContents: Invalid input", {
  949. index: !!index,
  950. indices: !!indices,
  951. });
  952. return [];
  953. }
  954. const contents = indices
  955. .map((idx) => {
  956. const file = index[idx];
  957. if (file) {
  958. return {
  959. fileName: file.fileName,
  960. content: file.fileContent,
  961. };
  962. } else {
  963. return null;
  964. }
  965. })
  966. .filter(function (item) {
  967. return item !== null;
  968. });
  969. Logger.log(
  970. "getFileContents: Retrieved contents for",
  971. contents.length,
  972. "files"
  973. );
  974. return contents;
  975. };
  976.  
  977. const getRepoTree = () => {
  978. return new Promise((resolve) => {
  979. const chatId = getChatId();
  980. IngestDBManager.getState(chatId)
  981. .then((state) => {
  982. if (!state || !state.repoTree) {
  983. Logger.log("getRepoTree: No repo tree found in state");
  984. resolve(null);
  985. return;
  986. }
  987. Logger.log("getRepoTree: Retrieved repo tree");
  988. resolve(state.repoTree);
  989. })
  990. .catch((err) => {
  991. Logger.error("Failed to get repo tree:", err);
  992. resolve(null);
  993. });
  994. });
  995. };
  996.  
  997. // Simplified generateRelevantContext function
  998. const generateRelevantContext = (query) =>
  999. new Promise((resolve) => {
  1000. const chatId = getChatId();
  1001. IngestDBManager.getState(chatId).then((state) => {
  1002. if (!state?.repoIndex) return resolve(null);
  1003.  
  1004. const idx = state.repoIndex;
  1005. const fileList = getAllFileNames(idx);
  1006.  
  1007. const prompt = `Given the following list of files in a repository and a user query, return the indices of the most relevant files that would help answer the query. Only return the indices of files that are directly relevant.
  1008.  
  1009. Files in repository:
  1010. ${JSON.stringify(fileList, null, 2)}
  1011.  
  1012. User query: ${query}
  1013.  
  1014. Return the response in this exact JSON format:
  1015. {
  1016. "relevantIndices": [array of indices]
  1017. }`;
  1018.  
  1019. GM_xmlhttpRequest({
  1020. method: "POST",
  1021. url: `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiApiKey}`,
  1022. headers: { "Content-Type": "application/json" },
  1023. data: JSON.stringify({
  1024. contents: [
  1025. {
  1026. parts: [{ text: prompt }],
  1027. },
  1028. ],
  1029. generationConfig: {
  1030. responseMimeType: "application/json",
  1031. responseSchema: {
  1032. type: "OBJECT",
  1033. properties: {
  1034. relevantIndices: {
  1035. type: "ARRAY",
  1036. items: { type: "NUMBER" },
  1037. },
  1038. },
  1039. propertyOrdering: ["relevantIndices"],
  1040. },
  1041. },
  1042. }),
  1043. onload: (r) => {
  1044. if (r.status < 200 || r.status >= 300) {
  1045. Logger.error("Gemini error", r);
  1046. return resolve(null);
  1047. }
  1048. let relevantIndices;
  1049. try {
  1050. relevantIndices = JSON.parse(
  1051. JSON.parse(r.responseText).candidates[0].content.parts[0].text
  1052. ).relevantIndices;
  1053. } catch (e) {
  1054. Logger.error("Parse error", e);
  1055. return resolve(null);
  1056. }
  1057.  
  1058. if (!Array.isArray(relevantIndices)) {
  1059. Logger.error("Invalid relevantIndices format", relevantIndices);
  1060. return resolve(null);
  1061. }
  1062.  
  1063. const files = getFileContents(idx, relevantIndices);
  1064. if (!files.length) return resolve(null);
  1065.  
  1066. const context = files
  1067. .map((f) => `File: ${f.fileName}\nContent:\n${f.content}\n`)
  1068. .join("\n");
  1069. resolve(context);
  1070. },
  1071. onerror: (e) => {
  1072. Logger.error("Gemini request failed", e);
  1073. resolve(null);
  1074. },
  1075. });
  1076. });
  1077. });
  1078.  
  1079. // Fixed FetchInterceptor to prevent interference with pasting
  1080. const FetchInterceptor = {
  1081. originalFetch: null,
  1082. isIntercepting: false,
  1083. init: () => {
  1084. try {
  1085. if (typeof unsafeWindow === "undefined") {
  1086. Logger.error("FetchInterceptor: unsafeWindow is not available");
  1087. return;
  1088. }
  1089. const w = unsafeWindow;
  1090. w.t3ChatIngest = w.t3ChatIngest || { needIngest: false };
  1091. const originalFetch = w.fetch;
  1092. FetchInterceptor.originalFetch = originalFetch;
  1093.  
  1094. w.fetch = (input, initOptions = {}) => {
  1095. // Early return for non-relevant requests
  1096. const url = typeof input === "string" ? input : input?.url;
  1097. if (
  1098. !url ||
  1099. !url.includes("/api/chat") ||
  1100. url.includes("/api/chat/resume") ||
  1101. initOptions?.method !== "POST" ||
  1102. FetchInterceptor.isIntercepting
  1103. ) {
  1104. return originalFetch.call(w, input, initOptions);
  1105. }
  1106.  
  1107. const chatId = getChatId();
  1108. if (!chatId) {
  1109. return originalFetch.call(w, input, initOptions);
  1110. }
  1111.  
  1112. // Set intercepting flag immediately
  1113. FetchInterceptor.isIntercepting = true;
  1114.  
  1115. return IngestDBManager.getState(chatId)
  1116. .then((state) => {
  1117. if (!state || !state.repoUrl) {
  1118. Logger.log(
  1119. "FetchInterceptor: No repo URL in state, passing through"
  1120. );
  1121. return originalFetch.call(w, input, initOptions);
  1122. }
  1123.  
  1124. let data;
  1125. try {
  1126. data = JSON.parse(initOptions.body || "{}");
  1127. } catch (error) {
  1128. Logger.error(
  1129. "FetchInterceptor: Failed to parse request body",
  1130. error
  1131. );
  1132. return originalFetch.call(w, input, initOptions);
  1133. }
  1134.  
  1135. if (!Array.isArray(data.messages)) {
  1136. Logger.log("FetchInterceptor: No messages array in request");
  1137. return originalFetch.call(w, input, initOptions);
  1138. }
  1139.  
  1140. const messages = data.messages;
  1141. const lastIdx = messages.length - 1;
  1142. const lastMessage = messages[lastIdx];
  1143.  
  1144. let messageType = null;
  1145. let originalPrompt = null;
  1146.  
  1147. if (lastIdx < 0 || !lastMessage || lastMessage.role !== "user") {
  1148. Logger.log(
  1149. "FetchInterceptor: No valid user message found",
  1150. lastMessage
  1151. );
  1152. return originalFetch.call(w, input, initOptions);
  1153. }
  1154.  
  1155. if (
  1156. Array.isArray(lastMessage.parts) &&
  1157. lastMessage.parts.length > 0 &&
  1158. typeof lastMessage.parts[0].text === "string"
  1159. ) {
  1160. messageType = "parts";
  1161. originalPrompt = lastMessage.parts[0].text;
  1162. } else if (typeof lastMessage.content === "string") {
  1163. messageType = "content";
  1164. originalPrompt = lastMessage.content;
  1165. } else {
  1166. Logger.log(
  1167. "FetchInterceptor: No valid prompt found in last user message",
  1168. lastMessage
  1169. );
  1170. return originalFetch.call(w, input, initOptions);
  1171. }
  1172.  
  1173. Logger.log(
  1174. "FetchInterceptor: Intercepting fetch for ingest enhancement"
  1175. );
  1176.  
  1177. return Promise.all([
  1178. getRepoTree(),
  1179. generateRelevantContext(originalPrompt),
  1180. ])
  1181. .then(([tree, context]) => {
  1182. Logger.log(
  1183. "FetchInterceptor: Retrieved repo tree and context"
  1184. );
  1185.  
  1186. if (context) {
  1187. const importInstruction =
  1188. "The following information was retrieved from the repository. Please use these results to inform your response:\n";
  1189. const enhancedPrompt = `${importInstruction}\n[Repository Tree]\n${tree}\n\n[Repository Context]\n${context}\n\n[Original Message]\n${originalPrompt}`;
  1190. if (messageType === "parts") {
  1191. messages[lastIdx].parts[0].text = enhancedPrompt;
  1192. } else if (messageType === "content") {
  1193. messages[lastIdx].content = enhancedPrompt;
  1194. }
  1195. initOptions.body = JSON.stringify(data);
  1196. Logger.log(
  1197. "FetchInterceptor: Enhanced prompt with repository context"
  1198. );
  1199. } else {
  1200. Logger.log("FetchInterceptor: No context to add to prompt");
  1201. }
  1202.  
  1203. return originalFetch.call(w, input, initOptions);
  1204. })
  1205. .catch((error) => {
  1206. Logger.error(
  1207. "FetchInterceptor: Error during interception",
  1208. error
  1209. );
  1210. return originalFetch.call(w, input, initOptions);
  1211. });
  1212. })
  1213. .catch((error) => {
  1214. Logger.error("FetchInterceptor: Error getting state", error);
  1215. return originalFetch.call(w, input, initOptions);
  1216. })
  1217. .finally(() => {
  1218. FetchInterceptor.isIntercepting = false;
  1219. });
  1220. };
  1221.  
  1222. Logger.log("FetchInterceptor: Initialized successfully");
  1223. } catch (error) {
  1224. Logger.error("FetchInterceptor: Failed to initialize", error);
  1225. }
  1226. },
  1227. };
  1228.  
  1229. const IngestDBManager = {
  1230. db: null,
  1231.  
  1232. init: () => {
  1233. return new Promise((resolve, reject) => {
  1234. try {
  1235. const request = indexedDB.open(DB_NAME, DB_VERSION);
  1236.  
  1237. request.onerror = () => {
  1238. Logger.error("Failed to open IndexedDB");
  1239. reject(request.error);
  1240. };
  1241.  
  1242. request.onsuccess = (event) => {
  1243. IngestDBManager.db = event.target.result;
  1244. Logger.log("IndexedDB opened successfully");
  1245. resolve();
  1246. };
  1247.  
  1248. request.onupgradeneeded = (event) => {
  1249. const db = event.target.result;
  1250. if (!db.objectStoreNames.contains(STORE_NAME)) {
  1251. const store = db.createObjectStore(STORE_NAME, {
  1252. keyPath: "chatId",
  1253. });
  1254. store.createIndex("repoUrl", "repoUrl", { unique: false });
  1255. Logger.log("IndexedDB store created");
  1256. }
  1257. };
  1258. } catch (error) {
  1259. Logger.error("Error initializing IndexedDB:", error);
  1260. reject(error);
  1261. }
  1262. });
  1263. },
  1264.  
  1265. getAllStates: () => {
  1266. return new Promise((resolve, reject) => {
  1267. try {
  1268. const transaction = IngestDBManager.db.transaction(
  1269. [STORE_NAME],
  1270. "readonly"
  1271. );
  1272. const store = transaction.objectStore(STORE_NAME);
  1273. const request = store.getAll();
  1274.  
  1275. request.onsuccess = () => resolve(request.result);
  1276. request.onerror = () => reject(request.error);
  1277. } catch (error) {
  1278. reject(error);
  1279. }
  1280. });
  1281. },
  1282.  
  1283. getState: (chatId) => {
  1284. return new Promise((resolve, reject) => {
  1285. if (!chatId) {
  1286. resolve(null);
  1287. return;
  1288. }
  1289.  
  1290. try {
  1291. const transaction = IngestDBManager.db.transaction(
  1292. [STORE_NAME],
  1293. "readonly"
  1294. );
  1295. const store = transaction.objectStore(STORE_NAME);
  1296. const request = store.get(chatId);
  1297.  
  1298. request.onsuccess = () => resolve(request.result);
  1299. request.onerror = () => reject(request.error);
  1300. } catch (error) {
  1301. reject(error);
  1302. }
  1303. });
  1304. },
  1305.  
  1306. saveState: (state) => {
  1307. return new Promise((resolve, reject) => {
  1308. try {
  1309. const transaction = IngestDBManager.db.transaction(
  1310. [STORE_NAME],
  1311. "readwrite"
  1312. );
  1313. const store = transaction.objectStore(STORE_NAME);
  1314. const request = store.put(state);
  1315.  
  1316. request.onsuccess = () => resolve(request.result);
  1317. request.onerror = () => reject(request.error);
  1318. } catch (error) {
  1319. reject(error);
  1320. }
  1321. });
  1322. },
  1323.  
  1324. deleteState: (chatId) => {
  1325. return new Promise((resolve, reject) => {
  1326. try {
  1327. const transaction = IngestDBManager.db.transaction(
  1328. [STORE_NAME],
  1329. "readwrite"
  1330. );
  1331. const store = transaction.objectStore(STORE_NAME);
  1332. const request = store.delete(chatId);
  1333.  
  1334. request.onsuccess = () => resolve(request.result);
  1335. request.onerror = () => reject(request.error);
  1336. } catch (error) {
  1337. reject(error);
  1338. }
  1339. });
  1340. },
  1341.  
  1342. clearAll: () => {
  1343. return new Promise((resolve, reject) => {
  1344. try {
  1345. const transaction = IngestDBManager.db.transaction(
  1346. [STORE_NAME],
  1347. "readwrite"
  1348. );
  1349. const store = transaction.objectStore(STORE_NAME);
  1350. const request = store.clear();
  1351.  
  1352. request.onsuccess = () => resolve(request.result);
  1353. request.onerror = () => reject(request.error);
  1354. } catch (error) {
  1355. reject(error);
  1356. }
  1357. });
  1358. },
  1359. };
  1360.  
  1361. const MenuCommands = {
  1362. init: () => {
  1363. return new Promise((resolve) => {
  1364. try {
  1365. GM_registerMenuCommand("Toggle debug logs", () => {
  1366. safeGMGetValue(GM_STORAGE_KEYS.DEBUG, false)
  1367. .then((currentDebug) => {
  1368. const newDebug = !currentDebug;
  1369. return safeGMSetValue(GM_STORAGE_KEYS.DEBUG, newDebug);
  1370. })
  1371. .then(() => {
  1372. debugMode = !debugMode;
  1373. Logger.log(
  1374. `Debug mode toggled to: ${debugMode} via menu. Reloading...`
  1375. );
  1376. location.reload();
  1377. })
  1378. .catch((err) => {
  1379. Logger.error("Failed to toggle debug mode:", err);
  1380. });
  1381. });
  1382.  
  1383. GM_registerMenuCommand("Reset Gemini API Key", () => {
  1384. safeGMSetValue(GM_STORAGE_KEYS.GEMINI_API_KEY, "")
  1385. .then(() => {
  1386. geminiApiKey = null;
  1387. Logger.log("Gemini API Key reset via menu.");
  1388. location.reload();
  1389. })
  1390. .catch((err) => {
  1391. Logger.error("Failed to reset Gemini API key:", err);
  1392. });
  1393. });
  1394.  
  1395. GM_registerMenuCommand("Reset RepoGist API URL", () => {
  1396. safeGMSetValue(GM_STORAGE_KEYS.API_URL, "")
  1397. .then(() => {
  1398. apiUrl = null;
  1399. Logger.log("RepoGist API URL reset via menu.");
  1400. location.reload();
  1401. })
  1402. .catch((err) => {
  1403. Logger.error("Failed to reset RepoGist API URL:", err);
  1404. });
  1405. });
  1406.  
  1407. GM_registerMenuCommand("Reset IndexedDB for all chats", () => {
  1408. IngestDBManager.clearAll()
  1409. .then(() => {
  1410. Logger.log("IndexedDB reset via menu.");
  1411. location.reload();
  1412. })
  1413. .catch((err) => {
  1414. Logger.error("Failed to reset IndexedDB:", err);
  1415. });
  1416. });
  1417.  
  1418. Logger.log("Menu commands registered.");
  1419. resolve();
  1420. } catch (error) {
  1421. Logger.error("Error registering menu commands:", error);
  1422. resolve();
  1423. }
  1424. });
  1425. },
  1426. };
  1427.  
  1428. function main() {
  1429. try {
  1430. // Initialize debug mode safely
  1431. safeGMGetValue(GM_STORAGE_KEYS.DEBUG, false)
  1432. .then((value) => {
  1433. debugMode = value;
  1434. Logger.log(
  1435. `${SCRIPT_NAME} v${SCRIPT_VERSION} starting. Debug mode: ${debugMode}`
  1436. );
  1437.  
  1438. // Initialize all components
  1439. FetchInterceptor.init();
  1440.  
  1441. return Promise.all([MenuCommands.init(), IngestDBManager.init()]);
  1442. })
  1443. .then(() => {
  1444. StyleManager.injectGlobalStyles();
  1445.  
  1446. // Load API configuration safely
  1447. return Promise.all([
  1448. safeGMGetValue(GM_STORAGE_KEYS.API_URL),
  1449. safeGMGetValue(GM_STORAGE_KEYS.GEMINI_API_KEY),
  1450. ]);
  1451. })
  1452. .then(([url, key]) => {
  1453. apiUrl = url;
  1454. geminiApiKey = key;
  1455. if (!apiUrl || !geminiApiKey) {
  1456. Logger.log(
  1457. "RepoGist API URL or Gemini API Key not found. It will be requested upon first import attempt."
  1458. );
  1459. } else {
  1460. Logger.log("RepoGist API URL and Gemini API Key loaded.");
  1461. }
  1462.  
  1463. // Set up URL observer and button injection
  1464. let lastChatId = getChatId();
  1465. const urlObserver = new MutationObserver(() => {
  1466. const currentChatId = getChatId();
  1467. if (currentChatId !== lastChatId) {
  1468. lastChatId = currentChatId;
  1469. setTimeout(() => {
  1470. injectButtonWithRetry().catch((err) => {
  1471. Logger.error("Failed to inject button:", err);
  1472. });
  1473. }, 500);
  1474.  
  1475. IngestDBManager.getState(currentChatId)
  1476. .then((state) => {
  1477. const importButton = document.getElementById(
  1478. UI_IDS.importButton
  1479. );
  1480. if (importButton) {
  1481. if (state && state.repoUrl) {
  1482. const repoName =
  1483. getRepoNamefromURL(state.repoUrl)
  1484. ?.split("/")
  1485. ?.pop()
  1486. ?.slice(0, 10)
  1487. ?.replace(/^./, (c) => c.toUpperCase()) || "Repo";
  1488.  
  1489. importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">${repoName}</span></div>`;
  1490. importButton.classList.add(CSS_CLASSES.importButtonOn);
  1491. importButton.setAttribute(
  1492. "aria-label",
  1493. "Repository imported"
  1494. );
  1495. importButton.dataset.mode = "on";
  1496. } else {
  1497. importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
  1498. importButton.classList.remove(CSS_CLASSES.importButtonOn);
  1499. importButton.setAttribute(
  1500. "aria-label",
  1501. "Import repository"
  1502. );
  1503. importButton.dataset.mode = "off";
  1504. }
  1505. }
  1506. })
  1507. .catch((err) => {
  1508. Logger.error("Failed to update button state:", err);
  1509. });
  1510. }
  1511. });
  1512.  
  1513. urlObserver.observe(document.querySelector("title"), {
  1514. subtree: true,
  1515. characterData: true,
  1516. childList: true,
  1517. });
  1518.  
  1519. const injectButtonWithRetry = (maxRetries = 10, delay = 1000) => {
  1520. let retries = 0;
  1521.  
  1522. const tryInjection = () => {
  1523. return new Promise((resolve) => {
  1524. const injectionObserverTargetParent = document.querySelector(
  1525. selectors.messageActions
  1526. );
  1527.  
  1528. if (injectionObserverTargetParent) {
  1529. UIManager.injectImportButton()
  1530. .then((success) => {
  1531. if (success) {
  1532. Logger.log("Successfully injected import button");
  1533. resolve(true);
  1534. return;
  1535. }
  1536. throw new Error("Injection failed");
  1537. })
  1538. .catch(() => {
  1539. if (retries < maxRetries) {
  1540. retries++;
  1541. Logger.log(
  1542. `Retrying button injection (${retries}/${maxRetries})...`
  1543. );
  1544. setTimeout(() => {
  1545. tryInjection()
  1546. .then(resolve)
  1547. .catch(() => resolve(false));
  1548. }, delay);
  1549. } else {
  1550. resolve(false);
  1551. }
  1552. });
  1553. } else {
  1554. if (retries < maxRetries) {
  1555. retries++;
  1556. Logger.log(
  1557. `Container not found, retrying (${retries}/${maxRetries})...`
  1558. );
  1559. setTimeout(() => {
  1560. tryInjection()
  1561. .then(resolve)
  1562. .catch(() => resolve(false));
  1563. }, delay);
  1564. } else {
  1565. Logger.error("Container not found after max retries");
  1566. resolve(false);
  1567. }
  1568. }
  1569. });
  1570. };
  1571.  
  1572. return tryInjection();
  1573. };
  1574.  
  1575. // Initial injection with delay to ensure DOM is ready
  1576. setTimeout(() => {
  1577. injectButtonWithRetry()
  1578. .then((success) => {
  1579. if (!success) {
  1580. Logger.log(
  1581. "Initial button injection failed, setting up observers"
  1582. );
  1583.  
  1584. const documentObserver = new MutationObserver(() => {
  1585. const target = document.querySelector(
  1586. selectors.messageActions
  1587. );
  1588. if (
  1589. target &&
  1590. !target.querySelector(`#${UI_IDS.importButton}`)
  1591. ) {
  1592. UIManager.injectImportButton()
  1593. .then((success) => {
  1594. if (success) {
  1595. Logger.log("Button injected via document observer");
  1596. }
  1597. })
  1598. .catch((err) => {
  1599. Logger.error("Failed to inject button:", err);
  1600. });
  1601. }
  1602. });
  1603.  
  1604. documentObserver.observe(document.body, {
  1605. childList: true,
  1606. subtree: true,
  1607. });
  1608. }
  1609. })
  1610. .catch((err) => {
  1611. Logger.error("Failed to initialize button injection:", err);
  1612. });
  1613. }, 2000);
  1614.  
  1615. Logger.log(`${SCRIPT_NAME} v${SCRIPT_VERSION} initialized!`);
  1616. })
  1617. .catch((err) => {
  1618. Logger.error("Failed to initialize:", err);
  1619. });
  1620. } catch (error) {
  1621. Logger.error("Failed to initialize:", error);
  1622. }
  1623. }
  1624.  
  1625. main();
  1626. })();