fix: Improve error handling and validation across multiple services

This commit is contained in:
gsxdsm
2026-02-18 22:11:31 -08:00
parent 53d07fefb8
commit 205f662022
17 changed files with 395 additions and 339 deletions

View File

@@ -641,6 +641,12 @@ export function GitDiffPanel({
const handleStageAll = useCallback(async () => {
const allPaths = files.map((f) => f.path);
if (allPaths.length === 0) return;
if (enableStaging && useWorktrees && !worktreePath) {
toast.error('Failed to stage all files', {
description: 'worktreePath required when useWorktrees is enabled',
});
return;
}
await executeStagingAction(
'stage',
allPaths,
@@ -649,11 +655,17 @@ export function GitDiffPanel({
() => setStagingInProgress(new Set(allPaths)),
() => setStagingInProgress(new Set())
);
}, [worktreePath, projectPath, useWorktrees, files, executeStagingAction]);
}, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]);
const handleUnstageAll = useCallback(async () => {
const allPaths = files.map((f) => f.path);
if (allPaths.length === 0) return;
if (enableStaging && useWorktrees && !worktreePath) {
toast.error('Failed to unstage all files', {
description: 'worktreePath required when useWorktrees is enabled',
});
return;
}
await executeStagingAction(
'unstage',
allPaths,
@@ -662,7 +674,7 @@ export function GitDiffPanel({
() => setStagingInProgress(new Set(allPaths)),
() => setStagingInProgress(new Set())
);
}, [worktreePath, projectPath, useWorktrees, files, executeStagingAction]);
}, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]);
// Compute staging summary
const stagingSummary = useMemo(() => {
@@ -899,68 +911,70 @@ export function GitDiffPanel({
{/* Fallback for files that have no diff content (shouldn't happen after fix, but safety net) */}
{files.length > 0 && parsedDiffs.length === 0 && (
<div className="space-y-2">
{files.map((file) => (
<div
key={file.path}
className="border border-border rounded-lg overflow-hidden"
>
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
{getFileIcon(file.status)}
<TruncatedFilePath
path={file.path}
className="flex-1 text-sm font-mono text-foreground"
/>
{enableStaging && <StagingBadge state={getStagingState(file)} />}
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded border font-medium',
getStatusBadgeColor(file.status)
)}
>
{getStatusDisplayName(file.status)}
</span>
{enableStaging && (
<div className="flex items-center gap-1 ml-1">
{stagingInProgress.has(file.path) ? (
<Spinner size="sm" />
) : getStagingState(file) === 'staged' ||
getStagingState(file) === 'partial' ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleUnstageFile(file.path)}
title="Unstage file"
>
<Minus className="w-3 h-3 mr-1" />
Unstage
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleStageFile(file.path)}
title="Stage file"
>
<Plus className="w-3 h-3 mr-1" />
Stage
</Button>
{files.map((file) => {
const stagingState = getStagingState(file);
return (
<div
key={file.path}
className="border border-border rounded-lg overflow-hidden"
>
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
{getFileIcon(file.status)}
<TruncatedFilePath
path={file.path}
className="flex-1 text-sm font-mono text-foreground"
/>
{enableStaging && <StagingBadge state={stagingState} />}
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded border font-medium',
getStatusBadgeColor(file.status)
)}
</div>
)}
>
{getStatusDisplayName(file.status)}
</span>
{enableStaging && (
<div className="flex items-center gap-1 ml-1">
{stagingInProgress.has(file.path) ? (
<Spinner size="sm" />
) : stagingState === 'staged' || stagingState === 'partial' ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleUnstageFile(file.path)}
title="Unstage file"
>
<Minus className="w-3 h-3 mr-1" />
Unstage
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleStageFile(file.path)}
title="Stage file"
>
<Plus className="w-3 h-3 mr-1" />
Stage
</Button>
)}
</div>
)}
</div>
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
{file.status === '?' ? (
<span>New file - content preview not available</span>
) : file.status === 'D' ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>
)}
</div>
</div>
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
{file.status === '?' ? (
<span>New file - content preview not available</span>
) : file.status === 'D' ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>
)}
</div>
</div>
))}
);
})}
</div>
)}
</div>

View File

