Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/app/[owner]/[repo]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);
const indexAbortControllerRef = useRef<AbortController | null>(null);
const chatLoadedRef = useRef(false);
const messagesRef = useRef<Message[]>([]);
const chatSessionsRef = useRef<ChatSession[]>([]);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -408,6 +419,9 @@ export default function RepoPage({

return () => {
aborted = true;
if (indexAbortControllerRef.current === controller) {
indexAbortControllerRef.current = null;
}
controller.abort();
};
}, [owner, repo, token, reindexKey]);
Expand Down Expand Up @@ -575,6 +589,21 @@ export default function RepoPage({
}
}, [owner, repo]);

const handleCancelIndexing = useCallback(async () => {
if (isCancellingIndex) return;
setIsCancellingIndex(true);
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, owner, repo, router]);

const handleDeleteEmbeddings = useCallback(async () => {
if (!owner || !repo) return;
const confirmed = typeof window !== "undefined" && window.confirm(
Expand Down Expand Up @@ -1137,6 +1166,8 @@ ${context}`,
onCreateChat={handleCreateChat}
onCollapse={() => setSidebarCollapsed((v) => !v)}
onRequestNotification={handleRequestNotificationPermission}
onCancelIndexing={handleCancelIndexing}
isCancellingIndex={isCancellingIndex}
/>

<main style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", background: "var(--bg-app)" }}>
Expand All @@ -1147,6 +1178,8 @@ ${context}`,
progressPercent={progressPercent}
timeRemaining={timeRemaining}
onRetry={() => { void handleClearCacheAndReindex(); }}
onCancel={handleCancelIndexing}
isCancelling={isCancellingIndex}
/>
)}

Expand Down
15 changes: 15 additions & 0 deletions src/components/chat/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface ChatSidebarProps {
onCreateChat: () => void;
onCollapse: () => void;
onRequestNotification: () => void;
onCancelIndexing?: () => void;
isCancellingIndex?: boolean;
}

export function ChatSidebar({
Expand All @@ -45,6 +47,8 @@ export function ChatSidebar({
onCreateChat,
onCollapse,
onRequestNotification,
onCancelIndexing,
isCancellingIndex,
}: ChatSidebarProps) {
const [interactiveChatId, setInteractiveChatId] = useState<string | null>(null);

Expand Down Expand Up @@ -89,6 +93,17 @@ export function ChatSidebar({
Notify when ready
</button>
)}
{onCancelIndexing && (
<button
type="button"
style={{ marginTop: 8, fontSize: "10px", padding: "2px 6px", background: "transparent", border: "1px solid var(--border-dark)", color: "var(--text-on-dark-muted)", cursor: isCancellingIndex ? "default" : "pointer", fontFamily: "var(--font-mono)", display: "block" }}
onClick={onCancelIndexing}
disabled={isCancellingIndex}
title="Stop indexing and return home"
>
{isCancellingIndex ? "Cancelling…" : "Cancel indexing"}
</button>
)}
</div>
)}
{isIndexed && <span style={{ fontSize: "12px", color: "#16a34a" }}>● Indexed</span>}
Expand Down
41 changes: 36 additions & 5 deletions src/components/chat/IndexingOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ interface IndexingOverlayProps {
progressPercent: number;
timeRemaining: string | null;
onRetry: () => void;
onCancel?: () => void;
isError?: boolean;
isCancelling?: boolean;
}

function formatBytes(bytes: number): string {
Expand Down Expand Up @@ -183,8 +185,12 @@ export function IndexingOverlay({
progressPercent,
timeRemaining,
onRetry,
onCancel,
isError,
isCancelling,
}: IndexingOverlayProps) {
const [showDetails, setShowDetails] = useState(false);

if (isError && indexProgress?.message) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
Expand All @@ -199,9 +205,31 @@ export function IndexingOverlay({
);
}

const [showDetails, setShowDetails] = useState(false);
const isBigRepo = (indexProgress?.estimatedSizeBytes ?? 0) > 1.5 * 1024 * 1024;

const cancelButton = onCancel ? (
<button
type="button"
onClick={onCancel}
disabled={isCancelling}
style={{
marginTop: 16,
background: "transparent",
color: isCancelling ? "var(--text-on-dark-muted)" : "var(--text-on-dark-secondary)",
border: "1px solid var(--border-dark)",
padding: "8px 14px",
cursor: isCancelling ? "default" : "pointer",
fontWeight: 600,
fontSize: "12px",
fontFamily: "var(--font-mono)",
letterSpacing: "0.02em",
}}
title="Stop indexing and return home"
>
{isCancelling ? "Cancelling…" : "Cancel indexing"}
</button>
) : null;

if (!isBigRepo) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
Expand All @@ -218,18 +246,19 @@ export function IndexingOverlay({
<div style={{ height: "100%", background: "#16a34a", width: `${progressPercent}%`, transition: "width 0.3s" }} />
</div>
<p style={{ fontSize: "0.85rem", color: "var(--text-on-dark-secondary)", marginBottom: 0 }}>
{indexProgress?.message ?? "Starting..."}
{isCancelling ? "Stopping…" : (indexProgress?.message ?? "Starting...")}
</p>
{indexProgress?.estimatedSizeBytes != null && indexProgress.estimatedSizeBytes > 0 && (
<p style={{ fontFamily: "var(--font-mono)", fontSize: "0.72rem", color: "var(--text-on-dark-muted)", margin: "4px 0 0 0" }}>
~{formatBytes(indexProgress.estimatedSizeBytes)}
</p>
)}
{timeRemaining && (
{timeRemaining && !isCancelling && (
<p style={{ fontFamily: "var(--font-mono)", fontSize: "0.72rem", color: "var(--text-on-dark-muted)", margin: "4px 0 0 0" }}>
{timeRemaining} remaining
</p>
)}
{cancelButton}
</div>
</div>
);
Expand Down Expand Up @@ -415,22 +444,24 @@ export function IndexingOverlay({
{showDetails && (
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 4 }}>
<p style={{ fontSize: "0.78rem", color: "var(--text-on-dark-secondary)", margin: 0, lineHeight: 1.5, fontFamily: "var(--font-mono)" }}>
{indexProgress?.message ?? "Starting…"}
{isCancelling ? "Stopping…" : (indexProgress?.message ?? "Starting…")}
</p>
<div style={{ display: "flex", gap: 14, flexWrap: "wrap" }}>
{indexProgress?.estimatedSizeBytes != null && indexProgress.estimatedSizeBytes > 0 && (
<span style={{ fontFamily: "var(--font-mono)", fontSize: "0.68rem", color: "var(--text-on-dark-muted)" }}>
~{formatBytes(indexProgress.estimatedSizeBytes)}
</span>
)}
{timeRemaining && (
{timeRemaining && !isCancelling && (
<span style={{ fontFamily: "var(--font-mono)", fontSize: "0.68rem", color: "var(--text-on-dark-muted)" }}>
{timeRemaining} remaining
</span>
)}
</div>
</div>
)}

{cancelButton}
</div>
</div>
</div>
Expand Down
61 changes: 61 additions & 0 deletions src/lib/github.abort.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading