mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-12 09:27:22 +00:00
Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402)
This commit is contained in:
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
162
src/components/git-panel/view/changes/CommitComposer.tsx
Normal file
162
src/components/git-panel/view/changes/CommitComposer.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import MicButton from '../../../mic-button/view/MicButton';
|
||||
import type { ConfirmationRequest } from '../../types/types';
|
||||
|
||||
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 (message = commitMessage) => {
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsCommitting(true);
|
||||
try {
|
||||
const success = await onCommit(trimmedMessage);
|
||||
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 () => {
|
||||
await handleCommit(trimmedMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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' }}>
|
||||
<MicButton
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user