feat: improve file tree (#209)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user