From 5bd298ddc2d32c5528913100d197f7fb3b59951b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Mon, 2 Feb 2026 00:59:48 +0000 Subject: [PATCH] feat: Add file editor with diff-based save functionality (#145) --- apps/backend/go.mod | 4 +- .../agent/handlers/workspace_file_handlers.go | 46 ++++++ .../internal/agentctl/client/client.go | 47 ++++++ .../internal/agentctl/server/api/server.go | 1 + .../internal/agentctl/server/api/workspace.go | 37 +++++ .../server/process/workspace_tracker.go | 84 ++++++++++ .../internal/agentctl/types/streams/file.go | 32 ++++ apps/backend/pkg/websocket/actions.go | 9 +- apps/pnpm-lock.yaml | 14 ++ apps/web/components/task/file-browser.tsx | 7 + .../components/task/file-editor-content.tsx | 150 ++++++++++++++++++ .../web/components/task/task-center-panel.tsx | 102 +++++++++++- apps/web/components/task/task-files-panel.tsx | 7 +- apps/web/lib/types/backend.ts | 3 + apps/web/lib/utils/file-diff.ts | 87 ++++++++++ apps/web/lib/ws/workspace-files.ts | 28 ++++ apps/web/package.json | 2 + 17 files changed, 640 insertions(+), 20 deletions(-) create mode 100644 apps/web/components/task/file-editor-content.tsx create mode 100644 apps/web/lib/utils/file-diff.ts diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 19a933e3bb..61e00591dd 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -10,12 +10,12 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/fsnotify/fsnotify v1.6.0 github.com/gin-gonic/gin v1.9.1 + github.com/github/copilot-sdk/go v0.1.18 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mark3labs/mcp-go v0.43.2 github.com/mattn/go-sqlite3 v1.14.33 github.com/nats-io/nats.go v1.31.0 - github.com/pelletier/go-toml/v2 v2.0.8 github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.11.1 github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7 @@ -39,7 +39,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect - github.com/github/copilot-sdk/go v0.1.18 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -67,6 +66,7 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/afero v1.9.5 // indirect diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index 33c8dc79b1..819a095b67 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -28,6 +28,7 @@ func NewWorkspaceFileHandlers(lm *lifecycle.Manager, log *logger.Logger) *Worksp func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorkspaceFileTreeGet, h.wsGetFileTree) d.RegisterFunc(ws.ActionWorkspaceFileContentGet, h.wsGetFileContent) + d.RegisterFunc(ws.ActionWorkspaceFileContentUpdate, h.wsUpdateFileContent) d.RegisterFunc(ws.ActionWorkspaceFilesSearch, h.wsSearchFiles) } @@ -108,6 +109,51 @@ func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Me return ws.NewResponse(msg.ID, msg.Action, response) } +// wsUpdateFileContent handles workspace.file.update action +func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + SessionID string `json:"session_id"` + Path string `json:"path"` + Diff string `json:"diff"` + OriginalHash string `json:"original_hash"` + } + + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + if req.Path == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "path is required", nil) + } + if req.Diff == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "diff is required", nil) + } + + // Get agent execution for this session + execution, found := h.lifecycle.GetExecutionBySessionID(req.SessionID) + if !found { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for session", nil) + } + + // Get agentctl client + client := execution.GetAgentCtlClient() + if client == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent client not available", nil) + } + + // Apply file diff via agentctl + response, err := client.ApplyFileDiff(ctx, req.Path, req.Diff, req.OriginalHash) + if err != nil { + h.logger.Error("failed to apply file diff", zap.Error(err), zap.String("session_id", req.SessionID), zap.String("path", req.Path)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to apply file diff: %v", err), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, response) +} + // wsSearchFiles handles workspace.files.search action func (h *WorkspaceFileHandlers) wsSearchFiles(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index 6b3bfe4610..13d6572c1d 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -13,6 +13,7 @@ import ( "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) @@ -414,6 +415,52 @@ func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileCont return &response, nil } +// ApplyFileDiff applies a unified diff to a file via HTTP POST +func (c *Client) ApplyFileDiff(ctx context.Context, path string, diff string, originalHash string) (*streams.FileUpdateResponse, error) { + reqBody := streams.FileUpdateRequest{ + Path: path, + Diff: diff, + OriginalHash: originalHash, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content", c.baseURL) + req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to apply file diff: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file update response body", zap.Error(err)) + } + }() + + var response streams.FileUpdateResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if response.Error != "" { + return nil, fmt.Errorf("file update error: %s", response.Error) + } + + if !response.Success { + return nil, fmt.Errorf("file update failed") + } + + return &response, nil +} + // Close closes all connections func (c *Client) Close() { c.CloseUpdatesStream() diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 45acf47f6b..76fa49ffa7 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -89,6 +89,7 @@ func (s *Server) setupRoutes() { // Workspace file operations (simple HTTP) api.GET("/workspace/tree", s.handleFileTree) api.GET("/workspace/file/content", s.handleFileContent) + api.POST("/workspace/file/content", s.handleFileUpdate) api.GET("/workspace/search", s.handleFileSearch) // Shell access (HTTP endpoints only - streaming is via /workspace/stream) diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index 579c2ac138..59d356cc13 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" "go.uber.org/zap" ) @@ -151,6 +152,42 @@ func (s *Server) handleFileContent(c *gin.Context) { c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size}) } +// handleFileUpdate handles file update requests via HTTP POST +func (s *Server) handleFileUpdate(c *gin.Context) { + var req streams.FileUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, streams.FileUpdateResponse{Error: "invalid request: " + err.Error()}) + return + } + + if req.Path == "" { + c.JSON(400, streams.FileUpdateResponse{Error: "path is required"}) + return + } + + if req.Diff == "" { + c.JSON(400, streams.FileUpdateResponse{Error: "diff is required"}) + return + } + + // Apply the diff + newHash, err := s.procMgr.GetWorkspaceTracker().ApplyFileDiff(req.Path, req.Diff, req.OriginalHash) + if err != nil { + c.JSON(400, streams.FileUpdateResponse{ + Path: req.Path, + Success: false, + Error: err.Error(), + }) + return + } + + c.JSON(200, streams.FileUpdateResponse{ + Path: req.Path, + Success: true, + NewHash: newHash, + }) +} + // handleFileSearch handles file search requests via HTTP GET func (s *Server) handleFileSearch(c *gin.Context) { query := c.Query("q") diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 30aa619d41..d15b2549e9 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -2,6 +2,8 @@ package process import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "io" "io/fs" @@ -1196,6 +1198,88 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error return string(content), info.Size(), nil } +// ApplyFileDiff applies a unified diff to a file with conflict detection +// Uses git apply for reliable, battle-tested patch application +func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, originalHash string) (string, error) { + cleanWorkDir := filepath.Clean(wt.workDir) + cleanReqPath := filepath.Clean(reqPath) + + // Resolve the full path - if already absolute and within workDir, use directly + var fullPath string + if filepath.IsAbs(cleanReqPath) && strings.HasPrefix(cleanReqPath, cleanWorkDir+string(os.PathSeparator)) { + fullPath = cleanReqPath + } else { + fullPath = filepath.Join(wt.workDir, cleanReqPath) + } + + // Path traversal protection + if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir { + return "", fmt.Errorf("path traversal detected") + } + + // Read current file content + currentContent, _, err := wt.GetFileContent(reqPath) + if err != nil { + return "", fmt.Errorf("failed to read current file: %w", err) + } + + // Calculate hash of current content for conflict detection + currentHash := calculateSHA256(currentContent) + if originalHash != "" && currentHash != originalHash { + return "", fmt.Errorf("conflict detected: file has been modified (expected hash %s, got %s)", originalHash, currentHash) + } + + // Write diff to a temporary patch file + patchFile := filepath.Join(wt.workDir, ".kandev-patch.tmp") + err = os.WriteFile(patchFile, []byte(unifiedDiff), 0644) + if err != nil { + return "", fmt.Errorf("failed to write patch file: %w", err) + } + defer func() { + _ = os.Remove(patchFile) // Best effort cleanup + }() + + // 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.Dir = wt.workDir + + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git apply failed: %w\nOutput: %s", err, string(output)) + } + + // Read the updated content + updatedContent, err := os.ReadFile(fullPath) + if err != nil { + return "", fmt.Errorf("failed to read updated file: %w", err) + } + + // Calculate new hash + newHash := calculateSHA256(string(updatedContent)) + + // Trigger filesystem change notification + select { + case wt.fsChangeTrigger <- struct{}{}: + default: + // Channel already has a pending trigger, skip + } + + wt.logger.Debug("applied file diff using git apply", + zap.String("path", reqPath), + zap.String("old_hash", currentHash), + zap.String("new_hash", newHash), + ) + + return newHash, nil +} + +// calculateSHA256 calculates the SHA256 hash of a string +func calculateSHA256(content string) string { + hash := sha256.Sum256([]byte(content)) + return hex.EncodeToString(hash[:]) +} + // scoredMatch holds a file path and its match score for sorting type scoredMatch struct { path string diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go index ae76db2588..ac596562ee 100644 --- a/apps/backend/internal/agentctl/types/streams/file.go +++ b/apps/backend/internal/agentctl/types/streams/file.go @@ -148,3 +148,35 @@ type FileSearchResponse struct { Error string `json:"error,omitempty"` } +// FileUpdateRequest represents a request to update file content using a diff. +// +// HTTP endpoint: POST /api/v1/workspace/file/content +type FileUpdateRequest struct { + // Path is the file path (relative to workspace root). + Path string `json:"path"` + + // Diff is the unified diff to apply to the file. + Diff string `json:"diff"` + + // OriginalHash is the SHA256 hash of the original file content. + // Used for conflict detection. + OriginalHash string `json:"original_hash"` +} + +// FileUpdateResponse represents a response to a file update request. +type FileUpdateResponse struct { + // RequestID identifies the request. + RequestID string `json:"request_id"` + + // Path is the file path. + Path string `json:"path"` + + // Success indicates if the update was successful. + Success bool `json:"success"` + + // NewHash is the SHA256 hash of the updated file content. + NewHash string `json:"new_hash,omitempty"` + + // Error contains error message if the request failed. + Error string `json:"error,omitempty"` +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index dbde9dff05..0f2c34cf20 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -167,10 +167,11 @@ const ( ActionPermissionRespond = "permission.respond" // User responding to permission request // Workspace file operations - ActionWorkspaceFileTreeGet = "workspace.tree.get" - ActionWorkspaceFileContentGet = "workspace.file.get" - ActionWorkspaceFilesSearch = "workspace.files.search" - ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification + ActionWorkspaceFileTreeGet = "workspace.tree.get" + ActionWorkspaceFileContentGet = "workspace.file.get" + ActionWorkspaceFileContentUpdate = "workspace.file.update" + ActionWorkspaceFilesSearch = "workspace.files.search" + ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification // Shell actions ActionShellStatus = "session.shell.status" // Get shell status diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 85d0707d40..9d0306cab8 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -369,6 +369,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + diff: + specifier: ^8.0.3 + version: 8.0.3 embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) @@ -436,6 +439,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.1.18 + '@types/diff': + specifier: ^8.0.0 + version: 8.0.0 '@types/node': specifier: ^20 version: 20.19.28 @@ -2744,6 +2750,10 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/diff@8.0.0': + resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} + deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed. + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -9021,6 +9031,10 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/diff@8.0.0': + dependencies: + diff: 8.0.3 + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index 5c931808f3..0d68c12a49 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -117,10 +117,17 @@ export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) { const response: FileContentResponse = await requestFileContent(client, sessionId, node.path); + // Calculate hash for the file content + const { calculateHash } = await import('@/lib/utils/file-diff'); + const hash = await calculateHash(response.content); + onOpenFile({ path: node.path, name: node.name, content: response.content, + originalContent: response.content, + originalHash: hash, + isDirty: false, }); } catch (error) { console.error('Failed to load file content:', error); diff --git a/apps/web/components/task/file-editor-content.tsx b/apps/web/components/task/file-editor-content.tsx new file mode 100644 index 0000000000..f558adda57 --- /dev/null +++ b/apps/web/components/task/file-editor-content.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useCallback, useEffect, useMemo } from 'react'; +import CodeMirror from '@uiw/react-codemirror'; +import { vscodeDark } from '@uiw/codemirror-theme-vscode'; +import { EditorView } from '@codemirror/view'; +import type { Extension } from '@codemirror/state'; +import { getCodeMirrorExtensionFromPath } from '@/lib/languages'; +import { Button } from '@kandev/ui/button'; +import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons-react'; +import { formatDiffStats, calculateDiffStats } from '@/lib/utils/file-diff'; + +type FileEditorContentProps = { + path: string; + content: string; + originalContent: string; + isDirty: boolean; + isSaving: boolean; + onChange: (newContent: string) => void; + onSave: () => void; +}; + +export function FileEditorContent({ + path, + content, + originalContent, + isDirty, + isSaving, + onChange, + onSave, +}: FileEditorContentProps) { + const langExt = getCodeMirrorExtensionFromPath(path); + const extensions: Extension[] = [ + EditorView.lineWrapping, + EditorView.editable.of(true), + ]; + if (langExt) { + extensions.push(langExt); + } + + // Calculate diff stats when dirty + const diffStats = useMemo(() => { + if (!isDirty) return null; + + // Simple line-based diff for stats + const originalLines = originalContent.split('\n'); + const currentLines = content.split('\n'); + + let additions = 0; + let deletions = 0; + + // Simple comparison - count different lines + const maxLen = Math.max(originalLines.length, currentLines.length); + for (let i = 0; i < maxLen; i++) { + const origLine = originalLines[i]; + const currLine = currentLines[i]; + + if (origLine === undefined && currLine !== undefined) { + additions++; + } else if (origLine !== undefined && currLine === undefined) { + deletions++; + } else if (origLine !== currLine) { + // Line changed - count as both addition and deletion + additions++; + deletions++; + } + } + + return { additions, deletions }; + }, [isDirty, content, originalContent]); + + // Handle Cmd/Ctrl+S keyboard shortcut + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + if (isDirty && !isSaving) { + onSave(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isDirty, isSaving, onSave]); + + const handleChange = useCallback( + (value: string) => { + onChange(value); + }, + [onChange] + ); + + return ( +
+ {/* Editor header with save button */} +
+
+ {path} + {isDirty && diffStats && ( + + {formatDiffStats(diffStats.additions, diffStats.deletions)} + + )} +
+ +
+ + {/* CodeMirror editor */} +
+ +
+
+ ); +} + diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx index 168ece2dac..152564b16f 100644 --- a/apps/web/components/task/task-center-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -16,6 +16,7 @@ import { TaskChatPanel } from './task-chat-panel'; import { TaskChangesPanel } from './task-changes-panel'; import { TaskPlanPanel } from './task-plan-panel'; import { FileViewerContent } from './file-viewer-content'; +import { FileEditorContent } from './file-editor-content'; import { PassthroughTerminal } from './passthrough-terminal'; import type { OpenFileTab, FileContentResponse } from '@/lib/types/backend'; import { FILE_EXTENSION_COLORS } from '@/lib/types/backend'; @@ -23,7 +24,9 @@ import { useAppStore } from '@/components/state-provider'; import { SessionTabs, type SessionTab } from '@/components/session-tabs'; import { approveSessionAction } from '@/app/actions/workspaces'; import { getWebSocketClient } from '@/lib/ws/connection'; -import { requestFileContent } from '@/lib/ws/workspace-files'; +import { requestFileContent, updateFileContent } from '@/lib/ws/workspace-files'; +import { generateUnifiedDiff, calculateHash } from '@/lib/utils/file-diff'; +import { useToast } from '@/components/toast-provider'; import type { SelectedDiff } from './task-layout'; @@ -113,6 +116,8 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ }, []); const [selectedDiff, setSelectedDiff] = useState(null); const [openFileTabs, setOpenFileTabs] = useState([]); + const [savingFiles, setSavingFiles] = useState>(new Set()); + const { toast } = useToast(); // Track plan updates for notification dot const plan = useAppStore((state) => @@ -156,18 +161,27 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ // Handle external file open request useEffect(() => { if (openFileRequest) { - queueMicrotask(() => { + queueMicrotask(async () => { + // Calculate hash for the file if not already present + const hash = openFileRequest.originalHash || await calculateHash(openFileRequest.content); + const fileWithHash: OpenFileTab = { + ...openFileRequest, + originalContent: openFileRequest.originalContent || openFileRequest.content, + originalHash: hash, + isDirty: openFileRequest.isDirty ?? false, + }; + setOpenFileTabs((prev) => { // If file is already open, just switch to it - if (prev.some((t) => t.path === openFileRequest.path)) { + if (prev.some((t) => t.path === fileWithHash.path)) { return prev; } // Add new tab (LRU eviction at max 4) const maxTabs = 4; - const newTabs = prev.length >= maxTabs ? [...prev.slice(1), openFileRequest] : [...prev, openFileRequest]; + const newTabs = prev.length >= maxTabs ? [...prev.slice(1), fileWithHash] : [...prev, fileWithHash]; return newTabs; }); - setLeftTab(`file:${openFileRequest.path}`); + setLeftTab(`file:${fileWithHash.path}`); onFileOpenHandled(); }); } @@ -190,6 +204,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ try { const response: FileContentResponse = await requestFileContent(client, currentSessionId, filePath); const fileName = filePath.split('/').pop() || filePath; + const hash = await calculateHash(response.content); setOpenFileTabs((prev) => { // If file is already open, just switch to it @@ -202,6 +217,9 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ path: filePath, name: fileName, content: response.content, + originalContent: response.content, + originalHash: hash, + isDirty: false, }; const newTabs = prev.length >= maxTabs ? [...prev.slice(1), newTab] : [...prev, newTab]; return newTabs; @@ -212,6 +230,66 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ } }, [activeSessionId]); + // Handler for file content changes + const handleFileChange = useCallback((path: string, newContent: string) => { + setOpenFileTabs((prev) => + prev.map((tab) => + tab.path === path + ? { ...tab, content: newContent, isDirty: newContent !== tab.originalContent } + : tab + ) + ); + }, []); + + // Handler for saving file + const handleFileSave = useCallback(async (path: string) => { + const tab = openFileTabs.find((t) => t.path === path); + if (!tab || !tab.isDirty) return; + + const client = getWebSocketClient(); + if (!client || !activeSessionId) return; + + setSavingFiles((prev) => new Set(prev).add(path)); + + try { + const diff = generateUnifiedDiff(tab.originalContent, tab.content, tab.name); + const response = await updateFileContent(client, activeSessionId, path, diff, tab.originalHash); + + if (response.success && response.new_hash) { + setOpenFileTabs((prev) => + prev.map((t) => + t.path === path + ? { ...t, originalContent: t.content, originalHash: response.new_hash!, isDirty: false } + : t + ) + ); + toast({ + title: 'File saved', + description: `${tab.name} has been saved successfully.`, + }); + } else { + toast({ + title: 'Save failed', + description: response.error || 'Failed to save file', + variant: 'error', + }); + } + } catch (error) { + console.error('Failed to save file:', error); + toast({ + title: 'Save failed', + description: error instanceof Error ? error.message : 'An error occurred while saving the file', + variant: 'error', + }); + } finally { + setSavingFiles((prev) => { + const next = new Set(prev); + next.delete(path); + return next; + }); + } + }, [openFileTabs, activeSessionId, toast]); + const tabs: SessionTab[] = useMemo(() => { const staticTabs: SessionTab[] = [ { id: 'changes', label: 'All changes' }, @@ -230,10 +308,10 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ const fileTabs: SessionTab[] = openFileTabs.map((tab) => { const ext = tab.name.split('.').pop()?.toLowerCase() || ''; - const dotColor = FILE_EXTENSION_COLORS[ext] || 'bg-muted-foreground'; + const dotColor = tab.isDirty ? 'bg-yellow-500' : (FILE_EXTENSION_COLORS[ext] || 'bg-muted-foreground'); return { id: `file:${tab.path}`, - label: tab.name, + label: tab.isDirty ? `${tab.name} *` : tab.name, icon: , closable: true, onClose: (e) => { @@ -339,7 +417,15 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ {openFileTabs.map((tab) => ( - + handleFileChange(tab.path, newContent)} + onSave={() => handleFileSave(tab.path)} + /> ))} diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index 41eb926c16..38ed15c733 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -38,12 +38,7 @@ import type { FileInfo } from '@/lib/state/store'; import { FileBrowser } from '@/components/task/file-browser'; import { SessionTabs, type SessionTab } from '@/components/session-tabs'; import { useToast } from '@/components/toast-provider'; - -type OpenFileTab = { - path: string; - name: string; - content: string; -}; +import type { OpenFileTab } from '@/lib/types/backend'; type TaskFilesPanelProps = { onSelectDiff: (path: string, content?: string) => void; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index f74710bf13..c79ceb24af 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -423,6 +423,9 @@ export type OpenFileTab = { path: string; name: string; content: string; + originalContent: string; // For diff generation + originalHash: string; // SHA256 for conflict detection + isDirty: boolean; // Has unsaved changes }; // File extension to color mapping for file type indicators diff --git a/apps/web/lib/utils/file-diff.ts b/apps/web/lib/utils/file-diff.ts new file mode 100644 index 0000000000..ae952205a8 --- /dev/null +++ b/apps/web/lib/utils/file-diff.ts @@ -0,0 +1,87 @@ +import { createTwoFilesPatch } from 'diff'; + +/** + * Generate a unified diff between original and modified content + * @param original - Original file content + * @param modified - Modified file content + * @param filename - File name (used in diff header) + * @returns Unified diff string + */ +export function generateUnifiedDiff( + original: string, + modified: string, + filename: string +): string { + // createTwoFilesPatch generates a unified diff with headers + // Parameters: oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options + // Note: oldHeader and newHeader should be empty strings or valid timestamps + // The go-diff library on the backend expects standard unified diff format + const diff = createTwoFilesPatch( + filename, + filename, + original, + modified, + '', // Empty oldHeader - backend doesn't need timestamps + '', // Empty newHeader - backend doesn't need timestamps + { context: 3 } // Number of context lines + ); + + return diff; +} + +/** + * Calculate SHA256 hash of content + * @param content - Content to hash + * @returns Hex-encoded SHA256 hash + */ +export async function calculateHash(content: string): Promise { + // Use Web Crypto API for SHA256 hashing + const encoder = new TextEncoder(); + const data = encoder.encode(content); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + + // Convert buffer to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; +} + +/** + * Calculate diff stats (additions and deletions) + * @param diff - Unified diff string + * @returns Object with additions and deletions count + */ +export function calculateDiffStats(diff: string): { additions: number; deletions: number } { + const lines = diff.split('\n'); + let additions = 0; + let deletions = 0; + + for (const line of lines) { + if (line.startsWith('+') && !line.startsWith('+++')) { + additions++; + } else if (line.startsWith('-') && !line.startsWith('---')) { + deletions++; + } + } + + return { additions, deletions }; +} + +/** + * Format diff stats for display + * @param additions - Number of additions + * @param deletions - Number of deletions + * @returns Formatted string like "+5 -3" + */ +export function formatDiffStats(additions: number, deletions: number): string { + const parts: string[] = []; + if (additions > 0) { + parts.push(`+${additions}`); + } + if (deletions > 0) { + parts.push(`-${deletions}`); + } + return parts.join(' ') || 'No changes'; +} + diff --git a/apps/web/lib/ws/workspace-files.ts b/apps/web/lib/ws/workspace-files.ts index fe0cb3a97e..e0b1a64f7e 100644 --- a/apps/web/lib/ws/workspace-files.ts +++ b/apps/web/lib/ws/workspace-files.ts @@ -46,3 +46,31 @@ export async function searchWorkspaceFiles( limit, }); } + +/** + * File update response from backend + */ +export type FileUpdateResponse = { + path: string; + success: boolean; + new_hash?: string; + error?: string; +}; + +/** + * Update file content using a diff + */ +export async function updateFileContent( + client: WebSocketClient, + sessionId: string, + path: string, + diff: string, + originalHash: string +): Promise { + return client.request('workspace.file.update', { + session_id: sessionId, + path, + diff, + original_hash: originalHash, + }); +} diff --git a/apps/web/package.json b/apps/web/package.json index 51b00a53d3..78e230abac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,6 +57,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "diff": "^8.0.3", "embla-carousel-react": "^8.6.0", "immer": "^10.1.1", "input-otp": "^1.4.2", @@ -81,6 +82,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/diff": "^8.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19",