fix(git-panel): prevent stale project-switch updates and remove blocking initial-commit alerts

Problem
Git panel status/diff requests could resolve after the user switched projects and still write into state (`gitStatus`, `gitDiff`, `currentBranch`), causing cross-project UI corruption. Also, `createInitialCommit` used `window.alert` on failure, which blocks the UI and prevents centralized error handling.

Root cause
`fetchGitStatus` and `fetchFileDiff` captured `selectedProject` in async callbacks without request cancellation or project-identity guards. The project-change reset effect did not abort in-flight status/diff fetches. `createInitialCommit` handled failures with alerts instead of thrown errors.

Changes
- Added a per-session `AbortController` in the project-reset `useEffect`, and abort on cleanup.
- Passed `AbortSignal` through status/diff flow:
  - `fetchGitStatus(signal)` -> `fetchWithAuth(..., { signal })` + `readJson(..., signal)`
  - `fetchFileDiff(filePath, signal)` -> `fetchWithAuth(..., { signal })` + `readJson(..., signal)`
- Captured `projectName` at the start of each async function and skipped state setters when:
  - `signal.aborted`, or
  - current selected project name differs from captured project name.
- Added abort-aware JSON parsing helper and ignored `AbortError` noise in catch paths.
- Removed `alert(...)` from `createInitialCommit`.
- On API failure now throws:
  - `new Error(data.error || 'Failed to create initial commit')`
- In catch now rethrows normalized error:
  - `new Error(error?.message || 'Failed to create initial commit')`
- Kept existing success refresh behavior (`fetchGitStatus`, `fetchRemoteStatus`) and `setIsCreatingInitialCommit(false)` in `finally`.