@@ -93,11 +93,12 @@ export function UsagePopover() {
// Track whether the user has manually selected a tab so we don't override their choice
const userHasSelected = useRef(false);
// Check authentication status
// Check authentication status — use explicit boolean coercion so hooks never
// receive undefined for their `enabled` parameter during auth-loading
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
const isCodexAuthenticated = codexAuthStatus?.authenticated;
const isZaiAuthenticated = zaiAuthStatus?.authenticated;
const isGeminiAuthenticated = geminiAuthStatus?.authenticated;
const isCodexAuthenticated = !!codexAuthStatus?.authenticated;
const isZaiAuthenticated = !!zaiAuthStatus?.authenticated;
const isGeminiAuthenticated = !!geminiAuthStatus?.authenticated;
// Use React Query hooks for usage data
// Only enable polling when popover is open AND the tab is active
@@ -239,12 +240,6 @@ export function UsagePopover() {
return !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000;
}, [geminiUsageLastUpdated]);
// Refetch functions for manual refresh
const fetchClaudeUsage = () => refetchClaude();
const fetchCodexUsage = () => refetchCodex();
const fetchZaiUsage = () => refetchZai();
const fetchGeminiUsage = () => refetchGemini();
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
if (percentage >= 75) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' };
@@ -368,13 +363,6 @@ export function UsagePopover() {
// Calculate max percentage for header button
const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0;
const _codexMaxPercentage = codexUsage?.rateLimits
? Math.max(
codexUsage.rateLimits.primary?.usedPercent || 0,
codexUsage.rateLimits.secondary?.usedPercent || 0
)
: 0;
const zaiMaxPercentage = zaiUsage?.quotaLimits
? Math.max(
zaiUsage.quotaLimits.tokens?.usedPercent || 0,
@@ -383,7 +371,8 @@ export function UsagePopover() {
: 0;
// Gemini quota from Google Cloud API (if available)
const geminiMaxPercentage = geminiUsage?.usedPercent ?? (geminiUsage?.authenticated ? 0 : 100);
// Default to 0 when usedPercent is not available to avoid a misleading full-red indicator
const geminiMaxPercentage = geminiUsage?.usedPercent ?? 0;
const getProgressBarColor = (percentage: number) => {
if (percentage >= 80) return 'bg-red-500';
@@ -397,9 +386,6 @@ export function UsagePopover() {
codexSecondaryWindowMinutes && codexPrimaryWindowMinutes
? Math.min(codexPrimaryWindowMinutes, codexSecondaryWindowMinutes)
: (codexSecondaryWindowMinutes ?? codexPrimaryWindowMinutes);
const _codexWindowLabel = codexWindowMinutes
? getCodexWindowLabel(codexWindowMinutes).title
: 'Window';
const codexWindowUsage =
codexWindowMinutes === codexSecondaryWindowMinutes
? codexUsage?.rateLimits?.secondary?.usedPercent
@@ -537,7 +523,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
onClick={() => !claudeLoading && fetchClaudeUsage()}
onClick={() => !claudeLoading && refetchClaude()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
@@ -646,7 +632,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
onClick={() => !codexLoading && fetchCodexUsage()}
onClick={() => !codexLoading && refetchCodex()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
@@ -783,7 +769,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', zaiLoading && 'opacity-80')}
onClick={() => !zaiLoading && fetchZaiUsage()}
onClick={() => !zaiLoading && refetchZai()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
@@ -899,7 +885,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', geminiLoading && 'opacity-80')}
onClick={() => !geminiLoading && fetchGeminiUsage()}
onClick={() => !geminiLoading && refetchGemini()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>

View File

@@ -32,6 +32,7 @@ import {
type UncommittedChangesInfo,
type StashConfirmAction,
} from './stash-confirm-dialog';
import { type BranchInfo } from '../worktree-panel/types';
interface WorktreeInfo {
path: string;
@@ -41,12 +42,6 @@ interface WorktreeInfo {
changedFilesCount?: number;
}
interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
const logger = createLogger('CreateBranchDialog');
interface CreateBranchDialogProps {
@@ -67,6 +62,7 @@ export function CreateBranchDialog({
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [baseBranchPopoverOpen, setBaseBranchPopoverOpen] = useState(false);
const baseBranchTriggerRef = useRef<HTMLButtonElement>(null);
@@ -121,6 +117,7 @@ export function CreateBranchDialog({
setBaseBranchPopoverOpen(false);
setShowStashConfirm(false);
setUncommittedChanges(null);
setIsChecking(false);
fetchBranches();
}
}, [open, fetchBranches]);
@@ -151,6 +148,7 @@ export function CreateBranchDialog({
const api = getElectronAPI();
if (!api?.worktree?.checkoutBranch) {
toast.error('Branch API not available');
setIsCreating(false);
return;
}
@@ -197,6 +195,8 @@ export function CreateBranchDialog({
* Checks for uncommitted changes first and shows confirmation if needed.
*/
const handleCreate = async () => {
// Guard against concurrent invocations during the async pre-check or creation
if (isCreating || isChecking) return;
if (!worktree || !branchName.trim()) return;
// Basic validation
@@ -207,6 +207,7 @@ export function CreateBranchDialog({
}
setError(null);
setIsChecking(true);
// Check for uncommitted changes before proceeding
try {
@@ -221,6 +222,7 @@ export function CreateBranchDialog({
untracked: changesResult.result.untracked,
totalFiles: changesResult.result.totalFiles,
});
setIsChecking(false);
setShowStashConfirm(true);
return;
}
@@ -229,6 +231,8 @@ export function CreateBranchDialog({
logger.warn('Failed to check for uncommitted changes, proceeding without stash:', err);
}
setIsChecking(false);
// No changes detected, proceed directly
doCreate(false);
};
@@ -289,11 +293,11 @@ export function CreateBranchDialog({
setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && branchName.trim() && !isCreating) {
if (e.key === 'Enter' && branchName.trim() && !isCreating && !isChecking) {
handleCreate();
}
}}
disabled={isCreating}
disabled={isCreating || isChecking}
autoFocus
/>
</div>
@@ -417,15 +421,27 @@ export function CreateBranchDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCreating || isChecking}
>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
<Button
onClick={handleCreate}
disabled={!branchName.trim() || isCreating || isChecking}
>
{isCreating ? (
<>
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : isChecking ? (
<>
<Spinner size="sm" className="mr-2" />
Checking...
</>
) : (
'Create Branch'
)}

View File

@@ -1596,6 +1596,26 @@ export interface WorktreeAPI {
aborted?: boolean;
}>;
// Abort an in-progress merge, rebase, or cherry-pick operation
abortOperation: (worktreePath: string) => Promise<{
success: boolean;
result?: {
operation: string;
message: string;
};
error?: string;
}>;
// Continue an in-progress merge, rebase, or cherry-pick after conflict resolution
continueOperation: (worktreePath: string) => Promise<{
success: boolean;
result?: {
operation: string;
message: string;
};
error?: string;
}>;
// Get commit log for a specific branch (not just the current one)
getBranchCommitLog: (
worktreePath: string,