feat: improve file tree (#209)

This commit is contained in:
Carlos Florêncio
2026-02-07 18:12:23 +00:00
committed by GitHub
parent 4d41155158
commit daccb38354
16 changed files with 529 additions and 124 deletions
@@ -143,13 +143,13 @@ func (s *Server) handleFileContent(c *gin.Context) {
return
}
content, size, err := s.procMgr.GetWorkspaceTracker().GetFileContent(path)
content, size, isBinary, err := s.procMgr.GetWorkspaceTracker().GetFileContent(path)
if err != nil {
c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size})
return
}
c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size})
c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size, IsBinary: isBinary})
}
// handleFileUpdate handles file update requests via HTTP POST
@@ -467,6 +467,12 @@ func (g *GitOperator) Stage(ctx context.Context, paths []string) (*GitOperationR
result.Success = true
g.logger.Info("stage completed", zap.Int("files", len(paths)))
// Refresh git status so the UI reflects the staged state
if g.workspaceTracker != nil {
g.workspaceTracker.RefreshGitStatus(ctx)
}
return result, nil
}
@@ -501,6 +507,12 @@ func (g *GitOperator) Unstage(ctx context.Context, paths []string) (*GitOperatio
result.Success = true
g.logger.Info("unstage completed", zap.Int("files", len(paths)))
// Refresh git status so the UI reflects the unstaged state
if g.workspaceTracker != nil {
g.workspaceTracker.RefreshGitStatus(ctx)
}
return result, nil
}
@@ -3,6 +3,7 @@ package process
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
@@ -15,6 +16,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/fsnotify/fsnotify"
"github.com/kandev/kandev/internal/agentctl/types"
@@ -493,6 +495,7 @@ func (wt *WorkspaceTracker) getCommitStats(ctx context.Context, sha string) (fil
func (wt *WorkspaceTracker) updateGitStatus(ctx context.Context) {
status, err := wt.getGitStatus(ctx)
if err != nil {
wt.logger.Warn("updateGitStatus: getGitStatus failed", zap.Error(err))
return
}
@@ -504,6 +507,12 @@ func (wt *WorkspaceTracker) updateGitStatus(ctx context.Context) {
wt.notifyWorkspaceStreamGitStatus(status)
}
// RefreshGitStatus forces a git status refresh and notifies subscribers.
// Useful after index-only changes (stage/unstage) that the file watcher won't detect.
func (wt *WorkspaceTracker) RefreshGitStatus(ctx context.Context) {
wt.updateGitStatus(ctx)
}
// getGitStatus retrieves the current git status
func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUpdate, error) {
update := types.GitStatusUpdate{
@@ -1162,8 +1171,9 @@ func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.
return node, nil
}
// GetFileContent returns the content of a file
func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error) {
// GetFileContent returns the content of a file.
// If the file is not valid UTF-8, it is base64-encoded and isBinary is true.
func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool, error) {
cleanWorkDir := filepath.Clean(wt.workDir)
cleanReqPath := filepath.Clean(reqPath)
@@ -1177,29 +1187,29 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error
// Path traversal protection
if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir {
return "", 0, fmt.Errorf("path traversal detected")
return "", 0, false, fmt.Errorf("path traversal detected")
}
// Check if file exists and is a regular file
info, err := os.Stat(fullPath)
if err != nil {
return "", 0, fmt.Errorf("file not found: %w", err)
return "", 0, false, fmt.Errorf("file not found: %w", err)
}
if info.IsDir() {
return "", 0, fmt.Errorf("path is a directory, not a file")
return "", 0, false, fmt.Errorf("path is a directory, not a file")
}
// Check file size (limit to 10MB)
const maxFileSize = 10 * 1024 * 1024
if info.Size() > maxFileSize {
return "", info.Size(), fmt.Errorf("file too large (max 10MB)")
return "", info.Size(), false, fmt.Errorf("file too large (max 10MB)")
}
// Read file content
file, err := os.Open(fullPath)
if err != nil {
return "", 0, fmt.Errorf("failed to open file: %w", err)
return "", 0, false, fmt.Errorf("failed to open file: %w", err)
}
defer func() {
_ = file.Close()
@@ -1207,10 +1217,16 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error
content, err := io.ReadAll(file)
if err != nil {
return "", 0, fmt.Errorf("failed to read file: %w", err)
return "", 0, false, fmt.Errorf("failed to read file: %w", err)
}
return string(content), info.Size(), nil
// Detect binary: if content is not valid UTF-8, base64-encode it
if !utf8.Valid(content) {
encoded := base64.StdEncoding.EncodeToString(content)
return encoded, info.Size(), true, nil
}
return string(content), info.Size(), false, nil
}
// ApplyFileDiff applies a unified diff to a file with conflict detection
@@ -1233,7 +1249,7 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or
}
// Read current file content
currentContent, _, err := wt.GetFileContent(reqPath)
currentContent, _, _, err := wt.GetFileContent(reqPath)
if err != nil {
return "", fmt.Errorf("failed to read current file: %w", err)
}
@@ -1256,7 +1272,7 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or
// Use git apply to apply the patch directly to the file
// This is much more reliable than custom diff application
cmd := exec.Command("git", "apply", "--unidiff-zero", "--whitespace=nowarn", patchFile)
cmd := exec.Command("git", "apply", "-p0", "--unidiff-zero", "--whitespace=nowarn", patchFile)
cmd.Dir = wt.workDir
output, err := cmd.CombinedOutput()
@@ -2,6 +2,7 @@ package process
import (
"context"
"encoding/base64"
"os"
"os/exec"
"path/filepath"
@@ -301,3 +302,72 @@ func TestFilterLocalCommits_PullAndResetScenario(t *testing.T) {
}
}
}
func TestGetFileContent_BinaryDetection(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
wt := NewWorkspaceTracker(repoDir, log)
// Test 1: Valid UTF-8 text file returns isBinary=false with raw content
textContent := "Hello, world!\nLine 2\n"
writeFile(t, repoDir, "text.txt", textContent)
content, size, isBinary, err := wt.GetFileContent("text.txt")
if err != nil {
t.Fatalf("GetFileContent(text.txt) error: %v", err)
}
if isBinary {
t.Error("expected isBinary=false for text file")
}
if content != textContent {
t.Errorf("expected content %q, got %q", textContent, content)
}
if size != int64(len(textContent)) {
t.Errorf("expected size %d, got %d", len(textContent), size)
}
// Test 2: Non-UTF-8 binary file returns isBinary=true with base64-encoded content
binaryContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF, 0xFE}
if err := os.WriteFile(filepath.Join(repoDir, "image.png"), binaryContent, 0o644); err != nil {
t.Fatalf("failed to write binary file: %v", err)
}
content, size, isBinary, err = wt.GetFileContent("image.png")
if err != nil {
t.Fatalf("GetFileContent(image.png) error: %v", err)
}
if !isBinary {
t.Error("expected isBinary=true for binary file")
}
if size != int64(len(binaryContent)) {
t.Errorf("expected size %d, got %d", len(binaryContent), size)
}
// Verify content is valid base64 that decodes to original bytes
decoded, err := base64.StdEncoding.DecodeString(content)
if err != nil {
t.Fatalf("failed to decode base64 content: %v", err)
}
if string(decoded) != string(binaryContent) {
t.Errorf("decoded content doesn't match original binary")
}
// Test 3: Empty file returns isBinary=false
writeFile(t, repoDir, "empty.txt", "")
_, _, isBinary, err = wt.GetFileContent("empty.txt")
if err != nil {
t.Fatalf("GetFileContent(empty.txt) error: %v", err)
}
if isBinary {
t.Error("expected isBinary=false for empty file")
}
// Test 4: Path traversal is rejected
_, _, _, err = wt.GetFileContent("../../etc/passwd")
if err == nil {
t.Error("expected error for path traversal, got nil")
}
}
@@ -118,12 +118,16 @@ type FileContentResponse struct {
// Path is the file path.
Path string `json:"path"`
// Content is the file content.
// Content is the file content (base64-encoded if IsBinary is true).
Content string `json:"content"`
// Size is the file size in bytes.
Size int64 `json:"size"`
// IsBinary indicates the file contains non-UTF-8 content.
// When true, Content is base64-encoded.
IsBinary bool `json:"is_binary,omitempty"`
// Error contains error message if the request failed.
Error string `json:"error,omitempty"`
}
@@ -0,0 +1,26 @@
'use client';
import { IconFileOff } from '@tabler/icons-react';
import { toRelativePath } from '@/lib/utils';
type FileBinaryViewerProps = {
path: string;
worktreePath?: string;
};
export function FileBinaryViewer({ path, worktreePath }: FileBinaryViewerProps) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
</div>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<IconFileOff size={48} strokeWidth={1.2} />
<div className="text-sm font-medium">Cannot preview this file</div>
<div className="text-xs">Binary file</div>
</div>
</div>
);
}
+161 -28
View File
@@ -1,16 +1,19 @@
'use client';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { IconChevronRight, IconChevronDown, IconFolder, IconFolderOpen, IconSearch, IconX, IconLoader2, IconTrash } from '@tabler/icons-react';
import { IconChevronRight, IconChevronDown, IconFolder, IconFolderOpen, IconSearch, IconX, IconLoader2, IconTrash, IconRefresh, IconListTree, IconFolderShare, IconCopy, IconCheck } from '@tabler/icons-react';
import { ScrollArea } from '@kandev/ui/scroll-area';
import { Input } from '@kandev/ui/input';
import { FileIcon } from '@/components/ui/file-icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { cn } from '@/lib/utils';
import { getWebSocketClient } from '@/lib/ws/connection';
import { requestFileTree, requestFileContent, searchWorkspaceFiles } from '@/lib/ws/workspace-files';
import type { FileTreeNode, FileContentResponse, OpenFileTab } from '@/lib/types/backend';
import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl';
import { useSession } from '@/hooks/domains/session/use-session';
import { useOpenSessionFolder } from '@/hooks/use-open-session-folder';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import {
getFilesPanelExpandedPaths,
setFilesPanelExpandedPaths,
@@ -19,6 +22,32 @@ import {
} from '@/lib/local-storage';
import { useToast } from '@/components/toast-provider';
/**
* Merge a freshly-fetched tree node into an existing one, preserving
* already-loaded children so expanded folders don't collapse.
*/
function mergeTreeNodes(existing: FileTreeNode, incoming: FileTreeNode): FileTreeNode {
// If the incoming node has no children list, keep existing subtree
if (!incoming.children) return { ...existing, ...incoming, children: existing.children };
// If existing has no children, just use incoming
if (!existing.children) return incoming;
// Build a lookup of existing children by path
const existingByPath = new Map(existing.children.map((c) => [c.path, c]));
const mergedChildren = incoming.children.map((inChild) => {
const exChild = existingByPath.get(inChild.path);
if (exChild && exChild.is_dir && inChild.is_dir) {
// Recursively merge directories to keep loaded subtrees
return mergeTreeNodes(exChild, inChild);
}
return inChild;
});
return { ...existing, ...incoming, children: mergedChildren };
}
type FileBrowserProps = {
sessionId: string;
onOpenFile: (file: OpenFileTab) => void;
@@ -32,12 +61,15 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
const { toast } = useToast();
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
const [visibleLoadingPaths, setVisibleLoadingPaths] = useState<Set<string>>(new Set());
const loadingTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
const [isLoadingTree, setIsLoadingTree] = useState(true);
const [loadState, setLoadState] = useState<'loading' | 'waiting' | 'loaded' | 'manual' | 'error'>('loading');
const [loadError, setLoadError] = useState<string | null>(null);
const agentctlStatus = useSessionAgentctl(sessionId);
const { isFailed: isSessionFailed, errorMessage: sessionError } = useSession(sessionId);
const { session, isFailed: isSessionFailed, errorMessage: sessionError } = useSession(sessionId);
const { open: openFolder } = useOpenSessionFolder(sessionId);
const { copied, copy: copyPath } = useCopyToClipboard(1000);
// Search state
const [localSearchQuery, setLocalSearchQuery] = useState('');
@@ -58,6 +90,36 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
const MAX_RETRY_ATTEMPTS = 4;
const RETRY_DELAYS_MS = [1000, 2000, 5000, 10000];
// Workspace path for header
const fullPath = session?.worktree_path ?? '';
const displayPath = fullPath.replace(/^\/(?:Users|home)\/[^/]+\//, '~/');
const collapseAll = useCallback(() => {
setExpandedPaths(new Set());
}, []);
// Show loading spinner only after 150ms to avoid flash on fast loads
const showLoading = useCallback((path: string) => {
const timer = setTimeout(() => {
setVisibleLoadingPaths((prev) => new Set(prev).add(path));
loadingTimersRef.current.delete(path);
}, 150);
loadingTimersRef.current.set(path, timer);
}, []);
const hideLoading = useCallback((path: string) => {
const timer = loadingTimersRef.current.get(path);
if (timer) {
clearTimeout(timer);
loadingTimersRef.current.delete(path);
}
setVisibleLoadingPaths((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}, []);
// Focus search input when search opens
useEffect(() => {
if (isSearchOpen && searchInputRef.current) {
@@ -131,7 +193,6 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
// Load initial tree - always try to load, don't wait for agentctl ready status
useEffect(() => {
setTree(null);
setLoadingPaths(new Set());
setIsLoadingTree(true);
setLoadState('loading');
setLoadError(null);
@@ -174,8 +235,8 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
}
hasInitializedExpandedRef.current = sessionId;
// Only expand the root folder to show its immediate children
setExpandedPaths(new Set([tree.path]));
// Root is no longer a visible node, start with no expanded paths
setExpandedPaths(new Set());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tree?.children?.length, isLoadingTree, sessionId]);
@@ -212,10 +273,16 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
if (!client) return;
const unsubscribe = client.on('session.workspace.file.changes', async () => {
// Refresh the root tree when any file changes
// Refresh the root tree when any file changes.
// Merge new top-level children into the existing tree so that
// already-loaded subtrees (expanded folders) are preserved.
try {
const response = await requestFileTree(client, sessionId, '', 1);
setTree(response.root ?? null);
const newRoot = response.root ?? null;
setTree((prev) => {
if (!prev || !newRoot) return newRoot;
return mergeTreeNodes(prev, newRoot);
});
setLoadState('loaded');
} catch (error) {
console.error('Failed to refresh file tree:', error);
@@ -325,7 +392,7 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
} else {
// If children not loaded yet, fetch them
if (!node.children || node.children.length === 0) {
setLoadingPaths((prev) => new Set(prev).add(node.path));
showLoading(node.path);
try {
const client = getWebSocketClient();
if (!client) return;
@@ -349,18 +416,14 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
} catch (error) {
console.error('Failed to load children:', error);
} finally {
setLoadingPaths((prev) => {
const next = new Set(prev);
next.delete(node.path);
return next;
});
hideLoading(node.path);
}
}
newExpanded.add(node.path);
setExpandedPaths(newExpanded);
}
}, [expandedPaths, sessionId, tree]);
}, [expandedPaths, sessionId, tree, showLoading, hideLoading]);
const openFile = useCallback(async (node: FileTreeNode) => {
if (node.is_dir) return;
@@ -382,6 +445,7 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
});
} catch (error) {
const reason = error instanceof Error ? error.message : 'Unknown error';
@@ -415,6 +479,7 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
});
} catch (error) {
const reason = error instanceof Error ? error.message : 'Unknown error';
@@ -428,32 +493,36 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
const renderTreeNode = (node: FileTreeNode, depth: number = 0): React.ReactNode => {
const isExpanded = expandedPaths.has(node.path);
const isLoading = loadingPaths.has(node.path);
const isLoading = visibleLoadingPaths.has(node.path);
const isActive = !node.is_dir && activeFilePath === node.path;
return (
<div key={node.path}>
<div
className={`group flex items-center gap-1 py-1 px-2 hover:bg-muted/50 cursor-pointer rounded text-sm ${isActive ? 'bg-muted/50' : ''}`}
className={cn(
"group flex w-full items-center gap-1 px-2 py-0.5 text-left text-sm cursor-pointer",
"hover:bg-muted",
isActive && "bg-muted"
)}
style={{ paddingLeft: `${depth * 12 + 8 + (node.is_dir ? 0 : 20)}px` }}
onClick={() => (node.is_dir ? toggleExpand(node) : openFile(node))}
>
{node.is_dir && (
<span className="flex-shrink-0">
{isLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<IconRefresh className="h-4 w-4 animate-spin text-muted-foreground shrink-0" />
) : isExpanded ? (
<IconChevronDown className="h-4 w-4 text-muted-foreground" />
<IconChevronDown className="h-3 w-3 text-muted-foreground/60" />
) : (
<IconChevronRight className="h-4 w-4 text-muted-foreground" />
<IconChevronRight className="h-3 w-3 text-muted-foreground/60" />
)}
</span>
)}
{node.is_dir ? (
isExpanded ? (
<IconFolderOpen className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<IconFolderOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<IconFolder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<IconFolder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)
) : (
<FileIcon
@@ -461,8 +530,8 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
filePath={node.path}
className="flex-shrink-0"
style={{
width: '12px',
height: '12px',
width: '14px',
height: '14px',
opacity: isActive ? 1 : 0.7
}}
/>
@@ -523,14 +592,17 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
return (
<div
key={path}
className="group flex items-center gap-1 py-1 px-2 hover:bg-muted/50 cursor-pointer rounded text-sm"
className={cn(
"group flex w-full items-center gap-1 px-2 py-0.5 text-left text-sm cursor-pointer",
"hover:bg-muted"
)}
onClick={() => openFileByPath(path)}
>
<FileIcon
fileName={name}
filePath={path}
className="flex-shrink-0"
style={{ width: '12px', height: '12px' }}
style={{ width: '14px', height: '14px' }}
/>
<span className="truncate text-muted-foreground group-hover:text-foreground">
{folder && <span>{folder}/</span>}
@@ -573,6 +645,59 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
</div>
)}
{/* Header toolbar */}
{tree && loadState === 'loaded' && (
<div className="group/header flex items-center gap-1 border-b border-border/50 px-2 py-1">
<Tooltip>
<TooltipTrigger asChild>
<button
className="relative shrink-0 cursor-pointer"
onClick={() => {
if (fullPath) void copyPath(fullPath);
}}
>
<IconFolderOpen className={cn("h-3.5 w-3.5 text-muted-foreground transition-opacity", copied ? "opacity-0" : "group-hover/header:opacity-0")} />
{copied ? (
<IconCheck className="absolute inset-0 h-3.5 w-3.5 text-green-600/70" />
) : (
<IconCopy className="absolute inset-0 h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover/header:opacity-100 hover:text-foreground transition-opacity" />
)}
</button>
</TooltipTrigger>
<TooltipContent>Copy workspace path</TooltipContent>
</Tooltip>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-muted-foreground">
{displayPath}
</span>
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={() => openFolder()}
>
<IconFolderShare className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Open workspace folder</TooltipContent>
</Tooltip>
{expandedPaths.size > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={collapseAll}
>
<IconListTree className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Collapse all</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
<ScrollArea className="flex-1" ref={scrollAreaRef}>
{isSearchOpen && searchResults !== null ? (
renderSearchResults()
@@ -583,7 +708,7 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
<div className="text-xs text-muted-foreground">{sessionError}</div>
)}
</div>
) : loadState === 'loading' || isLoadingTree ? (
) : (loadState === 'loading' || isLoadingTree) && !tree ? (
<div className="p-4 text-sm text-muted-foreground">Loading files...</div>
) : loadState === 'waiting' ? (
<div className="p-4 text-sm text-muted-foreground flex items-center gap-2">
@@ -602,7 +727,15 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
</button>
</div>
) : tree ? (
<div className="pb-2">{renderTreeNode(tree)}</div>
<div className="pb-2">
{tree.children && [...tree.children]
.sort((a, b) => {
if (a.is_dir && !b.is_dir) return -1;
if (!a.is_dir && b.is_dir) return 1;
return a.name.localeCompare(b.name);
})
.map((child) => renderTreeNode(child, 0))}
</div>
) : (
<div className="p-4 text-sm text-muted-foreground">No files found</div>
)}
@@ -0,0 +1,33 @@
'use client';
import { toRelativePath } from '@/lib/utils';
import { getImageMimeType } from '@/lib/utils/file-types';
type FileImageViewerProps = {
path: string;
content: string; // base64-encoded
worktreePath?: string;
};
export function FileImageViewer({ path, content, worktreePath }: FileImageViewerProps) {
const mime = getImageMimeType(path);
const src = `data:${mime};base64,${content}`;
return (
<div className="flex flex-col h-full">
<div className="flex items-center px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
</div>
</div>
<div className="flex-1 flex items-center justify-center overflow-auto p-6">
<img
src={src}
alt={path}
className="max-w-full max-h-full object-contain rounded"
draggable={false}
/>
</div>
</div>
);
}
+43 -22
View File
@@ -16,6 +16,8 @@ import { TaskChatPanel } from './task-chat-panel';
import { TaskChangesPanel } from './task-changes-panel';
import { TaskPlanPanel } from './task-plan-panel';
import { FileEditorContent } from './file-editor-content';
import { FileImageViewer } from './file-image-viewer';
import { FileBinaryViewer } from './file-binary-viewer';
import { PassthroughTerminal } from './passthrough-terminal';
import type { OpenFileTab, FileContentResponse } from '@/lib/types/backend';
import { useAppStore } from '@/components/state-provider';
@@ -27,6 +29,7 @@ import { getPlanLastSeen, setPlanLastSeen, getOpenFileTabs, setOpenFileTabs as s
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
import { generateUnifiedDiff, calculateHash } from '@/lib/utils/file-diff';
import { getFileCategory } from '@/lib/utils/file-types';
import { useToast } from '@/components/toast-provider';
import type { SelectedDiff } from './task-layout';
@@ -240,6 +243,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
});
} catch {
// Failed to restore tab, skip it
@@ -414,6 +418,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
};
const newTabs = prev.length >= maxTabs ? [...prev.slice(1), newTab] : [...prev, newTab];
return newTabs;
@@ -451,7 +456,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
setSavingFiles((prev) => new Set(prev).add(path));
try {
const diff = generateUnifiedDiff(tab.originalContent, tab.content, tab.name);
const diff = generateUnifiedDiff(tab.originalContent, tab.content, tab.path);
const response = await updateFileContent(client, activeSessionId, path, diff, tab.originalHash);
if (response.success && response.new_hash) {
@@ -462,10 +467,6 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
: t
)
);
toast({
title: 'File saved',
description: `${tab.name} has been saved successfully.`,
});
} else {
toast({
title: 'Save failed',
@@ -640,23 +641,43 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
)}
</TabsContent>
{openFileTabs.map((tab) => (
<TabsContent key={tab.path} value={`file:${tab.path}`} className="flex-1 min-h-0">
<FileEditorContent
path={tab.path}
content={tab.content}
originalContent={tab.originalContent}
isDirty={tab.isDirty}
isSaving={savingFiles.has(tab.path)}
sessionId={activeSessionId || undefined}
worktreePath={activeSession?.worktree_path ?? undefined}
enableComments={!!activeSessionId}
onChange={(newContent) => handleFileChange(tab.path, newContent)}
onSave={() => handleFileSave(tab.path)}
onDelete={() => handleFileDelete(tab.path)}
/>
</TabsContent>
))}
{openFileTabs.map((tab) => {
const extCategory = getFileCategory(tab.path);
const category = tab.isBinary
? (extCategory === 'image' ? 'image' : 'binary')
: 'text';
return (
<TabsContent key={tab.path} value={`file:${tab.path}`} className="flex-1 min-h-0">
{category === 'image' ? (
<FileImageViewer
path={tab.path}
content={tab.content}
worktreePath={activeSession?.worktree_path ?? undefined}
/>
) : category === 'binary' ? (
<FileBinaryViewer
path={tab.path}
worktreePath={activeSession?.worktree_path ?? undefined}
/>
) : (
<FileEditorContent
path={tab.path}
content={tab.content}
originalContent={tab.originalContent}
isDirty={tab.isDirty}
isSaving={savingFiles.has(tab.path)}
sessionId={activeSessionId || undefined}
worktreePath={activeSession?.worktree_path ?? undefined}
enableComments={!!activeSessionId}
onChange={(newContent) => handleFileChange(tab.path, newContent)}
onSave={() => handleFileSave(tab.path)}
onDelete={() => handleFileDelete(tab.path)}
/>
)}
</TabsContent>
);
})}
</SessionTabs>
</SessionPanel>
);
+71 -40
View File
@@ -89,6 +89,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
const [commitDiffs, setCommitDiffs] = useState<Record<string, Record<string, FileInfo>>>({});
const [loadingCommitSha, setLoadingCommitSha] = useState<string | null>(null);
const [pendingStageFiles, setPendingStageFiles] = useState<Set<string>>(new Set());
// Fetch commit diff
const fetchCommitDiff = useCallback(async (commitSha: string) => {
@@ -195,6 +196,40 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
}));
}, [gitStatus]);
// 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]);
// Smart tab selection: restore user preference or auto-select based on changes
useEffect(() => {
if (!activeSessionId) return;
@@ -286,44 +321,40 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
onClick={() => onSelectDiff(file.path)}
>
<div className="flex items-center gap-2 min-w-0">
{file.staged ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="group/unstage flex items-center justify-center h-4 w-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600"
onClick={(event) => {
event.stopPropagation();
void gitOps.unstage([file.path]);
}}
>
<IconCheck className="h-3 w-3 group-hover/unstage:hidden" />
<IconMinus className="h-2.5 w-2.5 hidden group-hover/unstage:block" />
</button>
</TooltipTrigger>
<TooltipContent>Click to unstage</TooltipContent>
</Tooltip>
{pendingStageFiles.has(file.path) ? (
<div className="flex-shrink-0 flex items-center justify-center size-4">
<IconLoader2 className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
) : file.staged ? (
<button
type="button"
title="Unstage file"
className="group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
void handleUnstage(file.path);
}}
>
<IconCheck className="h-3 w-3 group-hover/unstage:hidden" />
<IconMinus className="h-2.5 w-2.5 hidden group-hover/unstage:block" />
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center h-4 w-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10"
onClick={(event) => {
event.stopPropagation();
void gitOps.stage([file.path]);
}}
>
<IconPlus className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Click to stage</TooltipContent>
</Tooltip>
<button
type="button"
title="Stage file"
className="flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
void handleStage(file.path);
}}
>
<IconPlus className="h-2.5 w-2.5" />
</button>
)}
<button type="button" className="min-w-0 text-left cursor-pointer">
<p className="truncate text-foreground text-xs">
<span className="text-foreground/60">{folder}/</span>
<span className="font-medium text-foreground">{name}</span>
<button type="button" className="min-w-0 text-left cursor-pointer" title={file.path}>
<p className="flex text-foreground text-xs min-w-0">
{folder && <span className="text-foreground/60 truncate shrink">{folder}/</span>}
<span className="font-medium text-foreground whitespace-nowrap shrink-0">{name}</span>
</p>
</button>
</div>
@@ -358,7 +389,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
<IconExternalLink className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Open in editor</TooltipContent>
<TooltipContent>Open in external editor</TooltipContent>
</Tooltip>
</div>
<LineStat added={file.plus} removed={file.minus} />
@@ -423,9 +454,9 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
onClick={() => onSelectDiff(path, file.diff)}
>
<FileStatusIcon status={file.status} />
<span className="truncate flex-1">
<span className="text-foreground/60">{folder}/</span>
<span className="text-foreground">{fileName}</span>
<span className="flex flex-1 min-w-0" title={path}>
{folder && <span className="text-foreground/60 truncate shrink">{folder}/</span>}
<span className="text-foreground whitespace-nowrap shrink-0">{fileName}</span>
</span>
<span className="shrink-0 text-xs">
<span className="text-emerald-500">+{file.additions ?? 0}</span>
-11
View File
@@ -8,7 +8,6 @@ import {
IconBug,
IconCopy,
IconDots,
IconFolderOpen,
IconGitBranch,
IconGitMerge,
IconGitPullRequest,
@@ -55,7 +54,6 @@ import { EditorsMenu } from '@/components/task/editors-menu';
import { useToast } from '@/components/toast-provider';
import { PreviewControls } from '@/components/task/preview/preview-controls';
import { DEBUG_UI } from '@/lib/config';
import { useOpenSessionFolder } from '@/hooks/use-open-session-folder';
type TaskTopBarProps = {
taskId?: string | null;
@@ -105,7 +103,6 @@ const TaskTopBar = memo(function TaskTopBar({
const { toast } = useToast();
const gitStatus = useSessionGitStatus(activeSessionId ?? null);
const { pull, push, rebase, merge, commit, createPR, isLoading: isGitLoading } = useGitOperations(activeSessionId ?? null);
const { open: openFolder, isLoading: isOpenFolderLoading } = useOpenSessionFolder(activeSessionId);
// Use worktree branch if available, otherwise fall back to base branch
const displayBranch = worktreeBranch || baseBranch;
@@ -543,14 +540,6 @@ const TaskTopBar = memo(function TaskTopBar({
<IconCopy className="h-4 w-4" />
Copy workspace path
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => openFolder()}
disabled={!activeSessionId || isOpenFolderLoading}
>
<IconFolderOpen className="h-4 w-4" />
Open workspace folder
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
+3 -3
View File
@@ -1,17 +1,17 @@
import { useState, useCallback } from 'react';
export function useCopyToClipboard() {
export function useCopyToClipboard(duration = 2000) {
const [copied, setCopied] = useState(false);
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
setTimeout(() => setCopied(false), duration);
} catch (err) {
console.error('Failed to copy:', err);
}
}, []);
}, [duration]);
return { copied, copy };
}
+4 -4
View File
@@ -51,21 +51,21 @@ export function useGitOperations(sessionId: string | null): UseGitOperationsRetu
if (!sessionId) {
throw new Error('No session ID provided');
}
const client = getWebSocketClient();
if (!client) {
throw new Error('WebSocket not connected');
}
setIsLoading(true);
setError(null);
try {
const result = await client.request<T>(action, {
session_id: sessionId,
...payload
}, 60000); // 60s timeout for git operations
setLastResult(result);
if (!result.success && result.error) {
setError(result.error);
@@ -93,7 +93,7 @@ export const createSessionRuntimeSlice: StateCreator<
const syncStatusChanged = existing.ahead !== gitStatus.ahead ||
existing.behind !== gitStatus.behind;
// Compare file list (keys) and total stats, not individual file contents
// Compare file list (keys) and total stats
const existingFileKeys = existing.files ? Object.keys(existing.files).sort().join(',') : '';
const newFileKeys = gitStatus.files ? Object.keys(gitStatus.files).sort().join(',') : '';
const fileListChanged = existingFileKeys !== newFileKeys;
@@ -116,7 +116,17 @@ export const createSessionRuntimeSlice: StateCreator<
const statsChanged = existingTotal.additions !== newTotal.additions ||
existingTotal.deletions !== newTotal.deletions;
if (!branchChanged && !syncStatusChanged && !fileListChanged && !statsChanged) {
// Compare staged status of individual files
const stagedChanged = (() => {
if (!existing.files || !gitStatus.files) return existing.files !== gitStatus.files;
const keys = Object.keys(gitStatus.files);
for (const key of keys) {
if (existing.files[key]?.staged !== gitStatus.files[key]?.staged) return true;
}
return false;
})();
if (!branchChanged && !syncStatusChanged && !fileListChanged && !statsChanged && !stagedChanged) {
return; // No meaningful change, skip update
}
}
+2
View File
@@ -415,6 +415,7 @@ export type FileContentResponse = {
path: string;
content: string;
size: number;
is_binary?: boolean;
error?: string;
};
@@ -447,6 +448,7 @@ export type OpenFileTab = {
originalContent: string; // For diff generation
originalHash: string; // SHA256 for conflict detection
isDirty: boolean; // Has unsaved changes
isBinary?: boolean; // Binary file (content is base64-encoded)
};
// File extension to color mapping for file type indicators
+58
View File
@@ -0,0 +1,58 @@
export type FileCategory = 'text' | 'image' | 'binary';
const IMAGE_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif',
]);
const BINARY_EXTENSIONS = new Set([
// Executables & libraries
'exe', 'dll', 'so', 'dylib', 'o', 'a',
// Archives
'zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'xz', 'zst',
// Fonts
'woff', 'woff2', 'ttf', 'otf', 'eot',
// Media
'mp3', 'mp4', 'wav', 'ogg', 'flac', 'avi', 'mov', 'mkv', 'webm',
// Documents
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
// Compiled / data
'pyc', 'pyo', 'class', 'wasm', 'sqlite', 'db',
// Images already covered above, but some binary-only image formats
'psd', 'tiff', 'tif',
]);
const MIME_MAP: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
ico: 'image/x-icon',
bmp: 'image/bmp',
avif: 'image/avif',
};
function getExtension(path: string): string {
const dot = path.lastIndexOf('.');
if (dot === -1) return '';
return path.slice(dot + 1).toLowerCase();
}
/**
* Determine the file category based on extension.
* Note: the backend's `is_binary` flag is the source of truth for encoding;
* this only determines which viewer to use.
*/
export function getFileCategory(path: string): FileCategory {
const ext = getExtension(path);
if (IMAGE_EXTENSIONS.has(ext)) return 'image';
if (BINARY_EXTENSIONS.has(ext)) return 'binary';
return 'text';
}
/** Return the MIME type for a known image extension, or a fallback. */
export function getImageMimeType(path: string): string {
const ext = getExtension(path);
return MIME_MAP[ext] || 'application/octet-stream';
}