From e31fdcf3c99a9a0cf82bbbc11648cc4c1b93fb0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 18:49:04 +0000 Subject: [PATCH 1/2] feat: add cancel button to stop repository indexing immediately Expose AbortController in the indexing overlay/sidebar so users can bail out of chunking/embedding without waiting for the run to finish, and propagate AbortSignal into GitHub fetches so in-flight work stops promptly. Co-authored-by: Devansh Royal J --- src/app/[owner]/[repo]/page.tsx | 28 ++++++++- src/components/chat/ChatSidebar.tsx | 15 +++++ src/components/chat/IndexingOverlay.tsx | 41 ++++++++++++-- src/lib/github.abort.test.ts | 61 ++++++++++++++++++++ src/lib/github.ts | 75 ++++++++++++++++++++----- src/lib/indexer.ts | 63 ++++++++++++++------- 6 files changed, 245 insertions(+), 38 deletions(-) create mode 100644 src/lib/github.abort.test.ts diff --git a/src/app/[owner]/[repo]/page.tsx b/src/app/[owner]/[repo]/page.tsx index e5a5066..57a987d 100644 --- a/src/app/[owner]/[repo]/page.tsx +++ b/src/app/[owner]/[repo]/page.tsx @@ -97,9 +97,11 @@ export default function RepoPage({ const [fileBrowserOpen, setFileBrowserOpen] = useState(false); const [fileBrowserTab, setFileBrowserTab] = useState<"tree" | "chunks">("tree"); const [showDiagram, setShowDiagram] = useState(false); + const [isCancellingIndex, setIsCancellingIndex] = useState(false); const completedWhileHiddenRef = useRef(false); const indexStartTimeRef = useRef(null); + const indexAbortControllerRef = useRef(null); const chatLoadedRef = useRef(false); const messagesRef = useRef([]); const chatSessionsRef = useRef([]); @@ -336,7 +338,9 @@ export default function RepoPage({ if (!owner || !repo) return; completedWhileHiddenRef.current = false; indexStartTimeRef.current = Date.now(); + setIsCancellingIndex(false); const controller = new AbortController(); + indexAbortControllerRef.current = controller; const signal = controller.signal; let aborted = false; @@ -391,7 +395,14 @@ export default function RepoPage({ } }); } catch (err) { - if (err instanceof IndexAbortError || aborted) return; + if ( + err instanceof IndexAbortError + || aborted + || (err instanceof DOMException && err.name === "AbortError") + || (err instanceof Error && err.name === "AbortError") + ) { + return; + } const errorMessage = err instanceof Error ? err.message : String(err); if (shouldSuggestGitHubToken(errorMessage)) { safeSetState(setShowTokenInput, true); @@ -408,6 +419,9 @@ export default function RepoPage({ return () => { aborted = true; + if (indexAbortControllerRef.current === controller) { + indexAbortControllerRef.current = null; + } controller.abort(); }; }, [owner, repo, token, reindexKey]); @@ -575,6 +589,14 @@ export default function RepoPage({ } }, [owner, repo]); + const handleCancelIndexing = useCallback(() => { + if (isCancellingIndex) return; + setIsCancellingIndex(true); + storeRef.current.clear(); + indexAbortControllerRef.current?.abort(); + router.push("/"); + }, [isCancellingIndex, router]); + const handleDeleteEmbeddings = useCallback(async () => { if (!owner || !repo) return; const confirmed = typeof window !== "undefined" && window.confirm( @@ -1137,6 +1159,8 @@ ${context}`, onCreateChat={handleCreateChat} onCollapse={() => setSidebarCollapsed((v) => !v)} onRequestNotification={handleRequestNotificationPermission} + onCancelIndexing={handleCancelIndexing} + isCancellingIndex={isCancellingIndex} />
@@ -1147,6 +1171,8 @@ ${context}`, progressPercent={progressPercent} timeRemaining={timeRemaining} onRetry={() => { void handleClearCacheAndReindex(); }} + onCancel={handleCancelIndexing} + isCancelling={isCancellingIndex} /> )} diff --git a/src/components/chat/ChatSidebar.tsx b/src/components/chat/ChatSidebar.tsx index cbf6bc4..95781c9 100644 --- a/src/components/chat/ChatSidebar.tsx +++ b/src/components/chat/ChatSidebar.tsx @@ -24,6 +24,8 @@ interface ChatSidebarProps { onCreateChat: () => void; onCollapse: () => void; onRequestNotification: () => void; + onCancelIndexing?: () => void; + isCancellingIndex?: boolean; } export function ChatSidebar({ @@ -45,6 +47,8 @@ export function ChatSidebar({ onCreateChat, onCollapse, onRequestNotification, + onCancelIndexing, + isCancellingIndex, }: ChatSidebarProps) { const [interactiveChatId, setInteractiveChatId] = useState(null); @@ -89,6 +93,17 @@ export function ChatSidebar({ Notify when ready )} + {onCancelIndexing && ( + + )} )} {isIndexed && ● Indexed} diff --git a/src/components/chat/IndexingOverlay.tsx b/src/components/chat/IndexingOverlay.tsx index 303b248..5c94be9 100644 --- a/src/components/chat/IndexingOverlay.tsx +++ b/src/components/chat/IndexingOverlay.tsx @@ -8,7 +8,9 @@ interface IndexingOverlayProps { progressPercent: number; timeRemaining: string | null; onRetry: () => void; + onCancel?: () => void; isError?: boolean; + isCancelling?: boolean; } function formatBytes(bytes: number): string { @@ -183,8 +185,12 @@ export function IndexingOverlay({ progressPercent, timeRemaining, onRetry, + onCancel, isError, + isCancelling, }: IndexingOverlayProps) { + const [showDetails, setShowDetails] = useState(false); + if (isError && indexProgress?.message) { return (
@@ -199,9 +205,31 @@ export function IndexingOverlay({ ); } - const [showDetails, setShowDetails] = useState(false); const isBigRepo = (indexProgress?.estimatedSizeBytes ?? 0) > 1.5 * 1024 * 1024; + const cancelButton = onCancel ? ( + + ) : null; + if (!isBigRepo) { return (
@@ -218,18 +246,19 @@ export function IndexingOverlay({

- {indexProgress?.message ?? "Starting..."} + {isCancelling ? "Stopping…" : (indexProgress?.message ?? "Starting...")}

{indexProgress?.estimatedSizeBytes != null && indexProgress.estimatedSizeBytes > 0 && (

~{formatBytes(indexProgress.estimatedSizeBytes)}

)} - {timeRemaining && ( + {timeRemaining && !isCancelling && (

{timeRemaining} remaining

)} + {cancelButton}
); @@ -415,7 +444,7 @@ export function IndexingOverlay({ {showDetails && (

- {indexProgress?.message ?? "Starting…"} + {isCancelling ? "Stopping…" : (indexProgress?.message ?? "Starting…")}

{indexProgress?.estimatedSizeBytes != null && indexProgress.estimatedSizeBytes > 0 && ( @@ -423,7 +452,7 @@ export function IndexingOverlay({ ~{formatBytes(indexProgress.estimatedSizeBytes)} )} - {timeRemaining && ( + {timeRemaining && !isCancelling && ( {timeRemaining} remaining @@ -431,6 +460,8 @@ export function IndexingOverlay({
)} + + {cancelButton} diff --git a/src/lib/github.abort.test.ts b/src/lib/github.abort.test.ts new file mode 100644 index 0000000..a2daa88 --- /dev/null +++ b/src/lib/github.abort.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { fetchFileContent, isAbortError } from "./github"; + +describe("isAbortError", () => { + it("detects DOMException AbortError", () => { + expect(isAbortError(new DOMException("Aborted", "AbortError"))).toBe(true); + }); + + it("detects Error with AbortError name", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + expect(isAbortError(err)).toBe(true); + }); + + it("ignores unrelated errors", () => { + expect(isAbortError(new Error("boom"))).toBe(false); + expect(isAbortError("abort")).toBe(false); + expect(isAbortError(null)).toBe(false); + }); +}); + +describe("fetchFileContent abort signal", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("propagates AbortError when the external signal aborts", async () => { + const controller = new AbortController(); + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error("missing signal")); + return; + } + if (signal.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + signal.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + }); + }) + ); + + const pending = fetchFileContent( + "owner", + "repo", + "src/index.ts", + undefined, + "HEAD", + controller.signal + ); + controller.abort(); + await expect(pending).rejects.toSatisfy(isAbortError); + }); +}); diff --git a/src/lib/github.ts b/src/lib/github.ts index 05dff15..3b0446f 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -57,6 +57,50 @@ function headers(token?: string): HeadersInit { return h; } +/** True when a fetch/DOM abort was caused by an AbortSignal. */ +export function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ); +} + +/** + * Combine an optional external AbortSignal with a timeout controller. + * Returns the signal to pass to fetch and a cleanup function. + */ +function combineAbortSignals( + external: AbortSignal | undefined, + timeoutController: AbortController +): { signal: AbortSignal; cleanup: () => void } { + if (!external) { + return { signal: timeoutController.signal, cleanup: () => undefined }; + } + if (typeof AbortSignal.any === "function") { + return { + signal: AbortSignal.any([external, timeoutController.signal]), + cleanup: () => undefined, + }; + } + const combined = new AbortController(); + const onAbort = () => { + if (!combined.signal.aborted) combined.abort(); + }; + if (external.aborted || timeoutController.signal.aborted) { + combined.abort(); + return { signal: combined.signal, cleanup: () => undefined }; + } + external.addEventListener("abort", onAbort); + timeoutController.signal.addEventListener("abort", onAbort); + return { + signal: combined.signal, + cleanup: () => { + external.removeEventListener("abort", onAbort); + timeoutController.signal.removeEventListener("abort", onAbort); + }, + }; +} + /** * Fetch the full recursive file tree for a repository. * Returns the default branch commit SHA + all blob entries for that commit tree. @@ -64,11 +108,13 @@ function headers(token?: string): HeadersInit { export async function fetchRepoTree( owner: string, repo: string, - token?: string + token?: string, + signal?: AbortSignal ): Promise { // 1. Get default branch SHA const repoRes = await fetch(`${API_BASE}/repos/${owner}/${repo}`, { headers: headers(token), + signal, }); if (!repoRes.ok) { const details = await readGitHubErrorMessage(repoRes); @@ -89,7 +135,7 @@ export async function fetchRepoTree( // 2. Resolve the default branch HEAD commit for a stable snapshot. const commitRes = await fetch( `${API_BASE}/repos/${owner}/${repo}/commits/${encodeURIComponent(defaultBranch)}`, - { headers: headers(token) } + { headers: headers(token), signal } ); if (!commitRes.ok) { const details = await readGitHubErrorMessage(commitRes); @@ -118,7 +164,7 @@ export async function fetchRepoTree( // 3. Get the tree recursively for that exact commit tree. const treeRes = await fetch( `${API_BASE}/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`, - { headers: headers(token) } + { headers: headers(token), signal } ); if (!treeRes.ok) { const details = await readGitHubErrorMessage(treeRes); @@ -162,11 +208,12 @@ export async function compareCommits( repo: string, baseSha: string, headSha: string, - token?: string + token?: string, + signal?: AbortSignal ): Promise { const res = await fetch( `${API_BASE}/repos/${owner}/${repo}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`, - { headers: headers(token) } + { headers: headers(token), signal } ); if (!res.ok) { const details = await readGitHubErrorMessage(res); @@ -226,27 +273,28 @@ export async function fetchFileContent( repo: string, path: string, token?: string, - ref: string = "HEAD" + ref: string = "HEAD", + signal?: AbortSignal ): Promise { const encodedPath = path .split("/") .map((segment) => encodeURIComponent(segment)) .join("/"); const url = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${encodedPath}`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), RAW_FILE_FETCH_TIMEOUT_MS); + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => timeoutController.abort(), RAW_FILE_FETCH_TIMEOUT_MS); + const { signal: fetchSignal, cleanup } = combineAbortSignals(signal, timeoutController); try { const res = await fetch(url, { headers: headers(token), - signal: controller.signal, + signal: fetchSignal, }); if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.status}`); return res.text(); } catch (e) { - if ( - e instanceof DOMException - && e.name === "AbortError" - ) { + if (isAbortError(e)) { + // External cancel should propagate as AbortError so callers can stop indexing. + if (signal?.aborted) throw e; throw new Error( `Timed out fetching ${path}. Check your connection and try again.` ); @@ -254,6 +302,7 @@ export async function fetchFileContent( throw e; } finally { clearTimeout(timeoutId); + cleanup(); } } diff --git a/src/lib/indexer.ts b/src/lib/indexer.ts index d1667c0..d8a3b62 100644 --- a/src/lib/indexer.ts +++ b/src/lib/indexer.ts @@ -9,6 +9,7 @@ import { compareCommits, fetchRepoTree, fetchFileContent, + isAbortError, isIndexable, prioritiseFiles, } from "./github"; @@ -70,6 +71,21 @@ function checkAborted(signal?: AbortSignal): void { if (signal?.aborted) throw new IndexAbortError(); } +/** Re-throw abort-like errors as IndexAbortError so callers can stop cleanly. */ +function rethrowIfAborted(error: unknown, signal?: AbortSignal): void { + if (error instanceof IndexAbortError) throw error; + if (signal?.aborted || isAbortError(error)) throw new IndexAbortError(); +} + +async function withAbort(promise: Promise, signal?: AbortSignal): Promise { + try { + return await promise; + } catch (error) { + rethrowIfAborted(error, signal); + throw error; + } +} + const DIRECTORY_SUMMARY_LIMITS = { maxFilesPerDir: 120, maxCharsPerDir: 400_000, @@ -134,7 +150,7 @@ export async function indexRepository( total: 1, }); - const tree = await fetchRepoTree(owner, repo, token); + const tree = await withAbort(fetchRepoTree(owner, repo, token, signal), signal); checkAborted(signal); // Fail fast on truncated trees to avoid stale/partial context from incomplete repository views. @@ -208,7 +224,7 @@ export async function indexRepository( checkAborted(signal); try { // Fetch each file from the exact commit snapshot resolved during tree fetch. - const content = await fetchFileContent(owner, repo, file.path, token, tree.sha); + const content = await fetchFileContent(owner, repo, file.path, token, tree.sha, signal); checkAborted(signal); const lang = detectLanguage(file.path); let chunks: CodeChunk[] = []; @@ -263,7 +279,7 @@ export async function indexRepository( symbolNodes, }; } catch (e) { - if (e instanceof IndexAbortError) throw e; + rethrowIfAborted(e, signal); return { fileIndex, filePath: file.path, @@ -326,7 +342,10 @@ export async function indexRepository( if (blob && blob.sha !== tree.sha) { await store.clearPartialProgress(owner, repo); try { - const compare = await compareCommits(owner, repo, blob.sha, tree.sha, token); + const compare = await withAbort( + compareCommits(owner, repo, blob.sha, tree.sha, token, signal), + signal + ); const KNOWN_STATUSES = new Set(["added", "removed", "modified", "renamed"]); if (compare.files.length <= INCREMENTAL_CHANGED_FILES_LIMIT) { const pathsToRemove = new Set(); @@ -414,20 +433,23 @@ export async function indexRepository( current: 0, total: allNewChunks.length, }); - const embeddedNew = await embedChunks( - allNewChunks, - (done, total) => { - onProgress?.({ - phase: "embedding", - message: `Embedded ${done}/${total} new chunks`, - current: done, - total, - }); - }, - embedConfig.batchSize, - signal, - undefined, - embedConfig.workerCount + const embeddedNew = await withAbort( + embedChunks( + allNewChunks, + (done, total) => { + onProgress?.({ + phase: "embedding", + message: `Embedded ${done}/${total} new chunks`, + current: done, + total, + }); + }, + embedConfig.batchSize, + signal, + undefined, + embedConfig.workerCount + ), + signal ); store.insert(embeddedNew); } @@ -696,7 +718,8 @@ export async function indexRepository( for (const [fp] of fileChunkRanges) fileStatusMap.set(fp, "parsed"); let lastProgressMs = 0; - const newlyEmbedded = await embedChunks( + const newlyEmbedded = await withAbort( + embedChunks( chunksToEmbed, (done, _total) => { checkAborted(signal); @@ -757,6 +780,8 @@ export async function indexRepository( }); }, embedConfig.workerCount + ), + signal ); embedded = [...embeddedSoFar, ...newlyEmbedded]; } From dc89a70bcf6c6e6d05b45697dc586f48c073ee78 Mon Sep 17 00:00:00 2001 From: Sai Ravi Teja Gangavarapu <73626236+FloareDor@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:06:20 +0530 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/app/[owner]/[repo]/page.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/[owner]/[repo]/page.tsx b/src/app/[owner]/[repo]/page.tsx index 57a987d..7b8906f 100644 --- a/src/app/[owner]/[repo]/page.tsx +++ b/src/app/[owner]/[repo]/page.tsx @@ -589,13 +589,20 @@ export default function RepoPage({ } }, [owner, repo]); - const handleCancelIndexing = useCallback(() => { + const handleCancelIndexing = useCallback(async () => { if (isCancellingIndex) return; setIsCancellingIndex(true); - storeRef.current.clear(); indexAbortControllerRef.current?.abort(); + storeRef.current.clear(); + if (owner && repo) { + try { + await storeRef.current.clearPartialProgress(owner, repo); + } catch (e) { + console.warn("Failed to clear partial indexing progress:", e); + } + } router.push("/"); - }, [isCancellingIndex, router]); + }, [isCancellingIndex, owner, repo, router]); const handleDeleteEmbeddings = useCallback(async () => { if (!owner || !repo) return;