Files
claudecodeui/src/components/git-panel/view/changes/ChangesView.tsx
simosmik 4061a2761e fix(plugins): async shutdown and asset/RPC fixes
Await stopPluginServer/stopAllPlugins in signal handlers and route
handlers so process exit and state transitions wait for clean plugin
shutdown instead of racing ahead.

Validate asset paths are regular files before streaming to prevent
directory traversal returning unexpected content; add a stream error
handler to avoid unhandled crashes on read failures.

Fix RPC proxy body detection to use the content-length header instead
of Object.keys, so falsy but valid JSON payloads (null, false, 0, {})
are forwarded correctly to plugin servers.

Track in-flight start operations via a startingPlugins map to prevent
duplicate concurrent plugin starts.
2026-03-09 07:39:00 +00:00

217 lines
7.4 KiB
TypeScript

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;
projectPath: string;
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,
projectPath,
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}
projectPath={projectPath}
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 h-32 items-center justify-center">
<RefreshCw className="h-5 w-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="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/50">
<GitBranch className="h-7 w-7 text-muted-foreground/50" />
</div>
<h3 className="mb-2 text-lg font-medium text-foreground">No commits yet</h3>
<p className="mb-6 max-w-md text-sm text-muted-foreground">
This repository doesn&apos;t have any commits yet. Create your first commit to start tracking changes.
</p>
<button
onClick={() => void onCreateInitialCommit()}
disabled={isCreatingInitialCommit}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isCreatingInitialCommit ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
<span>Creating Initial Commit...</span>
</>
) : (
<>
<GitCommit className="h-4 w-4" />
<span>Create Initial Commit</span>
</>
)}
</button>
</div>
) : !gitStatus || !hasChangedFiles(gitStatus) ? (
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
<GitCommit className="mb-2 h-10 w-10 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>
</>
);
}