'use client'; import { memo, useMemo, useState, useCallback, useEffect, useRef } from 'react'; import { IconArrowBackUp, IconExternalLink, IconPlus, IconMinus, IconCheck, IconChevronRight, IconGitCommit, IconLoader2, IconSearch, IconColumns, } from '@tabler/icons-react'; import { TabsContent } from '@kandev/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@kandev/ui/alert-dialog'; import { LineStat } from '@/components/diff-stat'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useSessionCommits } from '@/hooks/domains/session/use-session-commits'; import { useGitOperations } from '@/hooks/use-git-operations'; import { useSessionFileReviews } from '@/hooks/use-session-file-reviews'; import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff'; import { hashDiff, normalizeDiffContent } from '@/components/review/types'; import { getWebSocketClient } from '@/lib/ws/connection'; import { deleteFile } from '@/lib/ws/workspace-files'; import type { FileInfo } from '@/lib/state/store'; import { FileBrowser } from '@/components/task/file-browser'; import { SessionTabs, type SessionTab } from '@/components/session-tabs'; import { useToast } from '@/components/toast-provider'; import type { OpenFileTab } from '@/lib/types/backend'; import { getFilesPanelTab, setFilesPanelTab, hasUserSelectedFilesPanelTab, setUserSelectedFilesPanelTab, } from '@/lib/local-storage'; import { usePanelActions } from '@/hooks/use-panel-actions'; import { FileStatusIcon } from './file-status-icon'; type TaskFilesPanelProps = { onSelectDiff: (path: string, content?: string) => void; onOpenFile: (file: OpenFileTab) => void; activeFilePath?: string | null; }; const splitPath = (path: string) => { const lastSlash = path.lastIndexOf('/'); if (lastSlash === -1) return { folder: '', file: path }; return { folder: path.slice(0, lastSlash), file: path.slice(lastSlash + 1), }; }; const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, activeFilePath }: TaskFilesPanelProps) { const [topTab, setTopTab] = useState<'diff' | 'files'>('diff'); const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [fileToDiscard, setFileToDiscard] = useState(null); const [isSearchOpen, setIsSearchOpen] = useState(false); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); const { commits } = useSessionCommits(activeSessionId ?? null); const openEditor = useOpenSessionInEditor(activeSessionId ?? null); const gitOps = useGitOperations(activeSessionId ?? null); const { toast } = useToast(); const { reviews } = useSessionFileReviews(activeSessionId); const { diff: cumulativeDiff } = useCumulativeDiff(activeSessionId); // Track if we've initialized the tab for this session const hasInitializedTabRef = useRef(null); // Track whether user explicitly clicked the "files" tab (vs auto-selected) const userClickedFilesTabRef = useRef(false); // Track previous changed files count for auto-switch detection const prevChangedCountRef = useRef(0); // Close search when switching tabs useEffect(() => { setIsSearchOpen(false); }, [topTab]); // State for commit diffs const [expandedCommit, setExpandedCommit] = useState(null); const [commitDiffs, setCommitDiffs] = useState>>({}); const [loadingCommitSha, setLoadingCommitSha] = useState(null); const [pendingStageFiles, setPendingStageFiles] = useState>(new Set()); // Fetch commit diff const fetchCommitDiff = useCallback(async (commitSha: string) => { if (!activeSessionId || commitDiffs[commitSha]) return; setLoadingCommitSha(commitSha); try { const ws = getWebSocketClient(); if (!ws) return; const response = await ws.request<{ success: boolean; files: Record }>( 'session.commit_diff', { session_id: activeSessionId, commit_sha: commitSha }, 10000 ); if (response?.success && response.files) { setCommitDiffs((prev) => ({ ...prev, [commitSha]: response.files })); } } catch (err) { toast({ title: 'Failed to load commit diff', description: err instanceof Error ? err.message : 'An unexpected error occurred', variant: 'error', }); } finally { setLoadingCommitSha(null); } }, [activeSessionId, commitDiffs, toast]); // Toggle commit expansion const toggleCommit = useCallback((commitSha: string) => { if (expandedCommit === commitSha) { setExpandedCommit(null); } else { setExpandedCommit(commitSha); void fetchCommitDiff(commitSha); } }, [expandedCommit, fetchCommitDiff]); // Handle discard changes const handleDiscardClick = useCallback((filePath: string) => { setFileToDiscard(filePath); setShowDiscardDialog(true); }, []); const handleDiscardConfirm = useCallback(async () => { if (!fileToDiscard) return; try { const result = await gitOps.discard([fileToDiscard]); if (!result.success) { toast({ title: 'Failed to discard changes', description: result.error || 'An unknown error occurred', variant: 'error', }); } } catch (error) { toast({ title: 'Failed to discard changes', description: error instanceof Error ? error.message : 'An unknown error occurred', variant: 'error', }); } finally { setShowDiscardDialog(false); setFileToDiscard(null); } }, [fileToDiscard, gitOps, toast]); // Handle delete file const handleDeleteFile = useCallback(async (path: string) => { const client = getWebSocketClient(); if (!client || !activeSessionId) return; try { const response = await deleteFile(client, activeSessionId, path); if (!response.success) { toast({ title: 'Failed to delete file', description: response.error || 'An unknown error occurred', variant: 'error', }); } } catch (error) { toast({ title: 'Failed to delete file', description: error instanceof Error ? error.message : 'An unknown error occurred', variant: 'error', }); } }, [activeSessionId, toast]); // Convert git status files to array for display const changedFiles = useMemo(() => { if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { return []; } return (Object.values(gitStatus.files) as FileInfo[]).map((file: FileInfo) => ({ path: file.path, status: file.status, staged: file.staged, plus: file.additions, minus: file.deletions, oldPath: file.old_path, })); }, [gitStatus]); // Compute review progress across all files (uncommitted + committed) const { reviewedCount, totalFileCount } = useMemo(() => { const paths = new Set(); // Uncommitted if (gitStatus?.files) { for (const [path, file] of Object.entries(gitStatus.files)) { const diff = file.diff ? normalizeDiffContent(file.diff) : ''; if (diff) paths.add(path); } } // Committed (skip duplicates) if (cumulativeDiff?.files) { for (const [path, file] of Object.entries(cumulativeDiff.files)) { if (paths.has(path)) continue; const diff = file.diff ? normalizeDiffContent(file.diff) : ''; if (diff) paths.add(path); } } let reviewed = 0; for (const path of paths) { const state = reviews.get(path); if (!state?.reviewed) continue; // Check staleness: find the diff content for this path let diffContent = ''; const uncommittedFile = gitStatus?.files?.[path]; if (uncommittedFile?.diff) { diffContent = normalizeDiffContent(uncommittedFile.diff); } else if (cumulativeDiff?.files?.[path]?.diff) { diffContent = normalizeDiffContent(cumulativeDiff.files[path].diff!); } if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) { continue; // stale — don't count } reviewed++; } return { reviewedCount: reviewed, totalFileCount: paths.size }; }, [gitStatus, cumulativeDiff, reviews]); const reviewProgressPercent = totalFileCount > 0 ? (reviewedCount / totalFileCount) * 100 : 0; // Clear pending stage/unstage spinners when git status updates useEffect(() => { if (pendingStageFiles.size > 0) { setPendingStageFiles(new Set()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [changedFiles]); const handleStage = useCallback(async (path: string) => { setPendingStageFiles((prev) => new Set(prev).add(path)); try { await gitOps.stage([path]); } catch { setPendingStageFiles((prev) => { const next = new Set(prev); next.delete(path); return next; }); } }, [gitOps]); const handleUnstage = useCallback(async (path: string) => { setPendingStageFiles((prev) => new Set(prev).add(path)); try { await gitOps.unstage([path]); } catch { setPendingStageFiles((prev) => { const next = new Set(prev); next.delete(path); return next; }); } }, [gitOps]); // Open file in editor panel const { openFile: panelOpenFile } = usePanelActions(); const handleOpenFileInDocumentPanel = useCallback((path: string) => { if (!activeSessionId) return; // On desktop, this will open a file editor tab via dockview panelOpenFile(path); }, [activeSessionId, panelOpenFile]); // Smart tab selection: restore user preference or auto-select based on changes useEffect(() => { if (!activeSessionId) return; // Only initialize once per session if (hasInitializedTabRef.current === activeSessionId) return; hasInitializedTabRef.current = activeSessionId; // If user has previously selected a tab for this session, restore it if (hasUserSelectedFilesPanelTab(activeSessionId)) { const savedTab = getFilesPanelTab(activeSessionId, 'diff'); setTopTab(savedTab); userClickedFilesTabRef.current = savedTab === 'files'; return; } // Auto-select based on whether there are changes userClickedFilesTabRef.current = false; const hasChanges = changedFiles.length > 0 || commits.length > 0; const defaultTab = hasChanges ? 'diff' : 'files'; setTopTab(defaultTab); prevChangedCountRef.current = changedFiles.length; }, [activeSessionId, changedFiles.length, commits.length]); // Handle tab change - save preference and mark as user-selected const handleTabChange = useCallback((tab: 'diff' | 'files') => { setTopTab(tab); userClickedFilesTabRef.current = tab === 'files'; if (activeSessionId) { setFilesPanelTab(activeSessionId, tab); setUserSelectedFilesPanelTab(activeSessionId); } }, [activeSessionId]); // Auto-switch to "Diff files" tab when new workspace changes appear useEffect(() => { const prev = prevChangedCountRef.current; prevChangedCountRef.current = changedFiles.length; // Switch when change count changes (new or removed changes detected) if (changedFiles.length !== prev) { // Don't switch if user explicitly chose the "All files" tab if (topTab === 'files' && userClickedFilesTabRef.current) return; setTopTab('diff'); } }, [changedFiles.length, topTab]); const tabs: SessionTab[] = [ { id: 'diff', label: `Diff files${changedFiles.length > 0 ? ` (${changedFiles.length})` : ''}`, }, { id: 'files', label: 'All files', }, ]; // Contextual buttons based on active tab const tabRightContent = useMemo(() => { if (topTab === 'files') { return ( Search files ); } // No contextual buttons for diff tab (for now) return null; }, [topTab]); return ( handleTabChange(value as 'diff' | 'files')} className="flex-1 min-h-0" rightContent={tabRightContent} >
{/* Uncommitted changes section */} {changedFiles.length > 0 && (
Uncommitted ({changedFiles.length})
    {changedFiles.map((file) => { const { folder, file: name } = splitPath(file.path); return (
  • onSelectDiff(file.path)} >
    {pendingStageFiles.has(file.path) ? (
    ) : file.staged ? ( ) : ( )}
    Discard changes Open side-by-side Open in external editor
  • ); })}
)} {/* Commits section */} {commits.length > 0 && (
Commits ({commits.length})
    {commits.map((commit) => { const isExpanded = expandedCommit === commit.commit_sha; const files = commitDiffs[commit.commit_sha]; const isLoading = loadingCommitSha === commit.commit_sha; return (
  • {isExpanded && files && (
      {Object.entries(files).map(([path, file]) => { const { folder, file: fileName } = splitPath(path); return (
    • onSelectDiff(path, file.diff)} > {folder && {folder}/} {fileName} +{file.additions ?? 0} {'/'} -{file.deletions ?? 0}
    • ); })}
    )}
  • ); })}
)} {/* Empty state */} {changedFiles.length === 0 && commits.length === 0 && (
Your changed files will appear here
)}
{/* Review progress — pushed to bottom of panel */} {totalFileCount > 0 && (
window.dispatchEvent(new CustomEvent('switch-to-changes-tab'))} >
{reviewedCount}/{totalFileCount} reviewed
{reviewedCount} of {totalFileCount} files reviewed )} {activeSessionId ? ( setIsSearchOpen(false)} activeFilePath={activeFilePath} /> ) : (
No task selected
)}
{/* Discard confirmation dialog */} Discard changes? This will permanently discard all changes to{' '} {fileToDiscard}. This action cannot be undone. Cancel Discard ); }); export { TaskFilesPanel };