From daccb38354210b950475244f8aa890613400a396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 7 Feb 2026 18:12:23 +0000 Subject: [PATCH] feat: improve file tree (#209) --- .../internal/agentctl/server/api/workspace.go | 4 +- .../internal/agentctl/server/process/git.go | 12 ++ .../server/process/workspace_tracker.go | 38 +++- .../server/process/workspace_tracker_test.go | 70 +++++++ .../internal/agentctl/types/streams/file.go | 6 +- .../components/task/file-binary-viewer.tsx | 26 +++ apps/web/components/task/file-browser.tsx | 189 +++++++++++++++--- .../web/components/task/file-image-viewer.tsx | 33 +++ .../web/components/task/task-center-panel.tsx | 65 ++++-- apps/web/components/task/task-files-panel.tsx | 111 ++++++---- apps/web/components/task/task-top-bar.tsx | 11 - apps/web/hooks/use-copy-to-clipboard.ts | 6 +- apps/web/hooks/use-git-operations.ts | 8 +- .../session-runtime/session-runtime-slice.ts | 14 +- apps/web/lib/types/backend.ts | 2 + apps/web/lib/utils/file-types.ts | 58 ++++++ 16 files changed, 529 insertions(+), 124 deletions(-) create mode 100644 apps/web/components/task/file-binary-viewer.tsx create mode 100644 apps/web/components/task/file-image-viewer.tsx create mode 100644 apps/web/lib/utils/file-types.ts diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index 9194acce0e..d627c79136 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -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 diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go index 8030b45c1d..372bc8938f 100644 --- a/apps/backend/internal/agentctl/server/process/git.go +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -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 } diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 1c7c3db960..44e2ce9fe9 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -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() diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go index 58487cd33a..343229b854 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go @@ -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") + } +} diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go index abe3c41e54..b366a8c421 100644 --- a/apps/backend/internal/agentctl/types/streams/file.go +++ b/apps/backend/internal/agentctl/types/streams/file.go @@ -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"` } diff --git a/apps/web/components/task/file-binary-viewer.tsx b/apps/web/components/task/file-binary-viewer.tsx new file mode 100644 index 0000000000..84a3ec35d6 --- /dev/null +++ b/apps/web/components/task/file-binary-viewer.tsx @@ -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 ( +
+
+
+ {toRelativePath(path, worktreePath)} +
+
+
+ +
Cannot preview this file
+
Binary file
+
+
+ ); +} diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index d60edd6754..d4d2c6b3f4 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -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(null); const [expandedPaths, setExpandedPaths] = useState>(new Set()); - const [loadingPaths, setLoadingPaths] = useState>(new Set()); + const [visibleLoadingPaths, setVisibleLoadingPaths] = useState>(new Set()); + const loadingTimersRef = useRef>(new Map()); const [isLoadingTree, setIsLoadingTree] = useState(true); const [loadState, setLoadState] = useState<'loading' | 'waiting' | 'loaded' | 'manual' | 'error'>('loading'); const [loadError, setLoadError] = useState(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 (
(node.is_dir ? toggleExpand(node) : openFile(node))} > {node.is_dir && ( {isLoading ? ( - + ) : isExpanded ? ( - + ) : ( - + )} )} {node.is_dir ? ( isExpanded ? ( - + ) : ( - + ) ) : ( @@ -523,14 +592,17 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen return (
openFileByPath(path)} > {folder && {folder}/} @@ -573,6 +645,59 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
)} + {/* Header toolbar */} + {tree && loadState === 'loaded' && ( +
+ + + + + Copy workspace path + + + {displayPath} + +
+ + + + + Open workspace folder + + {expandedPaths.size > 0 && ( + + + + + Collapse all + + )} +
+
+ )} + {isSearchOpen && searchResults !== null ? ( renderSearchResults() @@ -583,7 +708,7 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
{sessionError}
)}
- ) : loadState === 'loading' || isLoadingTree ? ( + ) : (loadState === 'loading' || isLoadingTree) && !tree ? (
Loading files...
) : loadState === 'waiting' ? (
@@ -602,7 +727,15 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
) : tree ? ( -
{renderTreeNode(tree)}
+
+ {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))} +
) : (
No files found
)} diff --git a/apps/web/components/task/file-image-viewer.tsx b/apps/web/components/task/file-image-viewer.tsx new file mode 100644 index 0000000000..0adc50c7bd --- /dev/null +++ b/apps/web/components/task/file-image-viewer.tsx @@ -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 ( +
+
+
+ {toRelativePath(path, worktreePath)} +
+
+
+ {path} +
+
+ ); +} diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx index 869bc849b0..38dc4914de 100644 --- a/apps/web/components/task/task-center-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -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({ )} - {openFileTabs.map((tab) => ( - - handleFileChange(tab.path, newContent)} - onSave={() => handleFileSave(tab.path)} - onDelete={() => handleFileDelete(tab.path)} - /> - - ))} + {openFileTabs.map((tab) => { + const extCategory = getFileCategory(tab.path); + const category = tab.isBinary + ? (extCategory === 'image' ? 'image' : 'binary') + : 'text'; + + return ( + + {category === 'image' ? ( + + ) : category === 'binary' ? ( + + ) : ( + handleFileChange(tab.path, newContent)} + onSave={() => handleFileSave(tab.path)} + onDelete={() => handleFileDelete(tab.path)} + /> + )} + + ); + })} ); diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index 1fe4c87b77..e7393f5824 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -89,6 +89,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, 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) => { @@ -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)} >
- {file.staged ? ( - - - - - Click to unstage - + {pendingStageFiles.has(file.path) ? ( +
+ +
+ ) : file.staged ? ( + ) : ( - - - - - Click to stage - + )} -
@@ -358,7 +389,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, - Open in editor + Open in external editor
@@ -423,9 +454,9 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, onClick={() => onSelectDiff(path, file.diff)} > - - {folder}/ - {fileName} + + {folder && {folder}/} + {fileName} +{file.additions ?? 0} diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 98942638a4..c4b0cb52f1 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -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({ Copy workspace path - openFolder()} - disabled={!activeSessionId || isOpenFolderLoading} - > - - Open workspace folder - { 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 }; } diff --git a/apps/web/hooks/use-git-operations.ts b/apps/web/hooks/use-git-operations.ts index b4f58a6c4e..63444b3153 100644 --- a/apps/web/hooks/use-git-operations.ts +++ b/apps/web/hooks/use-git-operations.ts @@ -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(action, { session_id: sessionId, ...payload }, 60000); // 60s timeout for git operations - + setLastResult(result); if (!result.success && result.error) { setError(result.error); diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index 3d1f790b5a..1db04e67d6 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -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 } } diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 8c081cbf8e..d9aefd9d31 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -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 diff --git a/apps/web/lib/utils/file-types.ts b/apps/web/lib/utils/file-types.ts new file mode 100644 index 0000000000..58f38efeef --- /dev/null +++ b/apps/web/lib/utils/file-types.ts @@ -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 = { + 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'; +}