Result
- Stale status/diff responses from prior projects are ignored and do not corrupt current project UI.
- Initial commit failures no longer block the UI; callers can surface errors via toast/notification.
This commit is contained in:
Haileyesus
2026-02-23 10:11:55 +03:00
parent a08601df60
commit 30c5da8984

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { DEFAULT_BRANCH, RECENT_COMMITS_LIMIT } from '../constants/constants'; import { DEFAULT_BRANCH, RECENT_COMMITS_LIMIT } from '../constants/constants';
import type { import type {
@@ -22,8 +22,22 @@ import { useSelectedProvider } from './useSelectedProvider';
// ! use authenticatedFetch directly. fetchWithAuth is redundant // ! use authenticatedFetch directly. fetchWithAuth is redundant
const fetchWithAuth = authenticatedFetch as (url: string, options?: RequestInit) => Promise<Response>; const fetchWithAuth = authenticatedFetch as (url: string, options?: RequestInit) => Promise<Response>;
async function readJson<T>(response: Response): Promise<T> { function isAbortError(error: unknown): boolean {
return response.json() as Promise<T>; return error instanceof DOMException && error.name === 'AbortError';
}
async function readJson<T>(response: Response, signal?: AbortSignal): Promise<T> {
if (signal?.aborted) {
throw new DOMException('Request aborted', 'AbortError');
}
const data = (await response.json()) as T;
if (signal?.aborted) {
throw new DOMException('Request aborted', 'AbortError');
}
return data;
} }
export function useGitPanelController({ export function useGitPanelController({
@@ -45,20 +59,36 @@ export function useGitPanelController({
const [isPushing, setIsPushing] = useState(false); const [isPushing, setIsPushing] = useState(false);
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false); const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);
const selectedProjectNameRef = useRef<string | null>(selectedProject?.name ?? null);
useEffect(() => {
selectedProjectNameRef.current = selectedProject?.name ?? null;
}, [selectedProject]);
const provider = useSelectedProvider(); const provider = useSelectedProvider();
const fetchFileDiff = useCallback( const fetchFileDiff = useCallback(
async (filePath: string) => { async (filePath: string, signal?: AbortSignal) => {
if (!selectedProject) { if (!selectedProject) {
return; return;
} }
const projectName = selectedProject.name;
try { try {
const response = await fetchWithAuth( const response = await fetchWithAuth(
`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`, `/api/git/diff?project=${encodeURIComponent(projectName)}&file=${encodeURIComponent(filePath)}`,
{ signal },
); );
const data = await readJson<GitDiffResponse>(response); const data = await readJson<GitDiffResponse>(response, signal);
if (
signal?.aborted ||
selectedProjectNameRef.current !== projectName ||
selectedProject.name !== projectName
) {
return;
}
if (!data.error && data.diff) { if (!data.error && data.diff) {
setGitDiff((previous) => ({ setGitDiff((previous) => ({
@@ -67,21 +97,35 @@ export function useGitPanelController({
})); }));
} }
} catch (error) { } catch (error) {
if (signal?.aborted || isAbortError(error)) {
return;
}
console.error('Error fetching file diff:', error); console.error('Error fetching file diff:', error);
} }
}, },
[selectedProject], [selectedProject],
); );
const fetchGitStatus = useCallback(async () => { const fetchGitStatus = useCallback(async (signal?: AbortSignal) => {
if (!selectedProject) { if (!selectedProject) {
return; return;
} }
const projectName = selectedProject.name;
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`); const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(projectName)}`, { signal });
const data = await readJson<GitStatusResponse>(response); const data = await readJson<GitStatusResponse>(response, signal);
if (
signal?.aborted ||
selectedProjectNameRef.current !== projectName ||
selectedProject.name !== projectName
) {
return;
}
if (data.error) { if (data.error) {
console.error('Git status error:', data.error); console.error('Git status error:', data.error);
@@ -95,13 +139,32 @@ export function useGitPanelController({
const changedFiles = getAllChangedFiles(data); const changedFiles = getAllChangedFiles(data);
changedFiles.forEach((filePath) => { changedFiles.forEach((filePath) => {
void fetchFileDiff(filePath); void fetchFileDiff(filePath, signal);
}); });
} catch (error) { } catch (error) {
if (signal?.aborted || isAbortError(error)) {
return;
}
if (
selectedProjectNameRef.current !== projectName ||
selectedProject.name !== projectName
) {
return;
}
console.error('Error fetching git status:', error); console.error('Error fetching git status:', error);
setGitStatus({ error: 'Git operation failed', details: String(error) }); setGitStatus({ error: 'Git operation failed', details: String(error) });
setCurrentBranch(''); setCurrentBranch('');
} finally { } finally {
if (
signal?.aborted ||
selectedProjectNameRef.current !== projectName ||
selectedProject.name !== projectName
) {
return;
}
setIsLoading(false); setIsLoading(false);
} }
}, [fetchFileDiff, selectedProject]); }, [fetchFileDiff, selectedProject]);
@@ -533,12 +596,10 @@ export function useGitPanelController({
} }
console.error('Initial commit failed:', data.error); console.error('Initial commit failed:', data.error);
alert(data.error || 'Failed to create initial commit'); throw new Error(data.error || 'Failed to create initial commit');
return false;
} catch (error) { } catch (error) {
console.error('Error creating initial commit:', error); console.error('Error creating initial commit:', error);
alert('Failed to create initial commit'); throw new Error((error as { message?: string })?.message || 'Failed to create initial commit');
return false;
} finally { } finally {
setIsCreatingInitialCommit(false); setIsCreatingInitialCommit(false);
} }
@@ -586,6 +647,8 @@ export function useGitPanelController({
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus]); }, [fetchBranches, fetchGitStatus, fetchRemoteStatus]);
useEffect(() => { useEffect(() => {
const controller = new AbortController();
// Reset repository-scoped state when project changes to avoid stale UI. // Reset repository-scoped state when project changes to avoid stale UI.
setCurrentBranch(''); setCurrentBranch('');
setBranches([]); setBranches([]);
@@ -594,14 +657,21 @@ export function useGitPanelController({
setGitDiff({}); setGitDiff({});
setRecentCommits([]); setRecentCommits([]);
setCommitDiffs({}); setCommitDiffs({});
setIsLoading(false);
if (!selectedProject) { if (!selectedProject) {
return; return () => {
controller.abort();
};
} }
void fetchGitStatus(); void fetchGitStatus(controller.signal);
void fetchBranches(); void fetchBranches();
void fetchRemoteStatus(); void fetchRemoteStatus();
return () => {
controller.abort();
};
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]); }, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);
useEffect(() => { useEffect(() => {