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