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 ( +