mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-06 06:27:42 +00:00
refactor(git-panel): Make GitPanel a feature based component and update imports
This commit is contained in:
File diff suppressed because it is too large
Load Diff
70
src/components/git-panel/constants/constants.ts
Normal file
70
src/components/git-panel/constants/constants.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types';
|
||||||
|
|
||||||
|
export const DEFAULT_BRANCH = 'main';
|
||||||
|
export const RECENT_COMMITS_LIMIT = 10;
|
||||||
|
|
||||||
|
export const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [
|
||||||
|
{ key: 'modified', status: 'M' },
|
||||||
|
{ key: 'added', status: 'A' },
|
||||||
|
{ key: 'deleted', status: 'D' },
|
||||||
|
{ key: 'untracked', status: 'U' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FILE_STATUS_LABELS: Record<FileStatusCode, string> = {
|
||||||
|
M: 'Modified',
|
||||||
|
A: 'Added',
|
||||||
|
D: 'Deleted',
|
||||||
|
U: 'Untracked',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FILE_STATUS_BADGE_CLASSES: Record<FileStatusCode, string> = {
|
||||||
|
M: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50',
|
||||||
|
A: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50',
|
||||||
|
D: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50',
|
||||||
|
U: 'bg-muted text-muted-foreground border-border',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
|
||||||
|
discard: 'Discard Changes',
|
||||||
|
delete: 'Delete File',
|
||||||
|
commit: 'Confirm Commit',
|
||||||
|
pull: 'Confirm Pull',
|
||||||
|
push: 'Confirm Push',
|
||||||
|
publish: 'Publish Branch',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
||||||
|
discard: 'Discard',
|
||||||
|
delete: 'Delete',
|
||||||
|
commit: 'Commit',
|
||||||
|
pull: 'Pull',
|
||||||
|
push: 'Push',
|
||||||
|
publish: 'Publish',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
||||||
|
discard: 'bg-red-600 hover:bg-red-700',
|
||||||
|
delete: 'bg-red-600 hover:bg-red-700',
|
||||||
|
commit: 'bg-primary hover:bg-primary/90',
|
||||||
|
pull: 'bg-green-600 hover:bg-green-700',
|
||||||
|
push: 'bg-orange-600 hover:bg-orange-700',
|
||||||
|
publish: 'bg-purple-600 hover:bg-purple-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
|
||||||
|
discard: 'bg-red-100 dark:bg-red-900/30',
|
||||||
|
delete: 'bg-red-100 dark:bg-red-900/30',
|
||||||
|
commit: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
|
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
|
push: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
|
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
||||||
|
discard: 'text-red-600 dark:text-red-400',
|
||||||
|
delete: 'text-red-600 dark:text-red-400',
|
||||||
|
commit: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
pull: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
push: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
publish: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
};
|
||||||
645
src/components/git-panel/hooks/useGitPanelController.ts
Normal file
645
src/components/git-panel/hooks/useGitPanelController.ts
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/components/git-panel/hooks/useSelectedProvider.ts
Normal file
20
src/components/git-panel/hooks/useSelectedProvider.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useSelectedProvider() {
|
||||||
|
const [provider, setProvider] = useState(() => {
|
||||||
|
return localStorage.getItem('selected-provider') || 'claude';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Keep provider in sync when another tab changes the selected provider.
|
||||||
|
const handleStorageChange = () => {
|
||||||
|
const nextProvider = localStorage.getItem('selected-provider') || 'claude';
|
||||||
|
setProvider(nextProvider);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
135
src/components/git-panel/types/types.ts
Normal file
135
src/components/git-panel/types/types.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Project } from '../../../types/app';
|
||||||
|
|
||||||
|
export type GitPanelView = 'changes' | 'history';
|
||||||
|
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
|
||||||
|
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
|
||||||
|
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish';
|
||||||
|
|
||||||
|
export type FileDiffInfo = {
|
||||||
|
old_string: string;
|
||||||
|
new_string: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileOpenHandler = (filePath: string, diffInfo?: FileDiffInfo) => void;
|
||||||
|
|
||||||
|
export type GitPanelProps = {
|
||||||
|
selectedProject: Project | null;
|
||||||
|
isMobile?: boolean;
|
||||||
|
onFileOpen?: FileOpenHandler;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitStatusResponse = {
|
||||||
|
branch?: string;
|
||||||
|
hasCommits?: boolean;
|
||||||
|
modified?: string[];
|
||||||
|
added?: string[];
|
||||||
|
deleted?: string[];
|
||||||
|
untracked?: string[];
|
||||||
|
error?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitRemoteStatus = {
|
||||||
|
hasRemote?: boolean;
|
||||||
|
hasUpstream?: boolean;
|
||||||
|
branch?: string;
|
||||||
|
remoteBranch?: string;
|
||||||
|
remoteName?: string | null;
|
||||||
|
ahead?: number;
|
||||||
|
behind?: number;
|
||||||
|
isUpToDate?: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitCommitSummary = {
|
||||||
|
hash: string;
|
||||||
|
author: string;
|
||||||
|
email?: string;
|
||||||
|
date: string;
|
||||||
|
message: string;
|
||||||
|
stats?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitDiffMap = Record<string, string>;
|
||||||
|
|
||||||
|
export type GitStatusGroupEntry = {
|
||||||
|
key: GitStatusFileGroup;
|
||||||
|
status: FileStatusCode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfirmationRequest = {
|
||||||
|
type: ConfirmActionType;
|
||||||
|
message: string;
|
||||||
|
onConfirm: () => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseGitPanelControllerOptions = {
|
||||||
|
selectedProject: Project | null;
|
||||||
|
activeView: GitPanelView;
|
||||||
|
onFileOpen?: FileOpenHandler;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitPanelController = {
|
||||||
|
gitStatus: GitStatusResponse | null;
|
||||||
|
gitDiff: GitDiffMap;
|
||||||
|
isLoading: boolean;
|
||||||
|
currentBranch: string;
|
||||||
|
branches: string[];
|
||||||
|
recentCommits: GitCommitSummary[];
|
||||||
|
commitDiffs: GitDiffMap;
|
||||||
|
remoteStatus: GitRemoteStatus | null;
|
||||||
|
isCreatingBranch: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
isPulling: boolean;
|
||||||
|
isPushing: boolean;
|
||||||
|
isPublishing: boolean;
|
||||||
|
isCreatingInitialCommit: boolean;
|
||||||
|
refreshAll: () => void;
|
||||||
|
switchBranch: (branchName: string) => Promise<boolean>;
|
||||||
|
createBranch: (branchName: string) => Promise<boolean>;
|
||||||
|
handleFetch: () => Promise<void>;
|
||||||
|
handlePull: () => Promise<void>;
|
||||||
|
handlePush: () => Promise<void>;
|
||||||
|
handlePublish: () => Promise<void>;
|
||||||
|
discardChanges: (filePath: string) => Promise<void>;
|
||||||
|
deleteUntrackedFile: (filePath: string) => Promise<void>;
|
||||||
|
fetchCommitDiff: (commitHash: string) => Promise<void>;
|
||||||
|
generateCommitMessage: (files: string[]) => Promise<string | null>;
|
||||||
|
commitChanges: (message: string, files: string[]) => Promise<boolean>;
|
||||||
|
createInitialCommit: () => Promise<boolean>;
|
||||||
|
openFile: (filePath: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitApiErrorResponse = {
|
||||||
|
error?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitDiffResponse = GitApiErrorResponse & {
|
||||||
|
diff?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBranchesResponse = GitApiErrorResponse & {
|
||||||
|
branches?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitCommitsResponse = GitApiErrorResponse & {
|
||||||
|
commits?: GitCommitSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitOperationResponse = GitApiErrorResponse & {
|
||||||
|
success?: boolean;
|
||||||
|
output?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitGenerateMessageResponse = GitApiErrorResponse & {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitFileWithDiffResponse = GitApiErrorResponse & {
|
||||||
|
oldContent?: string;
|
||||||
|
currentContent?: string;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
isUntracked?: boolean;
|
||||||
|
};
|
||||||
26
src/components/git-panel/utils/gitPanelUtils.ts
Normal file
26
src/components/git-panel/utils/gitPanelUtils.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { FILE_STATUS_BADGE_CLASSES, FILE_STATUS_GROUPS, FILE_STATUS_LABELS } from '../constants/constants';
|
||||||
|
import type { FileStatusCode, GitStatusResponse } from '../types/types';
|
||||||
|
|
||||||
|
export function getAllChangedFiles(gitStatus: GitStatusResponse | null): string[] {
|
||||||
|
if (!gitStatus) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return FILE_STATUS_GROUPS.flatMap(({ key }) => gitStatus[key] || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChangedFileCount(gitStatus: GitStatusResponse | null): number {
|
||||||
|
return getAllChangedFiles(gitStatus).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasChangedFiles(gitStatus: GitStatusResponse | null): boolean {
|
||||||
|
return getChangedFileCount(gitStatus) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusLabel(status: FileStatusCode): string {
|
||||||
|
return FILE_STATUS_LABELS[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusBadgeClass(status: FileStatusCode): string {
|
||||||
|
return FILE_STATUS_BADGE_CLASSES[status] || FILE_STATUS_BADGE_CLASSES.U;
|
||||||
|
}
|
||||||
150
src/components/git-panel/view/GitPanel.tsx
Normal file
150
src/components/git-panel/view/GitPanel.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useGitPanelController } from '../hooks/useGitPanelController';
|
||||||
|
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
|
||||||
|
import ChangesView from '../view/changes/ChangesView';
|
||||||
|
import HistoryView from '../view/history/HistoryView';
|
||||||
|
import GitPanelHeader from '../view/GitPanelHeader';
|
||||||
|
import GitRepositoryErrorState from '../view/GitRepositoryErrorState';
|
||||||
|
import GitViewTabs from '../view/GitViewTabs';
|
||||||
|
import ConfirmActionModal from '../view/modals/ConfirmActionModal';
|
||||||
|
|
||||||
|
export default function GitPanel({ selectedProject, isMobile = false, onFileOpen }: GitPanelProps) {
|
||||||
|
const [activeView, setActiveView] = useState<GitPanelView>('changes');
|
||||||
|
const [wrapText, setWrapText] = useState(true);
|
||||||
|
const [hasExpandedFiles, setHasExpandedFiles] = useState(false);
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmationRequest | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
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,
|
||||||
|
} = useGitPanelController({
|
||||||
|
selectedProject,
|
||||||
|
activeView,
|
||||||
|
onFileOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const executeConfirmedAction = useCallback(async () => {
|
||||||
|
if (!confirmAction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionToExecute = confirmAction;
|
||||||
|
setConfirmAction(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await actionToExecute.onConfirm();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing confirmation action:', error);
|
||||||
|
}
|
||||||
|
}, [confirmAction]);
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||||
|
<p>Select a project to view source control</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-background">
|
||||||
|
<GitPanelHeader
|
||||||
|
isMobile={isMobile}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
branches={branches}
|
||||||
|
remoteStatus={remoteStatus}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isCreatingBranch={isCreatingBranch}
|
||||||
|
isFetching={isFetching}
|
||||||
|
isPulling={isPulling}
|
||||||
|
isPushing={isPushing}
|
||||||
|
isPublishing={isPublishing}
|
||||||
|
onRefresh={refreshAll}
|
||||||
|
onSwitchBranch={switchBranch}
|
||||||
|
onCreateBranch={createBranch}
|
||||||
|
onFetch={handleFetch}
|
||||||
|
onPull={handlePull}
|
||||||
|
onPush={handlePush}
|
||||||
|
onPublish={handlePublish}
|
||||||
|
onRequestConfirmation={setConfirmAction}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{gitStatus?.error ? (
|
||||||
|
<GitRepositoryErrorState error={gitStatus.error} details={gitStatus.details} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GitViewTabs
|
||||||
|
activeView={activeView}
|
||||||
|
isHidden={hasExpandedFiles}
|
||||||
|
onChange={setActiveView}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeView === 'changes' && (
|
||||||
|
<ChangesView
|
||||||
|
isMobile={isMobile}
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
gitDiff={gitDiff}
|
||||||
|
isLoading={isLoading}
|
||||||
|
wrapText={wrapText}
|
||||||
|
isCreatingInitialCommit={isCreatingInitialCommit}
|
||||||
|
onWrapTextChange={setWrapText}
|
||||||
|
onCreateInitialCommit={createInitialCommit}
|
||||||
|
onOpenFile={openFile}
|
||||||
|
onDiscardFile={discardChanges}
|
||||||
|
onDeleteFile={deleteUntrackedFile}
|
||||||
|
onCommitChanges={commitChanges}
|
||||||
|
onGenerateCommitMessage={generateCommitMessage}
|
||||||
|
onRequestConfirmation={setConfirmAction}
|
||||||
|
onExpandedFilesChange={setHasExpandedFiles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeView === 'history' && (
|
||||||
|
<HistoryView
|
||||||
|
isMobile={isMobile}
|
||||||
|
isLoading={isLoading}
|
||||||
|
recentCommits={recentCommits}
|
||||||
|
commitDiffs={commitDiffs}
|
||||||
|
wrapText={wrapText}
|
||||||
|
onFetchCommitDiff={fetchCommitDiff}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmActionModal
|
||||||
|
action={confirmAction}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
void executeConfirmedAction();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
src/components/git-panel/view/GitPanelHeader.tsx
Normal file
251
src/components/git-panel/view/GitPanelHeader.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
|
||||||
|
import NewBranchModal from './modals/NewBranchModal';
|
||||||
|
|
||||||
|
type GitPanelHeaderProps = {
|
||||||
|
isMobile: boolean;
|
||||||
|
currentBranch: string;
|
||||||
|
branches: string[];
|
||||||
|
remoteStatus: GitRemoteStatus | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isCreatingBranch: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
isPulling: boolean;
|
||||||
|
isPushing: boolean;
|
||||||
|
isPublishing: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onSwitchBranch: (branchName: string) => Promise<boolean>;
|
||||||
|
onCreateBranch: (branchName: string) => Promise<boolean>;
|
||||||
|
onFetch: () => Promise<void>;
|
||||||
|
onPull: () => Promise<void>;
|
||||||
|
onPush: () => Promise<void>;
|
||||||
|
onPublish: () => Promise<void>;
|
||||||
|
onRequestConfirmation: (request: ConfirmationRequest) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GitPanelHeader({
|
||||||
|
isMobile,
|
||||||
|
currentBranch,
|
||||||
|
branches,
|
||||||
|
remoteStatus,
|
||||||
|
isLoading,
|
||||||
|
isCreatingBranch,
|
||||||
|
isFetching,
|
||||||
|
isPulling,
|
||||||
|
isPushing,
|
||||||
|
isPublishing,
|
||||||
|
onRefresh,
|
||||||
|
onSwitchBranch,
|
||||||
|
onCreateBranch,
|
||||||
|
onFetch,
|
||||||
|
onPull,
|
||||||
|
onPush,
|
||||||
|
onPublish,
|
||||||
|
onRequestConfirmation,
|
||||||
|
}: GitPanelHeaderProps) {
|
||||||
|
const [showBranchDropdown, setShowBranchDropdown] = useState(false);
|
||||||
|
const [showNewBranchModal, setShowNewBranchModal] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setShowBranchDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const aheadCount = remoteStatus?.ahead || 0;
|
||||||
|
const behindCount = remoteStatus?.behind || 0;
|
||||||
|
const remoteName = remoteStatus?.remoteName || 'remote';
|
||||||
|
const shouldShowFetchButton = aheadCount > 0 || (behindCount > 0 && aheadCount > 0);
|
||||||
|
|
||||||
|
const requestPullConfirmation = () => {
|
||||||
|
onRequestConfirmation({
|
||||||
|
type: 'pull',
|
||||||
|
message: `Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}?`,
|
||||||
|
onConfirm: onPull,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPushConfirmation = () => {
|
||||||
|
onRequestConfirmation({
|
||||||
|
type: 'push',
|
||||||
|
message: `Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}?`,
|
||||||
|
onConfirm: onPush,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPublishConfirmation = () => {
|
||||||
|
onRequestConfirmation({
|
||||||
|
type: 'publish',
|
||||||
|
message: `Publish branch "${currentBranch}" to ${remoteName}?`,
|
||||||
|
onConfirm: onPublish,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchBranch = async (branchName: string) => {
|
||||||
|
const success = await onSwitchBranch(branchName);
|
||||||
|
if (success) {
|
||||||
|
setShowBranchDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`flex items-center justify-between border-b border-border/60 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBranchDropdown((previous) => !previous)}
|
||||||
|
className={`flex items-center hover:bg-accent rounded-lg transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
|
||||||
|
>
|
||||||
|
<GitBranch className={`text-muted-foreground ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
|
||||||
|
{remoteStatus?.hasRemote && (
|
||||||
|
<span className="flex items-center gap-1 text-xs">
|
||||||
|
{aheadCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="text-green-600 dark:text-green-400"
|
||||||
|
title={`${aheadCount} commit${aheadCount !== 1 ? 's' : ''} ahead`}
|
||||||
|
>
|
||||||
|
{'\u2191'}
|
||||||
|
{aheadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{behindCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="text-primary"
|
||||||
|
title={`${behindCount} commit${behindCount !== 1 ? 's' : ''} behind`}
|
||||||
|
>
|
||||||
|
{'\u2193'}
|
||||||
|
{behindCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{remoteStatus.isUpToDate && (
|
||||||
|
<span className="text-muted-foreground" title="Up to date with remote">
|
||||||
|
{'\u2713'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showBranchDropdown && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 w-64 bg-card rounded-xl shadow-lg border border-border z-50 overflow-hidden">
|
||||||
|
<div className="py-1 max-h-64 overflow-y-auto">
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<button
|
||||||
|
key={branch}
|
||||||
|
onClick={() => void handleSwitchBranch(branch)}
|
||||||
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${
|
||||||
|
branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center space-x-2">
|
||||||
|
{branch === currentBranch && <Check className="w-3 h-3 text-primary" />}
|
||||||
|
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewBranchModal(true);
|
||||||
|
setShowBranchDropdown(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
<span>Create new branch</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||||
|
{remoteStatus?.hasRemote && (
|
||||||
|
<>
|
||||||
|
{!remoteStatus.hasUpstream && (
|
||||||
|
<button
|
||||||
|
onClick={requestPublishConfirmation}
|
||||||
|
disabled={isPublishing}
|
||||||
|
className="px-2.5 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||||
|
title={`Publish branch "${currentBranch}" to ${remoteName}`}
|
||||||
|
>
|
||||||
|
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||||
|
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{remoteStatus.hasUpstream && !remoteStatus.isUpToDate && (
|
||||||
|
<>
|
||||||
|
{behindCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={requestPullConfirmation}
|
||||||
|
disabled={isPulling}
|
||||||
|
className="px-2.5 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||||
|
title={`Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}`}
|
||||||
|
>
|
||||||
|
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
|
||||||
|
<span>{isPulling ? 'Pulling...' : `Pull ${behindCount}`}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aheadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={requestPushConfirmation}
|
||||||
|
disabled={isPushing}
|
||||||
|
className="px-2.5 py-1 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||||
|
title={`Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}`}
|
||||||
|
>
|
||||||
|
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
|
||||||
|
<span>{isPushing ? 'Pushing...' : `Push ${aheadCount}`}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shouldShowFetchButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => void onFetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="px-2.5 py-1 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||||
|
title={`Fetch from ${remoteName}`}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`hover:bg-accent rounded-lg transition-colors ${isMobile ? 'p-1' : 'p-1.5'}`}
|
||||||
|
title="Refresh git status"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`text-muted-foreground ${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewBranchModal
|
||||||
|
isOpen={showNewBranchModal}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
isCreatingBranch={isCreatingBranch}
|
||||||
|
onClose={() => setShowNewBranchModal(false)}
|
||||||
|
onCreateBranch={onCreateBranch}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/git-panel/view/GitRepositoryErrorState.tsx
Normal file
27
src/components/git-panel/view/GitRepositoryErrorState.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { GitBranch } from 'lucide-react';
|
||||||
|
|
||||||
|
type GitRepositoryErrorStateProps = {
|
||||||
|
error: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GitRepositoryErrorState({ error, details }: GitRepositoryErrorStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground px-6 py-12">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-6">
|
||||||
|
<GitBranch className="w-8 h-8 opacity-40" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium mb-3 text-center text-foreground">{error}</h3>
|
||||||
|
{details && (
|
||||||
|
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{details}</p>
|
||||||
|
)}
|
||||||
|
<div className="p-4 bg-primary/5 rounded-xl border border-primary/10 max-w-md">
|
||||||
|
<p className="text-sm text-primary text-center">
|
||||||
|
<strong>Tip:</strong> Run{' '}
|
||||||
|
<code className="bg-primary/10 px-2 py-1 rounded-md font-mono text-xs">git init</code>{' '}
|
||||||
|
in your project directory to initialize git source control.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/git-panel/view/GitViewTabs.tsx
Normal file
45
src/components/git-panel/view/GitViewTabs.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { FileText, History } from 'lucide-react';
|
||||||
|
import type { GitPanelView } from '../types/types';
|
||||||
|
|
||||||
|
type GitViewTabsProps = {
|
||||||
|
activeView: GitPanelView;
|
||||||
|
isHidden: boolean;
|
||||||
|
onChange: (view: GitPanelView) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GitViewTabs({ activeView, isHidden, onChange }: GitViewTabsProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${
|
||||||
|
isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-16 opacity-100 translate-y-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => onChange('changes')}
|
||||||
|
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
activeView === 'changes'
|
||||||
|
? 'text-primary border-b-2 border-primary'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>Changes</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChange('history')}
|
||||||
|
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
activeView === 'history'
|
||||||
|
? 'text-primary border-b-2 border-primary'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
<span>History</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
src/components/git-panel/view/changes/ChangesView.tsx
Normal file
213
src/components/git-panel/view/changes/ChangesView.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { GitBranch, GitCommit, RefreshCw } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';
|
||||||
|
import { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils';
|
||||||
|
import CommitComposer from './CommitComposer';
|
||||||
|
import FileChangeList from './FileChangeList';
|
||||||
|
import FileSelectionControls from './FileSelectionControls';
|
||||||
|
import FileStatusLegend from './FileStatusLegend';
|
||||||
|
|
||||||
|
type ChangesViewProps = {
|
||||||
|
isMobile: boolean;
|
||||||
|
gitStatus: GitStatusResponse | null;
|
||||||
|
gitDiff: GitDiffMap;
|
||||||
|
isLoading: boolean;
|
||||||
|
wrapText: boolean;
|
||||||
|
isCreatingInitialCommit: boolean;
|
||||||
|
onWrapTextChange: (wrapText: boolean) => void;
|
||||||
|
onCreateInitialCommit: () => Promise<boolean>;
|
||||||
|
onOpenFile: (filePath: string) => Promise<void>;
|
||||||
|
onDiscardFile: (filePath: string) => Promise<void>;
|
||||||
|
onDeleteFile: (filePath: string) => Promise<void>;
|
||||||
|
onCommitChanges: (message: string, files: string[]) => Promise<boolean>;
|
||||||
|
onGenerateCommitMessage: (files: string[]) => Promise<string | null>;
|
||||||
|
onRequestConfirmation: (request: ConfirmationRequest) => void;
|
||||||
|
onExpandedFilesChange: (hasExpandedFiles: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChangesView({
|
||||||
|
isMobile,
|
||||||
|
gitStatus,
|
||||||
|
gitDiff,
|
||||||
|
isLoading,
|
||||||
|
wrapText,
|
||||||
|
isCreatingInitialCommit,
|
||||||
|
onWrapTextChange,
|
||||||
|
onCreateInitialCommit,
|
||||||
|
onOpenFile,
|
||||||
|
onDiscardFile,
|
||||||
|
onDeleteFile,
|
||||||
|
onCommitChanges,
|
||||||
|
onGenerateCommitMessage,
|
||||||
|
onRequestConfirmation,
|
||||||
|
onExpandedFilesChange,
|
||||||
|
}: ChangesViewProps) {
|
||||||
|
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const changedFiles = useMemo(() => getAllChangedFiles(gitStatus), [gitStatus]);
|
||||||
|
const hasExpandedFiles = expandedFiles.size > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gitStatus || gitStatus.error) {
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve previous behavior: every fresh status snapshot reselects changed files.
|
||||||
|
setSelectedFiles(new Set(getAllChangedFiles(gitStatus)));
|
||||||
|
}, [gitStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onExpandedFilesChange(hasExpandedFiles);
|
||||||
|
}, [hasExpandedFiles, onExpandedFilesChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
onExpandedFilesChange(false);
|
||||||
|
};
|
||||||
|
}, [onExpandedFilesChange]);
|
||||||
|
|
||||||
|
const toggleFileExpanded = useCallback((filePath: string) => {
|
||||||
|
setExpandedFiles((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(filePath)) {
|
||||||
|
next.delete(filePath);
|
||||||
|
} else {
|
||||||
|
next.add(filePath);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFileSelected = useCallback((filePath: string) => {
|
||||||
|
setSelectedFiles((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(filePath)) {
|
||||||
|
next.delete(filePath);
|
||||||
|
} else {
|
||||||
|
next.add(filePath);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestFileAction = useCallback(
|
||||||
|
(filePath: string, status: FileStatusCode) => {
|
||||||
|
if (status === 'U') {
|
||||||
|
onRequestConfirmation({
|
||||||
|
type: 'delete',
|
||||||
|
message: `Delete untracked file "${filePath}"? This action cannot be undone.`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await onDeleteFile(filePath);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRequestConfirmation({
|
||||||
|
type: 'discard',
|
||||||
|
message: `Discard all changes to "${filePath}"? This action cannot be undone.`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await onDiscardFile(filePath);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onDeleteFile, onDiscardFile, onRequestConfirmation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitSelectedFiles = useCallback(
|
||||||
|
(message: string) => {
|
||||||
|
return onCommitChanges(message, Array.from(selectedFiles));
|
||||||
|
},
|
||||||
|
[onCommitChanges, selectedFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateMessageForSelection = useCallback(() => {
|
||||||
|
return onGenerateCommitMessage(Array.from(selectedFiles));
|
||||||
|
}, [onGenerateCommitMessage, selectedFiles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CommitComposer
|
||||||
|
isMobile={isMobile}
|
||||||
|
selectedFileCount={selectedFiles.size}
|
||||||
|
isHidden={hasExpandedFiles}
|
||||||
|
onCommit={commitSelectedFiles}
|
||||||
|
onGenerateMessage={generateMessageForSelection}
|
||||||
|
onRequestConfirmation={onRequestConfirmation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{gitStatus && !gitStatus.error && (
|
||||||
|
<FileSelectionControls
|
||||||
|
isMobile={isMobile}
|
||||||
|
selectedCount={selectedFiles.size}
|
||||||
|
totalCount={changedFiles.length}
|
||||||
|
isHidden={hasExpandedFiles}
|
||||||
|
onSelectAll={() => setSelectedFiles(new Set(changedFiles))}
|
||||||
|
onDeselectAll={() => setSelectedFiles(new Set())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!gitStatus?.error && <FileStatusLegend isMobile={isMobile} />}
|
||||||
|
|
||||||
|
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : gitStatus?.hasCommits === false ? (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||||
|
<GitBranch className="w-7 h-7 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium mb-2 text-foreground">No commits yet</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||||
|
This repository doesn't have any commits yet. Create your first commit to start tracking changes.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => void onCreateInitialCommit()}
|
||||||
|
disabled={isCreatingInitialCommit}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
{isCreatingInitialCommit ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Creating Initial Commit...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GitCommit className="w-4 h-4" />
|
||||||
|
<span>Create Initial Commit</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : !gitStatus || !hasChangedFiles(gitStatus) ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
|
||||||
|
<GitCommit className="w-10 h-10 mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">No changes detected</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={isMobile ? 'pb-4' : ''}>
|
||||||
|
<FileChangeList
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
gitDiff={gitDiff}
|
||||||
|
expandedFiles={expandedFiles}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
isMobile={isMobile}
|
||||||
|
wrapText={wrapText}
|
||||||
|
onToggleSelected={toggleFileSelected}
|
||||||
|
onToggleExpanded={toggleFileExpanded}
|
||||||
|
onOpenFile={(filePath) => {
|
||||||
|
void onOpenFile(filePath);
|
||||||
|
}}
|
||||||
|
onToggleWrapText={() => onWrapTextChange(!wrapText)}
|
||||||
|
onRequestFileAction={requestFileAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/components/git-panel/view/changes/CommitComposer.tsx
Normal file
177
src/components/git-panel/view/changes/CommitComposer.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { MicButton } from '../../../MicButton.jsx';
|
||||||
|
import type { ConfirmationRequest } from '../../types/types';
|
||||||
|
|
||||||
|
type MicButtonProps = {
|
||||||
|
onTranscript?: (transcript: string) => void;
|
||||||
|
className?: string;
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MicButtonComponent = MicButton as unknown as (props: MicButtonProps) => JSX.Element;
|
||||||
|
|
||||||
|
type CommitComposerProps = {
|
||||||
|
isMobile: boolean;
|
||||||
|
selectedFileCount: number;
|
||||||
|
isHidden: boolean;
|
||||||
|
onCommit: (message: string) => Promise<boolean>;
|
||||||
|
onGenerateMessage: () => Promise<string | null>;
|
||||||
|
onRequestConfirmation: (request: ConfirmationRequest) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CommitComposer({
|
||||||
|
isMobile,
|
||||||
|
selectedFileCount,
|
||||||
|
isHidden,
|
||||||
|
onCommit,
|
||||||
|
onGenerateMessage,
|
||||||
|
onRequestConfirmation,
|
||||||
|
}: CommitComposerProps) {
|
||||||
|
const [commitMessage, setCommitMessage] = useState('');
|
||||||
|
const [isCommitting, setIsCommitting] = useState(false);
|
||||||
|
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(isMobile);
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!commitMessage.trim() || selectedFileCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCommitting(true);
|
||||||
|
try {
|
||||||
|
const success = await onCommit(commitMessage);
|
||||||
|
if (success) {
|
||||||
|
setCommitMessage('');
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
} finally {
|
||||||
|
setIsCommitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateMessage = async () => {
|
||||||
|
if (selectedFileCount === 0 || isGeneratingMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingMessage(true);
|
||||||
|
try {
|
||||||
|
const generatedMessage = await onGenerateMessage();
|
||||||
|
if (generatedMessage) {
|
||||||
|
setCommitMessage(generatedMessage);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingMessage(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestCommitConfirmation = () => {
|
||||||
|
const trimmedMessage = commitMessage.trim();
|
||||||
|
if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRequestConfirmation({
|
||||||
|
type: 'commit',
|
||||||
|
message: `Commit ${selectedFileCount} file${selectedFileCount !== 1 ? 's' : ''} with message: "${trimmedMessage}"?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setIsCommitting(true);
|
||||||
|
try {
|
||||||
|
const success = await onCommit(commitMessage);
|
||||||
|
if (success) {
|
||||||
|
setCommitMessage('');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsCommitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-300 ease-in-out ${
|
||||||
|
isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-96 opacity-100 translate-y-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isMobile && isCollapsed ? (
|
||||||
|
<div className="px-4 py-2 border-b border-border/60">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(false)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<GitCommit className="w-4 h-4" />
|
||||||
|
<span>Commit {selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''}</span>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-3 border-b border-border/60">
|
||||||
|
{isMobile && (
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">Commit Changes</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(true)}
|
||||||
|
className="p-1 hover:bg-accent rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4 rotate-180" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
value={commitMessage}
|
||||||
|
onChange={(event) => setCommitMessage(event.target.value)}
|
||||||
|
placeholder="Message (Ctrl+Enter to commit)"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground resize-none pr-20 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
|
||||||
|
rows={3}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleCommit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 top-2 flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => void handleGenerateMessage()}
|
||||||
|
disabled={selectedFileCount === 0 || isGeneratingMessage}
|
||||||
|
className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Generate commit message"
|
||||||
|
>
|
||||||
|
{isGeneratingMessage ? (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'none' }}>
|
||||||
|
<MicButtonComponent
|
||||||
|
onTranscript={(transcript) => setCommitMessage(transcript)}
|
||||||
|
mode="default"
|
||||||
|
className="p-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={requestCommitConfirmation}
|
||||||
|
disabled={!commitMessage.trim() || selectedFileCount === 0 || isCommitting}
|
||||||
|
className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/components/git-panel/view/changes/FileChangeItem.tsx
Normal file
138
src/components/git-panel/view/changes/FileChangeItem.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { ChevronRight, Trash2 } from 'lucide-react';
|
||||||
|
import DiffViewer from '../../../DiffViewer.jsx';
|
||||||
|
import type { FileStatusCode } from '../../types/types';
|
||||||
|
import { getStatusBadgeClass, getStatusLabel } from '../../utils/gitPanelUtils';
|
||||||
|
|
||||||
|
type DiffViewerProps = {
|
||||||
|
diff: string;
|
||||||
|
fileName: string;
|
||||||
|
isMobile: boolean;
|
||||||
|
wrapText: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiffViewerComponent = DiffViewer as unknown as (props: DiffViewerProps) => JSX.Element;
|
||||||
|
|
||||||
|
type FileChangeItemProps = {
|
||||||
|
filePath: string;
|
||||||
|
status: FileStatusCode;
|
||||||
|
isMobile: boolean;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
diff?: string;
|
||||||
|
wrapText: boolean;
|
||||||
|
onToggleSelected: (filePath: string) => void;
|
||||||
|
onToggleExpanded: (filePath: string) => void;
|
||||||
|
onOpenFile: (filePath: string) => void;
|
||||||
|
onToggleWrapText: () => void;
|
||||||
|
onRequestFileAction: (filePath: string, status: FileStatusCode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FileChangeItem({
|
||||||
|
filePath,
|
||||||
|
status,
|
||||||
|
isMobile,
|
||||||
|
isExpanded,
|
||||||
|
isSelected,
|
||||||
|
diff,
|
||||||
|
wrapText,
|
||||||
|
onToggleSelected,
|
||||||
|
onToggleExpanded,
|
||||||
|
onOpenFile,
|
||||||
|
onToggleWrapText,
|
||||||
|
onRequestFileAction,
|
||||||
|
}: FileChangeItemProps) {
|
||||||
|
const statusLabel = getStatusLabel(status);
|
||||||
|
const badgeClass = getStatusBadgeClass(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border last:border-0">
|
||||||
|
<div className={`flex items-center hover:bg-accent/50 transition-colors ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => onToggleSelected(filePath)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
className={`rounded border-border text-primary focus:ring-primary/40 bg-background checked:bg-primary ${isMobile ? 'mr-1.5' : 'mr-2'}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center flex-1 min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleExpanded(filePath);
|
||||||
|
}}
|
||||||
|
className={`p-0.5 hover:bg-accent rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
|
||||||
|
title={isExpanded ? 'Collapse diff' : 'Expand diff'}
|
||||||
|
>
|
||||||
|
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-primary hover:underline`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenFile(filePath);
|
||||||
|
}}
|
||||||
|
title="Click to open file"
|
||||||
|
>
|
||||||
|
{filePath}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{(status === 'M' || status === 'D' || status === 'U') && (
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onRequestFileAction(filePath, status);
|
||||||
|
}}
|
||||||
|
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
|
||||||
|
title={status === 'U' ? 'Delete untracked file' : 'Discard changes'}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
{isMobile && <span>{status === 'U' ? 'Delete' : 'Discard'}</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${badgeClass}`}
|
||||||
|
title={statusLabel}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`bg-muted/50 transition-all duration-400 ease-in-out overflow-hidden ${
|
||||||
|
isExpanded && diff ? 'max-h-[600px] opacity-100 translate-y-0' : 'max-h-0 opacity-0 -translate-y-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-2 border-b border-border">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${badgeClass}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-foreground">{statusLabel}</span>
|
||||||
|
</span>
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleWrapText();
|
||||||
|
}}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={wrapText ? 'Switch to horizontal scroll' : 'Switch to text wrap'}
|
||||||
|
>
|
||||||
|
{wrapText ? 'Scroll' : 'Wrap'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{diff && <DiffViewerComponent diff={diff} fileName={filePath} isMobile={isMobile} wrapText={wrapText} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/git-panel/view/changes/FileChangeList.tsx
Normal file
55
src/components/git-panel/view/changes/FileChangeList.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { FILE_STATUS_GROUPS } from '../../constants/constants';
|
||||||
|
import type { FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';
|
||||||
|
import FileChangeItem from './FileChangeItem';
|
||||||
|
|
||||||
|
type FileChangeListProps = {
|
||||||
|
gitStatus: GitStatusResponse;
|
||||||
|
gitDiff: GitDiffMap;
|
||||||
|
expandedFiles: Set<string>;
|
||||||
|
selectedFiles: Set<string>;
|
||||||
|
isMobile: boolean;
|
||||||
|
wrapText: boolean;
|
||||||
|
onToggleSelected: (filePath: string) => void;
|
||||||
|
onToggleExpanded: (filePath: string) => void;
|
||||||
|
onOpenFile: (filePath: string) => void;
|
||||||
|
onToggleWrapText: () => void;
|
||||||
|
onRequestFileAction: (filePath: string, status: FileStatusCode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FileChangeList({
|
||||||
|
gitStatus,
|
||||||
|
gitDiff,
|
||||||
|
expandedFiles,
|
||||||
|
selectedFiles,
|
||||||
|
isMobile,
|
||||||
|
wrapText,
|
||||||
|
onToggleSelected,
|
||||||
|
onToggleExpanded,
|
||||||
|
onOpenFile,
|
||||||
|
onToggleWrapText,
|
||||||
|
onRequestFileAction,
|
||||||
|
}: FileChangeListProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{FILE_STATUS_GROUPS.map(({ key, status }) =>
|
||||||
|
(gitStatus[key] || []).map((filePath) => (
|
||||||
|
<FileChangeItem
|
||||||
|
key={filePath}
|
||||||
|
filePath={filePath}
|
||||||
|
status={status}
|
||||||
|
isMobile={isMobile}
|
||||||
|
isExpanded={expandedFiles.has(filePath)}
|
||||||
|
isSelected={selectedFiles.has(filePath)}
|
||||||
|
diff={gitDiff[filePath]}
|
||||||
|
wrapText={wrapText}
|
||||||
|
onToggleSelected={onToggleSelected}
|
||||||
|
onToggleExpanded={onToggleExpanded}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onToggleWrapText={onToggleWrapText}
|
||||||
|
onRequestFileAction={onRequestFileAction}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
type FileSelectionControlsProps = {
|
||||||
|
isMobile: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
isHidden: boolean;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onDeselectAll: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FileSelectionControls({
|
||||||
|
isMobile,
|
||||||
|
selectedCount,
|
||||||
|
totalCount,
|
||||||
|
isHidden,
|
||||||
|
onSelectAll,
|
||||||
|
onDeselectAll,
|
||||||
|
}: FileSelectionControlsProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border-b border-border/60 flex items-center justify-between transition-all duration-300 ease-in-out ${
|
||||||
|
isMobile ? 'px-3 py-1.5' : 'px-4 py-2'
|
||||||
|
} ${isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-16 opacity-100 translate-y-0'}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedCount} of {totalCount} {isMobile ? '' : 'files'} selected
|
||||||
|
</span>
|
||||||
|
<span className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||||
|
<button
|
||||||
|
onClick={onSelectAll}
|
||||||
|
className="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
{isMobile ? 'All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
<span className="text-border">|</span>
|
||||||
|
<button
|
||||||
|
onClick={onDeselectAll}
|
||||||
|
className="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
{isMobile ? 'None' : 'Deselect All'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/git-panel/view/changes/FileStatusLegend.tsx
Normal file
52
src/components/git-panel/view/changes/FileStatusLegend.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { getStatusBadgeClass } from '../../utils/gitPanelUtils';
|
||||||
|
|
||||||
|
type FileStatusLegendProps = {
|
||||||
|
isMobile: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGEND_ITEMS = [
|
||||||
|
{ status: 'M', label: 'Modified' },
|
||||||
|
{ status: 'A', label: 'Added' },
|
||||||
|
{ status: 'D', label: 'Deleted' },
|
||||||
|
{ status: 'U', label: 'Untracked' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function FileStatusLegend({ isMobile }: FileStatusLegendProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border/60">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen((previous) => !previous)}
|
||||||
|
className="w-full px-4 py-2 bg-muted/30 hover:bg-muted/50 text-sm text-muted-foreground flex items-center justify-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Info className="w-3 h-3" />
|
||||||
|
<span>File Status Guide</span>
|
||||||
|
{isOpen ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 py-3 bg-muted/30 text-sm">
|
||||||
|
<div className="flex justify-center gap-6">
|
||||||
|
{LEGEND_ITEMS.map((item) => (
|
||||||
|
<span key={item.status} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-5 h-5 rounded border font-bold text-[10px] ${getStatusBadgeClass(item.status)}`}
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground italic">{item.label}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/git-panel/view/history/CommitHistoryItem.tsx
Normal file
69
src/components/git-panel/view/history/CommitHistoryItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import DiffViewer from '../../../DiffViewer.jsx';
|
||||||
|
import type { GitCommitSummary } from '../../types/types';
|
||||||
|
|
||||||
|
type DiffViewerProps = {
|
||||||
|
diff: string;
|
||||||
|
fileName: string;
|
||||||
|
isMobile: boolean;
|
||||||
|
wrapText: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiffViewerComponent = DiffViewer as unknown as (props: DiffViewerProps) => JSX.Element;
|
||||||
|
|
||||||
|
type CommitHistoryItemProps = {
|
||||||
|
commit: GitCommitSummary;
|
||||||
|
isExpanded: boolean;
|
||||||
|
diff?: string;
|
||||||
|
isMobile: boolean;
|
||||||
|
wrapText: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CommitHistoryItem({
|
||||||
|
commit,
|
||||||
|
isExpanded,
|
||||||
|
diff,
|
||||||
|
isMobile,
|
||||||
|
wrapText,
|
||||||
|
onToggle,
|
||||||
|
}: CommitHistoryItemProps) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border last:border-0">
|
||||||
|
<div
|
||||||
|
className="flex items-start p-3 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<span className="mr-2 mt-1 p-0.5 hover:bg-accent rounded">
|
||||||
|
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{commit.message}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{commit.author}
|
||||||
|
{' \u2022 '}
|
||||||
|
{commit.date}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-muted-foreground/60 flex-shrink-0">
|
||||||
|
{commit.hash.substring(0, 7)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && diff && (
|
||||||
|
<div className="bg-muted/50">
|
||||||
|
<div className="max-h-96 overflow-y-auto p-2">
|
||||||
|
<div className="text-sm font-mono text-muted-foreground mb-2">
|
||||||
|
{commit.stats}
|
||||||
|
</div>
|
||||||
|
<DiffViewerComponent diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/git-panel/view/history/HistoryView.tsx
Normal file
72
src/components/git-panel/view/history/HistoryView.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { History, RefreshCw } from 'lucide-react';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { GitDiffMap, GitCommitSummary } from '../../types/types';
|
||||||
|
import CommitHistoryItem from './CommitHistoryItem';
|
||||||
|
|
||||||
|
type HistoryViewProps = {
|
||||||
|
isMobile: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
recentCommits: GitCommitSummary[];
|
||||||
|
commitDiffs: GitDiffMap;
|
||||||
|
wrapText: boolean;
|
||||||
|
onFetchCommitDiff: (commitHash: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HistoryView({
|
||||||
|
isMobile,
|
||||||
|
isLoading,
|
||||||
|
recentCommits,
|
||||||
|
commitDiffs,
|
||||||
|
wrapText,
|
||||||
|
onFetchCommitDiff,
|
||||||
|
}: HistoryViewProps) {
|
||||||
|
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleCommitExpanded = useCallback(
|
||||||
|
(commitHash: string) => {
|
||||||
|
setExpandedCommits((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(commitHash)) {
|
||||||
|
next.delete(commitHash);
|
||||||
|
} else {
|
||||||
|
next.add(commitHash);
|
||||||
|
// Load commit diff lazily only the first time a commit is expanded.
|
||||||
|
if (!commitDiffs[commitHash]) {
|
||||||
|
void onFetchCommitDiff(commitHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[commitDiffs, onFetchCommitDiff],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : recentCommits.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
|
||||||
|
<History className="w-10 h-10 mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">No commits found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={isMobile ? 'pb-4' : ''}>
|
||||||
|
{recentCommits.map((commit) => (
|
||||||
|
<CommitHistoryItem
|
||||||
|
key={commit.hash}
|
||||||
|
commit={commit}
|
||||||
|
isExpanded={expandedCommits.has(commit.hash)}
|
||||||
|
diff={commitDiffs[commit.hash]}
|
||||||
|
isMobile={isMobile}
|
||||||
|
wrapText={wrapText}
|
||||||
|
onToggle={() => toggleCommitExpanded(commit.hash)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/git-panel/view/modals/ConfirmActionModal.tsx
Normal file
71
src/components/git-panel/view/modals/ConfirmActionModal.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { AlertTriangle, Check, Download, Trash2, Upload } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
CONFIRMATION_ACTION_LABELS,
|
||||||
|
CONFIRMATION_BUTTON_CLASSES,
|
||||||
|
CONFIRMATION_ICON_CLASSES,
|
||||||
|
CONFIRMATION_ICON_CONTAINER_CLASSES,
|
||||||
|
CONFIRMATION_TITLES,
|
||||||
|
} from '../../constants/constants';
|
||||||
|
import type { ConfirmationRequest } from '../../types/types';
|
||||||
|
|
||||||
|
type ConfirmActionModalProps = {
|
||||||
|
action: ConfirmationRequest | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
|
||||||
|
if (actionType === 'discard' || actionType === 'delete') {
|
||||||
|
return <Trash2 className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === 'commit') {
|
||||||
|
return <Check className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === 'pull') {
|
||||||
|
return <Download className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Upload className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmActionModal({ action, onCancel, onConfirm }: ConfirmActionModalProps) {
|
||||||
|
if (!action) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||||
|
<div className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<div className={`p-2 rounded-full mr-3 ${CONFIRMATION_ICON_CONTAINER_CLASSES[action.type]}`}>
|
||||||
|
{renderConfirmActionIcon(action.type)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">{CONFIRMATION_TITLES[action.type]}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">{action.message}</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 text-sm text-white rounded-lg transition-colors flex items-center space-x-2 ${CONFIRMATION_BUTTON_CLASSES[action.type]}`}
|
||||||
|
>
|
||||||
|
{renderConfirmActionIcon(action.type)}
|
||||||
|
<span>{CONFIRMATION_ACTION_LABELS[action.type]}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/git-panel/view/modals/NewBranchModal.tsx
Normal file
104
src/components/git-panel/view/modals/NewBranchModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Plus, RefreshCw } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type NewBranchModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
currentBranch: string;
|
||||||
|
isCreatingBranch: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreateBranch: (branchName: string) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewBranchModal({
|
||||||
|
isOpen,
|
||||||
|
currentBranch,
|
||||||
|
isCreatingBranch,
|
||||||
|
onClose,
|
||||||
|
onCreateBranch,
|
||||||
|
}: NewBranchModalProps) {
|
||||||
|
const [newBranchName, setNewBranchName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setNewBranchName('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleCreateBranch = async () => {
|
||||||
|
const branchName = newBranchName.trim();
|
||||||
|
if (!branchName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await onCreateBranch(branchName);
|
||||||
|
if (success) {
|
||||||
|
setNewBranchName('');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
<div className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-4">Create New Branch</h3>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="git-new-branch-name" className="block text-sm font-medium text-foreground/80 mb-2">
|
||||||
|
Branch Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="git-new-branch-name"
|
||||||
|
type="text"
|
||||||
|
value={newBranchName}
|
||||||
|
onChange={(event) => setNewBranchName(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !isCreatingBranch) {
|
||||||
|
void handleCreateBranch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="feature/new-feature"
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
This will create a new branch from the current branch ({currentBranch})
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleCreateBranch()}
|
||||||
|
disabled={!newBranchName.trim() || isCreatingBranch}
|
||||||
|
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 transition-colors"
|
||||||
|
>
|
||||||
|
{isCreatingBranch ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
<span>Creating...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
<span>Create Branch</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import React, { useEffect } from 'react';
|
|||||||
import ChatInterface from '../../chat/view/ChatInterface';
|
import ChatInterface from '../../chat/view/ChatInterface';
|
||||||
import FileTree from '../../file-tree/FileTree';
|
import FileTree from '../../file-tree/FileTree';
|
||||||
import StandaloneShell from '../../StandaloneShell';
|
import StandaloneShell from '../../StandaloneShell';
|
||||||
import GitPanel from '../../GitPanel';
|
import GitPanel from '../../git-panel/view/GitPanel';
|
||||||
import ErrorBoundary from '../../ErrorBoundary';
|
import ErrorBoundary from '../../ErrorBoundary';
|
||||||
|
|
||||||
import MainContentHeader from './subcomponents/MainContentHeader';
|
import MainContentHeader from './subcomponents/MainContentHeader';
|
||||||
@@ -19,7 +19,6 @@ import { useEditorSidebar } from '../hooks/useEditorSidebar';
|
|||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
|
|
||||||
const AnyStandaloneShell = StandaloneShell as any;
|
const AnyStandaloneShell = StandaloneShell as any;
|
||||||
const AnyGitPanel = GitPanel as any;
|
|
||||||
|
|
||||||
type TaskMasterContextValue = {
|
type TaskMasterContextValue = {
|
||||||
currentProject?: Project | null;
|
currentProject?: Project | null;
|
||||||
@@ -154,7 +153,7 @@ function MainContent({
|
|||||||
|
|
||||||
{activeTab === 'git' && (
|
{activeTab === 'git' && (
|
||||||
<div className="h-full overflow-hidden">
|
<div className="h-full overflow-hidden">
|
||||||
<AnyGitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user