mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-06 14:37:38 +00:00
646 lines
18 KiB
TypeScript
646 lines
18 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { authenticatedFetch } from '../../../utils/api';
|
|
import { DEFAULT_BRANCH, RECENT_COMMITS_LIMIT } from '../constants/constants';
|
|
import type {
|
|
GitApiErrorResponse,
|
|
GitBranchesResponse,
|
|
GitCommitSummary,
|
|
GitCommitsResponse,
|
|
GitDiffMap,
|
|
GitDiffResponse,
|
|
GitFileWithDiffResponse,
|
|
GitGenerateMessageResponse,
|
|
GitOperationResponse,
|
|
GitPanelController,
|
|
GitRemoteStatus,
|
|
GitStatusResponse,
|
|
UseGitPanelControllerOptions,
|
|
} from '../types/types';
|
|
import { getAllChangedFiles } from '../utils/gitPanelUtils';
|
|
import { useSelectedProvider } from './useSelectedProvider';
|
|
|
|
// ! use authenticatedFetch directly. fetchWithAuth is redundant
|
|
const fetchWithAuth = authenticatedFetch as (url: string, options?: RequestInit) => Promise<Response>;
|
|
|
|
async function readJson<T>(response: Response): Promise<T> {
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export function useGitPanelController({
|
|
selectedProject,
|
|
activeView,
|
|
onFileOpen,
|
|
}: UseGitPanelControllerOptions): GitPanelController {
|
|
const [gitStatus, setGitStatus] = useState<GitStatusResponse | null>(null);
|
|
const [gitDiff, setGitDiff] = useState<GitDiffMap>({});
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [currentBranch, setCurrentBranch] = useState('');
|
|
const [branches, setBranches] = useState<string[]>([]);
|
|
const [recentCommits, setRecentCommits] = useState<GitCommitSummary[]>([]);
|
|
const [commitDiffs, setCommitDiffs] = useState<GitDiffMap>({});
|
|
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null);
|
|
const [isCreatingBranch, setIsCreatingBranch] = useState(false);
|
|
const [isFetching, setIsFetching] = useState(false);
|
|
const [isPulling, setIsPulling] = useState(false);
|
|
const [isPushing, setIsPushing] = useState(false);
|
|
const [isPublishing, setIsPublishing] = useState(false);
|
|
const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);
|
|
|
|
const provider = useSelectedProvider();
|
|
|
|
const fetchFileDiff = useCallback(
|
|
async (filePath: string) => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth(
|
|
`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`,
|
|
);
|
|
const data = await readJson<GitDiffResponse>(response);
|
|
|
|
if (!data.error && data.diff) {
|
|
setGitDiff((previous) => ({
|
|
...previous,
|
|
[filePath]: data.diff as string,
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching file diff:', error);
|
|
}
|
|
},
|
|
[selectedProject],
|
|
);
|
|
|
|
const fetchGitStatus = useCallback(async () => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
|
|
const data = await readJson<GitStatusResponse>(response);
|
|
|
|
if (data.error) {
|
|
console.error('Git status error:', data.error);
|
|
setGitStatus({ error: data.error, details: data.details });
|
|
setCurrentBranch('');
|
|
return;
|
|
}
|
|
|
|
setGitStatus(data);
|
|
setCurrentBranch(data.branch || DEFAULT_BRANCH);
|
|
|
|
const changedFiles = getAllChangedFiles(data);
|
|
changedFiles.forEach((filePath) => {
|
|
void fetchFileDiff(filePath);
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching git status:', error);
|
|
setGitStatus({ error: 'Git operation failed', details: String(error) });
|
|
setCurrentBranch('');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [fetchFileDiff, selectedProject]);
|
|
|
|
const fetchBranches = useCallback(async () => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
|
|
const data = await readJson<GitBranchesResponse>(response);
|
|
|
|
if (!data.error && data.branches) {
|
|
setBranches(data.branches);
|
|
return;
|
|
}
|
|
|
|
setBranches([]);
|
|
} catch (error) {
|
|
console.error('Error fetching branches:', error);
|
|
setBranches([]);
|
|
}
|
|
}, [selectedProject]);
|
|
|
|
const fetchRemoteStatus = useCallback(async () => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);
|
|
const data = await readJson<GitRemoteStatus | GitApiErrorResponse>(response);
|
|
|
|
if (!data.error) {
|
|
setRemoteStatus(data as GitRemoteStatus);
|
|
return;
|
|
}
|
|
|
|
setRemoteStatus(null);
|
|
} catch (error) {
|
|
console.error('Error fetching remote status:', error);
|
|
setRemoteStatus(null);
|
|
}
|
|
}, [selectedProject]);
|
|
|
|
const switchBranch = useCallback(
|
|
async (branchName: string) => {
|
|
if (!selectedProject) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/checkout', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
branch: branchName,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (!data.success) {
|
|
console.error('Failed to switch branch:', data.error);
|
|
return false;
|
|
}
|
|
|
|
setCurrentBranch(branchName);
|
|
void fetchGitStatus();
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error switching branch:', error);
|
|
return false;
|
|
}
|
|
},
|
|
[fetchGitStatus, selectedProject],
|
|
);
|
|
|
|
const createBranch = useCallback(
|
|
async (branchName: string) => {
|
|
const trimmedBranchName = branchName.trim();
|
|
if (!selectedProject || !trimmedBranchName) {
|
|
return false;
|
|
}
|
|
|
|
setIsCreatingBranch(true);
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/create-branch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
branch: trimmedBranchName,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (!data.success) {
|
|
console.error('Failed to create branch:', data.error);
|
|
return false;
|
|
}
|
|
|
|
setCurrentBranch(trimmedBranchName);
|
|
void fetchBranches();
|
|
void fetchGitStatus();
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error creating branch:', error);
|
|
return false;
|
|
} finally {
|
|
setIsCreatingBranch(false);
|
|
}
|
|
},
|
|
[fetchBranches, fetchGitStatus, selectedProject],
|
|
);
|
|
|
|
const handleFetch = useCallback(async () => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
setIsFetching(true);
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/fetch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (data.success) {
|
|
void fetchGitStatus();
|
|
void fetchRemoteStatus();
|
|
return;
|
|
}
|
|
|
|
console.error('Fetch failed:', data.error);
|
|
} catch (error) {
|
|
console.error('Error fetching from remote:', error);
|
|
} finally {
|
|
setIsFetching(false);
|
|
}
|
|
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
|
|
|
|
const handlePull = useCallback(async () => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
setIsPulling(true);
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/pull', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (data.success) {
|
|
void fetchGitStatus();
|
|
void fetchRemoteStatus();
|
|
return;
|
|
}
|
|
|
|
console.error('Pull failed:', data.error);
|
|
} catch (error) {
|
|
console.error('Error pulling from remote:', error);
|
|
} finally {
|
|
setIsPulling(false);
|
|
}
|
|
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
|
|
|
|
const handlePush = useCallback(async () => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
setIsPushing(true);
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/push', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (data.success) {
|
|
void fetchGitStatus();
|
|
void fetchRemoteStatus();
|
|
return;
|
|
}
|
|
|
|
console.error('Push failed:', data.error);
|
|
} catch (error) {
|
|
console.error('Error pushing to remote:', error);
|
|
} finally {
|
|
setIsPushing(false);
|
|
}
|
|
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
|
|
|
|
const handlePublish = useCallback(async () => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
setIsPublishing(true);
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/publish', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
branch: currentBranch,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (data.success) {
|
|
void fetchGitStatus();
|
|
void fetchRemoteStatus();
|
|
return;
|
|
}
|
|
|
|
console.error('Publish failed:', data.error);
|
|
} catch (error) {
|
|
console.error('Error publishing branch:', error);
|
|
} finally {
|
|
setIsPublishing(false);
|
|
}
|
|
}, [currentBranch, fetchGitStatus, fetchRemoteStatus, selectedProject]);
|
|
|
|
const discardChanges = useCallback(
|
|
async (filePath: string) => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/discard', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
file: filePath,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (data.success) {
|
|
void fetchGitStatus();
|
|
return;
|
|
}
|
|
|
|
console.error('Discard failed:', data.error);
|
|
} catch (error) {
|
|
console.error('Error discarding changes:', error);
|
|
}
|
|
},
|
|
[fetchGitStatus, selectedProject],
|
|
);
|
|
|
|
const deleteUntrackedFile = useCallback(
|
|
async (filePath: string) => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/delete-untracked', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
file: filePath,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (data.success) {
|
|
void fetchGitStatus();
|
|
return;
|
|
}
|
|
|
|
console.error('Delete failed:', data.error);
|
|
} catch (error) {
|
|
console.error('Error deleting untracked file:', error);
|
|
}
|
|
},
|
|
[fetchGitStatus, selectedProject],
|
|
);
|
|
|
|
const fetchRecentCommits = useCallback(async () => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth(
|
|
`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=${RECENT_COMMITS_LIMIT}`,
|
|
);
|
|
const data = await readJson<GitCommitsResponse>(response);
|
|
|
|
if (!data.error && data.commits) {
|
|
setRecentCommits(data.commits);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching commits:', error);
|
|
}
|
|
}, [selectedProject]);
|
|
|
|
const fetchCommitDiff = useCallback(
|
|
async (commitHash: string) => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth(
|
|
`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`,
|
|
);
|
|
const data = await readJson<GitDiffResponse>(response);
|
|
|
|
if (!data.error && data.diff) {
|
|
setCommitDiffs((previous) => ({
|
|
...previous,
|
|
[commitHash]: data.diff as string,
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching commit diff:', error);
|
|
}
|
|
},
|
|
[selectedProject],
|
|
);
|
|
|
|
const generateCommitMessage = useCallback(
|
|
async (files: string[]) => {
|
|
if (!selectedProject || files.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const response = await authenticatedFetch('/api/git/generate-commit-message', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
files,
|
|
provider,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitGenerateMessageResponse>(response);
|
|
if (data.message) {
|
|
return data.message;
|
|
}
|
|
|
|
console.error('Failed to generate commit message:', data.error);
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Error generating commit message:', error);
|
|
return null;
|
|
}
|
|
},
|
|
[provider, selectedProject],
|
|
);
|
|
|
|
const commitChanges = useCallback(
|
|
async (message: string, files: string[]) => {
|
|
if (!selectedProject || !message.trim() || files.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/commit', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
message,
|
|
files,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (data.success) {
|
|
void fetchGitStatus();
|
|
void fetchRemoteStatus();
|
|
return true;
|
|
}
|
|
|
|
console.error('Commit failed:', data.error);
|
|
return false;
|
|
} catch (error) {
|
|
console.error('Error committing changes:', error);
|
|
return false;
|
|
}
|
|
},
|
|
[fetchGitStatus, fetchRemoteStatus, selectedProject],
|
|
);
|
|
|
|
const createInitialCommit = useCallback(async () => {
|
|
if (!selectedProject) {
|
|
return false;
|
|
}
|
|
|
|
setIsCreatingInitialCommit(true);
|
|
try {
|
|
const response = await fetchWithAuth('/api/git/initial-commit', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: selectedProject.name,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson<GitOperationResponse>(response);
|
|
if (data.success) {
|
|
void fetchGitStatus();
|
|
void fetchRemoteStatus();
|
|
return true;
|
|
}
|
|
|
|
console.error('Initial commit failed:', data.error);
|
|
alert(data.error || 'Failed to create initial commit');
|
|
return false;
|
|
} catch (error) {
|
|
console.error('Error creating initial commit:', error);
|
|
alert('Failed to create initial commit');
|
|
return false;
|
|
} finally {
|
|
setIsCreatingInitialCommit(false);
|
|
}
|
|
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
|
|
|
|
const openFile = useCallback(
|
|
async (filePath: string) => {
|
|
if (!onFileOpen) {
|
|
return;
|
|
}
|
|
|
|
if (!selectedProject) {
|
|
onFileOpen(filePath);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithAuth(
|
|
`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`,
|
|
);
|
|
const data = await readJson<GitFileWithDiffResponse>(response);
|
|
|
|
if (data.error) {
|
|
console.error('Error fetching file with diff:', data.error);
|
|
onFileOpen(filePath);
|
|
return;
|
|
}
|
|
|
|
onFileOpen(filePath, {
|
|
old_string: data.oldContent || '',
|
|
new_string: data.currentContent || '',
|
|
});
|
|
} catch (error) {
|
|
console.error('Error opening file:', error);
|
|
onFileOpen(filePath);
|
|
}
|
|
},
|
|
[onFileOpen, selectedProject],
|
|
);
|
|
|
|
const refreshAll = useCallback(() => {
|
|
void fetchGitStatus();
|
|
void fetchBranches();
|
|
void fetchRemoteStatus();
|
|
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus]);
|
|
|
|
useEffect(() => {
|
|
// Reset repository-scoped state when project changes to avoid stale UI.
|
|
setCurrentBranch('');
|
|
setBranches([]);
|
|
setGitStatus(null);
|
|
setRemoteStatus(null);
|
|
setGitDiff({});
|
|
setRecentCommits([]);
|
|
setCommitDiffs({});
|
|
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
void fetchGitStatus();
|
|
void fetchBranches();
|
|
void fetchRemoteStatus();
|
|
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedProject || activeView !== 'history') {
|
|
return;
|
|
}
|
|
|
|
void fetchRecentCommits();
|
|
}, [activeView, fetchRecentCommits, selectedProject]);
|
|
|
|
return {
|
|
gitStatus,
|
|
gitDiff,
|
|
isLoading,
|
|
currentBranch,
|
|
branches,
|
|
recentCommits,
|
|
commitDiffs,
|
|
remoteStatus,
|
|
isCreatingBranch,
|
|
isFetching,
|
|
isPulling,
|
|
isPushing,
|
|
isPublishing,
|
|
isCreatingInitialCommit,
|
|
refreshAll,
|
|
switchBranch,
|
|
createBranch,
|
|
handleFetch,
|
|
handlePull,
|
|
handlePush,
|
|
handlePublish,
|
|
discardChanges,
|
|
deleteUntrackedFile,
|
|
fetchCommitDiff,
|
|
generateCommitMessage,
|
|
commitChanges,
|
|
createInitialCommit,
|
|
openFile,
|
|
};
|
|
}
|