feat: improve git operations (branch rename, amend commit, file rename, reset) (#337)

This commit is contained in:
José Almeida
2026-03-03 12:41:51 +00:00
committed by GitHub
parent 46c6a30b18
commit c191ef2577
44 changed files with 2814 additions and 569 deletions
@@ -51,6 +51,8 @@ func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionWorktreeDiscard, h.wsDiscard)
d.RegisterFunc(ws.ActionWorktreeCreatePR, h.wsCreatePR)
d.RegisterFunc(ws.ActionWorktreeRevertCommit, h.wsRevertCommit)
d.RegisterFunc(ws.ActionWorktreeRenameBranch, h.wsRenameBranch)
d.RegisterFunc(ws.ActionWorktreeReset, h.wsReset)
d.RegisterFunc(ws.ActionSessionCommitDiff, h.wsCommitDiff)
}
@@ -90,6 +92,13 @@ type GitCommitRequest struct {
SessionID string `json:"session_id"`
Message string `json:"message"`
StageAll bool `json:"stage_all"`
Amend bool `json:"amend"`
}
// GitRenameBranchRequest for worktree.rename_branch action
type GitRenameBranchRequest struct {
SessionID string `json:"session_id"`
NewName string `json:"new_name"`
}
// GitStageRequest for worktree.stage action
@@ -125,6 +134,13 @@ type GitRevertCommitRequest struct {
CommitSHA string `json:"commit_sha"`
}
// GitResetRequest for worktree.reset action
type GitResetRequest struct {
SessionID string `json:"session_id"`
CommitSHA string `json:"commit_sha"`
Mode string `json:"mode"` // "soft", "mixed", or "hard"
}
// GitShowCommitRequest for session.commit_diff action
type GitShowCommitRequest struct {
SessionID string `json:"session_id"`
@@ -279,7 +295,7 @@ func (h *GitHandlers) wsCommit(ctx context.Context, msg *ws.Message) (*ws.Messag
return nil, err
}
result, err := client.GitCommit(ctx, req.Message, req.StageAll)
result, err := client.GitCommit(ctx, req.Message, req.StageAll, req.Amend)
if err != nil {
return nil, fmt.Errorf("commit failed: %w", err)
}
@@ -287,6 +303,67 @@ func (h *GitHandlers) wsCommit(ctx context.Context, msg *ws.Message) (*ws.Messag
return ws.NewResponse(msg.ID, msg.Action, result)
}
// wsRenameBranch handles worktree.rename_branch action
func (h *GitHandlers) wsRenameBranch(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req GitRenameBranchRequest
if err := msg.ParsePayload(&req); err != nil {
return nil, fmt.Errorf("invalid payload: %w", err)
}
if req.SessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
if req.NewName == "" {
return nil, fmt.Errorf("new_name is required")
}
client, err := h.getAgentCtlClient(req.SessionID)
if err != nil {
return nil, err
}
result, err := client.GitRenameBranch(ctx, req.NewName)
if err != nil {
return nil, fmt.Errorf("rename branch failed: %w", err)
}
return ws.NewResponse(msg.ID, msg.Action, result)
}
// wsReset handles worktree.reset action
func (h *GitHandlers) wsReset(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req GitResetRequest
if err := msg.ParsePayload(&req); err != nil {
return nil, fmt.Errorf("invalid payload: %w", err)
}
if req.SessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
if req.CommitSHA == "" {
return nil, fmt.Errorf("commit_sha is required")
}
if req.Mode == "" {
req.Mode = "mixed"
}
validModes := map[string]bool{"soft": true, "mixed": true, "hard": true}
if !validModes[req.Mode] {
return nil, fmt.Errorf("invalid reset mode: %s (must be soft, mixed, or hard)", req.Mode)
}
client, err := h.getAgentCtlClient(req.SessionID)
if err != nil {
return nil, err
}
result, err := client.GitReset(ctx, req.CommitSHA, req.Mode)
if err != nil {
return nil, fmt.Errorf("reset failed: %w", err)
}
return ws.NewResponse(msg.ID, msg.Action, result)
}
// wsStage handles worktree.stage action
func (h *GitHandlers) wsStage(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req GitStageRequest
@@ -32,6 +32,7 @@ func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionWorkspaceFileContentUpdate, h.wsUpdateFileContent)
d.RegisterFunc(ws.ActionWorkspaceFileCreate, h.wsCreateFile)
d.RegisterFunc(ws.ActionWorkspaceFileDelete, h.wsDeleteFile)
d.RegisterFunc(ws.ActionWorkspaceFileRename, h.wsRenameFile)
d.RegisterFunc(ws.ActionWorkspaceFilesSearch, h.wsSearchFiles)
}
@@ -268,3 +269,50 @@ func (h *WorkspaceFileHandlers) wsSearchFiles(ctx context.Context, msg *ws.Messa
return ws.NewResponse(msg.ID, msg.Action, response)
}
// wsRenameFile handles workspace.file.rename action
func (h *WorkspaceFileHandlers) wsRenameFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
SessionID string `json:"session_id"`
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
}
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.OldPath == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "old_path is required", nil)
}
if req.NewPath == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "new_path 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)
}
// Rename file via agentctl
response, err := client.RenameFile(ctx, req.OldPath, req.NewPath)
if err != nil {
h.logger.Error("failed to rename file", zap.Error(err),
zap.String("session_id", req.SessionID),
zap.String("old_path", req.OldPath),
zap.String("new_path", req.NewPath))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to rename file: %v", err), nil)
}
return ws.NewResponse(msg.ID, msg.Action, response)
}
@@ -14,7 +14,12 @@ import (
// RequestFileTree requests a file tree via HTTP GET
func (c *Client) RequestFileTree(ctx context.Context, path string, depth int) (*FileTreeResponse, error) {
reqURL := fmt.Sprintf("%s/api/v1/workspace/tree?path=%s&depth=%d", c.baseURL, path, depth)
reqURL := fmt.Sprintf(
"%s/api/v1/workspace/tree?path=%s&depth=%d",
c.baseURL,
url.QueryEscape(path),
depth,
)
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
@@ -76,7 +81,7 @@ func (c *Client) SearchFiles(ctx context.Context, query string, limit int) (*Fil
// RequestFileContent requests file content via HTTP GET
func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileContentResponse, error) {
reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content?path=%s", c.baseURL, path)
reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content?path=%s", c.baseURL, url.QueryEscape(path))
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
@@ -195,7 +200,7 @@ func (c *Client) CreateFile(ctx context.Context, path string) (*streams.FileCrea
// DeleteFile deletes a file via HTTP DELETE
func (c *Client) DeleteFile(ctx context.Context, path string) (*streams.FileDeleteResponse, error) {
reqURL := fmt.Sprintf("%s/api/v1/workspace/file?path=%s", c.baseURL, path)
reqURL := fmt.Sprintf("%s/api/v1/workspace/file?path=%s", c.baseURL, url.QueryEscape(path))
req, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, nil)
if err != nil {
@@ -228,6 +233,48 @@ func (c *Client) DeleteFile(ctx context.Context, path string) (*streams.FileDele
return &response, nil
}
// RenameFile renames/moves a file or directory via HTTP POST
func (c *Client) RenameFile(ctx context.Context, oldPath, newPath string) (*streams.FileRenameResponse, error) {
reqBody := streams.FileRenameRequest{OldPath: oldPath, NewPath: newPath}
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/rename", 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 rename file: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Debug("failed to close file rename response body", zap.Error(err))
}
}()
var response streams.FileRenameResponse
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 rename error: %s", response.Error)
}
if !response.Success {
return nil, fmt.Errorf("file rename failed")
}
return &response, nil
}
// FileSearchResponse represents a response with matching files
type FileSearchResponse struct {
Files []string `json:"files"`
+27 -1
View File
@@ -87,17 +87,30 @@ func (c *Client) GitAbort(ctx context.Context, operation string) (*GitOperationR
// GitCommit creates a commit with the specified message.
// If stageAll is true, all changes are staged before committing.
func (c *Client) GitCommit(ctx context.Context, message string, stageAll bool) (*GitOperationResult, error) {
// If amend is true, it amends the previous commit instead of creating a new one.
func (c *Client) GitCommit(ctx context.Context, message string, stageAll bool, amend bool) (*GitOperationResult, error) {
payload := struct {
Message string `json:"message"`
StageAll bool `json:"stage_all"`
Amend bool `json:"amend"`
}{
Message: message,
StageAll: stageAll,
Amend: amend,
}
return c.gitOperation(ctx, "/api/v1/git/commit", payload)
}
// GitRenameBranch renames the current branch to a new name.
func (c *Client) GitRenameBranch(ctx context.Context, newName string) (*GitOperationResult, error) {
payload := struct {
NewName string `json:"new_name"`
}{
NewName: newName,
}
return c.gitOperation(ctx, "/api/v1/git/rename-branch", payload)
}
// GitStage stages files for commit.
// If paths is empty, stages all changes (git add -A).
func (c *Client) GitStage(ctx context.Context, paths []string) (*GitOperationResult, error) {
@@ -141,6 +154,19 @@ func (c *Client) GitRevertCommit(ctx context.Context, commitSHA string) (*GitOpe
return c.gitOperation(ctx, "/api/v1/git/revert-commit", payload)
}
// GitReset resets HEAD to the specified commit.
// Mode can be "soft" (keep changes staged), "mixed" (keep changes unstaged), or "hard" (discard all changes).
func (c *Client) GitReset(ctx context.Context, commitSHA, mode string) (*GitOperationResult, error) {
payload := struct {
CommitSHA string `json:"commit_sha"`
Mode string `json:"mode"`
}{
CommitSHA: commitSHA,
Mode: mode,
}
return c.gitOperation(ctx, "/api/v1/git/reset", payload)
}
// GitCreatePR creates a pull request using the gh CLI.
func (c *Client) GitCreatePR(ctx context.Context, title, body, baseBranch string, draft bool) (*PRCreateResult, error) {
payload := struct {
@@ -2,6 +2,7 @@ package api
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
@@ -39,6 +40,12 @@ type GitAbortRequest struct {
type GitCommitRequest struct {
Message string `json:"message"`
StageAll bool `json:"stage_all"`
Amend bool `json:"amend"`
}
// GitRenameBranchRequest for POST /api/v1/git/rename-branch
type GitRenameBranchRequest struct {
NewName string `json:"new_name"`
}
// GitStageRequest for POST /api/v1/git/stage
@@ -74,6 +81,12 @@ type GitCreatePRRequest struct {
Draft bool `json:"draft"`
}
// GitResetRequest for POST /api/v1/git/reset
type GitResetRequest struct {
CommitSHA string `json:"commit_sha"`
Mode string `json:"mode"` // "soft", "mixed", or "hard"
}
// handleGitPull handles POST /api/v1/git/pull
func (s *Server) handleGitPull(c *gin.Context) {
var req GitPullRequest
@@ -233,7 +246,7 @@ func (s *Server) handleGitCommit(c *gin.Context) {
}
gitOp := s.procMgr.GitOperator()
result, err := gitOp.Commit(c.Request.Context(), req.Message, req.StageAll)
result, err := gitOp.Commit(c.Request.Context(), req.Message, req.StageAll, req.Amend)
if err != nil {
s.handleGitError(c, "commit", err)
return
@@ -242,6 +255,37 @@ func (s *Server) handleGitCommit(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
// handleGitRenameBranch handles POST /api/v1/git/rename-branch
func (s *Server) handleGitRenameBranch(c *gin.Context) {
var req GitRenameBranchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, process.GitOperationResult{
Success: false,
Operation: "rename_branch",
Error: "invalid request: " + err.Error(),
})
return
}
if req.NewName == "" {
c.JSON(http.StatusBadRequest, process.GitOperationResult{
Success: false,
Operation: "rename_branch",
Error: "new_name is required",
})
return
}
gitOp := s.procMgr.GitOperator()
result, err := gitOp.RenameBranch(c.Request.Context(), req.NewName)
if err != nil {
s.handleGitError(c, "rename_branch", err)
return
}
c.JSON(http.StatusOK, result)
}
// handleGitStage handles POST /api/v1/git/stage
func (s *Server) handleGitStage(c *gin.Context) {
var req GitStageRequest
@@ -414,6 +458,50 @@ func (s *Server) handleGitShowCommit(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
// handleGitReset handles POST /api/v1/git/reset
func (s *Server) handleGitReset(c *gin.Context) {
var req GitResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, process.GitOperationResult{
Success: false,
Operation: "reset",
Error: "invalid request: " + err.Error(),
})
return
}
if req.CommitSHA == "" {
c.JSON(http.StatusBadRequest, process.GitOperationResult{
Success: false,
Operation: "reset",
Error: "commit_sha is required",
})
return
}
if req.Mode == "" {
req.Mode = "mixed"
}
validModes := map[string]bool{"soft": true, "mixed": true, "hard": true}
if !validModes[req.Mode] {
c.JSON(http.StatusBadRequest, process.GitOperationResult{
Success: false,
Operation: "reset",
Error: fmt.Sprintf("invalid reset mode: %s (must be soft, mixed, or hard)", req.Mode),
})
return
}
gitOp := s.procMgr.GitOperator()
result, err := gitOp.Reset(c.Request.Context(), req.CommitSHA, req.Mode)
if err != nil {
s.handleGitError(c, "reset", err)
return
}
c.JSON(http.StatusOK, result)
}
// handleGitError handles errors from git operations.
func (s *Server) handleGitError(c *gin.Context, operation string, err error) {
if errors.Is(err, process.ErrOperationInProgress) {
@@ -87,6 +87,7 @@ func (s *Server) setupRoutes() {
api.GET("/workspace/file/content", s.handleFileContent)
api.POST("/workspace/file/content", s.handleFileUpdate)
api.POST("/workspace/file/create", s.handleFileCreate)
api.POST("/workspace/file/rename", s.handleFileRename)
api.DELETE("/workspace/file", s.handleFileDelete)
api.GET("/workspace/search", s.handleFileSearch)
@@ -121,6 +122,8 @@ func (s *Server) setupRoutes() {
api.POST("/git/discard", s.handleGitDiscard)
api.POST("/git/create-pr", s.handleGitCreatePR)
api.POST("/git/revert-commit", s.handleGitRevertCommit)
api.POST("/git/rename-branch", s.handleGitRenameBranch)
api.POST("/git/reset", s.handleGitReset)
api.GET("/git/commit/:sha", s.handleGitShowCommit)
}
@@ -233,6 +233,41 @@ func (s *Server) handleFileDelete(c *gin.Context) {
})
}
// handleFileRename handles file/directory rename requests via HTTP POST
func (s *Server) handleFileRename(c *gin.Context) {
var req streams.FileRenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, streams.FileRenameResponse{Error: "invalid request: " + err.Error()})
return
}
if req.OldPath == "" {
c.JSON(400, streams.FileRenameResponse{Error: "old_path is required"})
return
}
if req.NewPath == "" {
c.JSON(400, streams.FileRenameResponse{Error: "new_path is required"})
return
}
err := s.procMgr.GetWorkspaceTracker().RenameFile(req.OldPath, req.NewPath)
if err != nil {
c.JSON(400, streams.FileRenameResponse{
OldPath: req.OldPath,
NewPath: req.NewPath,
Success: false,
Error: err.Error(),
})
return
}
c.JSON(200, streams.FileRenameResponse{
OldPath: req.OldPath,
NewPath: req.NewPath,
Success: true,
})
}
func (s *Server) handleWorkspaceStreamInput(conn *websocket.Conn, shell io.Writer, done <-chan struct{}) {
for {
select {
@@ -9,13 +9,13 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/securityutil"
"go.uber.org/zap"
)
@@ -25,27 +25,6 @@ var ErrOperationInProgress = errors.New("git operation already in progress")
// ErrInvalidBranchName is returned when a branch name contains invalid characters.
var ErrInvalidBranchName = errors.New("invalid branch name")
// validBranchNameRegex matches safe git branch names.
// Allows alphanumeric, hyphens, underscores, slashes, and dots.
// Disallows: spaces, shell metacharacters, and control characters.
var validBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
// isValidBranchName validates that a branch name is safe to use in git commands.
func isValidBranchName(branch string) bool {
if branch == "" || len(branch) > 255 {
return false
}
// Disallow ".." to prevent path traversal
if strings.Contains(branch, "..") {
return false
}
// Disallow ending with ".lock"
if strings.HasSuffix(branch, ".lock") {
return false
}
return validBranchNameRegex.MatchString(branch)
}
// GitOperationResult represents the result of a git operation.
type GitOperationResult struct {
Success bool `json:"success"`
@@ -75,9 +54,81 @@ func NewGitOperator(workDir string, log *logger.Logger, workspaceTracker *Worksp
}
}
// runGitCommand executes a git command in the workDir
// runGitCommand executes a git command in the workDir with defense-in-depth validation.
// Validates both flags and branch/ref arguments to prevent command injection.
func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
// Validate that user-controlled arguments don't introduce command injection risks
// Even though exec.CommandContext doesn't use a shell, we must prevent argument
// injection where malicious input like "--help" or "--exec=..." could be passed
// as what appears to be a file/branch name but is interpreted as a flag by git.
skipNextArg := false
afterDoubleDash := false // Track if we've seen "--" separator
for i, arg := range args {
// Skip git subcommand (first argument)
if i == 0 {
continue
}
// Skip if this argument is a value for a previous flag
if skipNextArg {
skipNextArg = false
continue
}
// After "--", all arguments are file paths - no validation needed
if afterDoubleDash {
continue
}
// Validate flags against whitelist
if strings.HasPrefix(arg, "-") {
if !securityutil.IsKnownSafeGitFlag(arg) {
return "", fmt.Errorf("potentially unsafe flag: %s", arg)
}
// Special handling for "--" separator
if arg == "--" {
afterDoubleDash = true
continue
}
// Flags that take a value in the next argument
if arg == "-m" || arg == "--format" {
skipNextArg = true
}
continue
}
// Skip known safe git literals (HEAD, origin, etc.)
if securityutil.IsKnownSafeGitLiteral(arg) {
continue
}
// Skip commit SHAs (validated elsewhere via validateCommitSHA)
if securityutil.LooksLikeCommitSHA(arg) {
continue
}
// Validate branch references (e.g., "origin/branch", "upstream/main")
// This provides defense-in-depth even though branches are validated at call sites
if strings.Contains(arg, "/") {
if err := securityutil.ValidateBranchReference(arg); err != nil {
return "", err
}
continue
}
// Validate standalone branch names
if securityutil.IsValidBranchName(arg) {
// Standalone arg that matches branch name pattern - validated and safe
continue
}
// If we reach here, it's a non-branch argument (file path, etc.)
// which we don't validate as strictly
}
// All args validated: flags in securityutil.IsKnownSafeGitFlag whitelist, branch names via securityutil.IsValidBranchName
// regex, commit SHAs via securityutil.LooksLikeCommitSHA pattern, args after "--" separator skipped.
// This defense-in-depth validation prevents injection of arbitrary commands.
cmd := exec.CommandContext(ctx, "git", args...) // lgtm[go/command-injection]
cmd.Dir = g.workDir
var stdout, stderr bytes.Buffer
@@ -296,7 +347,7 @@ func (g *GitOperator) Push(ctx context.Context, force bool, setUpstream bool) (*
// Rebase performs a git rebase onto the specified base branch.
func (g *GitOperator) Rebase(ctx context.Context, baseBranch string) (*GitOperationResult, error) {
// Validate branch name to prevent command injection
if !isValidBranchName(baseBranch) {
if !securityutil.IsValidBranchName(baseBranch) {
return nil, ErrInvalidBranchName
}
@@ -343,7 +394,7 @@ func (g *GitOperator) Rebase(ctx context.Context, baseBranch string) (*GitOperat
// Merge performs a git merge of the specified base branch.
func (g *GitOperator) Merge(ctx context.Context, baseBranch string) (*GitOperationResult, error) {
// Validate branch name to prevent command injection
if !isValidBranchName(baseBranch) {
if !securityutil.IsValidBranchName(baseBranch) {
return nil, ErrInvalidBranchName
}
@@ -383,7 +434,8 @@ func (g *GitOperator) Merge(ctx context.Context, baseBranch string) (*GitOperati
// Commit creates a git commit with the specified message.
// If stageAll is true, it stages all changes before committing.
func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool) (*GitOperationResult, error) {
// If amend is true, it amends the previous commit instead of creating a new one.
func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool, amend bool) (*GitOperationResult, error) {
if !g.tryLock("commit") {
return nil, ErrOperationInProgress
}
@@ -393,16 +445,19 @@ func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool)
Operation: "commit",
}
// Check if there are changes to commit
hasChanges, err := g.hasUncommittedChanges(ctx)
if err != nil {
result.Error = err.Error()
return result, nil
}
// For amend, we don't require staged changes if we're just changing the message
if !amend {
// Check if there are changes to commit
hasChanges, err := g.hasUncommittedChanges(ctx)
if err != nil {
result.Error = err.Error()
return result, nil
}
if !hasChanges {
result.Error = "no changes to commit"
return result, nil
if !hasChanges {
result.Error = "no changes to commit"
return result, nil
}
}
// Stage all changes if requested
@@ -416,8 +471,12 @@ func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool)
result.Output = stageOutput
}
// Create the commit
commitOutput, err := g.runGitCommand(ctx, "commit", "-m", message)
// Create the commit (with --amend if requested)
args := []string{"commit", "-m", message}
if amend {
args = append(args, "--amend")
}
commitOutput, err := g.runGitCommand(ctx, args...)
result.Output += commitOutput
if err != nil {
@@ -426,7 +485,7 @@ func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool)
}
result.Success = true
g.logger.Info("commit completed", zap.String("message", message))
g.logger.Info("commit completed", zap.String("message", message), zap.Bool("amend", amend))
// Publish commit notification if we have a workspace tracker
if g.workspaceTracker != nil {
@@ -669,6 +728,126 @@ func (g *GitOperator) RevertCommit(ctx context.Context, commitSHA string) (*GitO
return result, nil
}
// RenameBranch renames the current branch to a new name.
// Uses git branch -m <new_name>.
func (g *GitOperator) RenameBranch(ctx context.Context, newName string) (*GitOperationResult, error) {
if !securityutil.IsValidBranchName(newName) {
return nil, ErrInvalidBranchName
}
if !g.tryLock("rename_branch") {
return nil, ErrOperationInProgress
}
defer g.unlock()
result := &GitOperationResult{
Operation: "rename_branch",
}
// Get current branch name for logging
currentBranch, err := g.getCurrentBranch(ctx)
if err != nil {
result.Error = fmt.Sprintf("failed to get current branch: %s", err.Error())
return result, nil
}
// Rename the branch
output, err := g.runGitCommand(ctx, "branch", "-m", newName)
result.Output = output
if err != nil {
result.Error = err.Error()
return result, nil
}
result.Success = true
g.logger.Info("branch renamed",
zap.String("from", currentBranch),
zap.String("to", newName))
// Refresh git status so the UI reflects the new branch name
if g.workspaceTracker != nil {
g.workspaceTracker.RefreshGitStatus(ctx)
}
return result, nil
}
// Reset resets HEAD to the specified commit.
// mode can be "soft" (keep changes staged), "mixed" (keep changes unstaged), or "hard" (discard all changes).
func (g *GitOperator) Reset(ctx context.Context, commitSHA string, mode string) (*GitOperationResult, error) {
if !g.tryLock("reset") {
return nil, ErrOperationInProgress
}
defer g.unlock()
result := &GitOperationResult{
Operation: "reset",
}
// Validate mode
validModes := map[string]bool{"soft": true, "mixed": true, "hard": true}
if !validModes[mode] {
result.Error = fmt.Sprintf("invalid reset mode: %s (must be soft, mixed, or hard)", mode)
return result, nil
}
// Validate commit SHA format
if errMsg := validateCommitSHA(commitSHA); errMsg != "" {
result.Error = errMsg
return result, nil
}
// Validate commit SHA exists (peel to commit object)
if _, err := g.runGitCommand(ctx, "rev-parse", "--verify", commitSHA+"^{commit}"); err != nil {
result.Error = fmt.Sprintf("invalid commit: %s", commitSHA)
return result, nil
}
// Capture current HEAD for reset notification
previousHead, err := g.runGitCommand(ctx, "rev-parse", "HEAD")
if err != nil {
result.Error = "failed to resolve HEAD: " + err.Error()
return result, nil
}
previousHead = strings.TrimSpace(previousHead)
// Perform the reset
output, err := g.runGitCommand(ctx, "reset", "--"+mode, commitSHA)
result.Output = output
if err != nil {
result.Error = err.Error()
return result, nil
}
result.Success = true
g.logger.Info("reset completed",
zap.String("mode", mode),
zap.String("commit", commitSHA))
// Send reset notification and refresh git status
if g.workspaceTracker != nil {
// Notify about the reset
newHead, headErr := g.runGitCommand(ctx, "rev-parse", "HEAD")
if headErr != nil {
g.logger.Warn("failed to resolve HEAD after reset", zap.Error(headErr))
} else {
reset := &streams.GitResetNotification{
Timestamp: time.Now().UTC(),
PreviousHead: previousHead,
CurrentHead: strings.TrimSpace(newHead),
}
g.workspaceTracker.NotifyGitReset(reset)
}
// Refresh git status
g.workspaceTracker.RefreshGitStatus(ctx)
}
return result, nil
}
func (g *GitOperator) discardUntrackedFiles(ctx context.Context, paths []string) (outputs, errors []string) {
for _, path := range paths {
resetArgs := []string{"rm", "--cached", "--force", "--", path}
@@ -68,20 +68,20 @@ func (wt *WorkspaceTracker) getFileList(ctx context.Context) (types.FileListUpda
// GetFileTree returns the file tree for a given path and depth
func (wt *WorkspaceTracker) GetFileTree(reqPath string, depth int) (*types.FileTreeNode, error) {
// Resolve the full path with path traversal protection
fullPath := filepath.Join(wt.workDir, filepath.Clean(reqPath))
safePath := filepath.Join(wt.workDir, filepath.Clean(reqPath))
cleanWorkDir := filepath.Clean(wt.workDir)
if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir {
if !strings.HasPrefix(safePath, cleanWorkDir+string(os.PathSeparator)) && safePath != cleanWorkDir {
return nil, fmt.Errorf("path traversal detected")
}
// Check if path exists
info, err := os.Stat(fullPath)
info, err := os.Stat(safePath)
if err != nil {
return nil, fmt.Errorf("path not found: %w", err)
}
// Build the tree
node, err := wt.buildFileTreeNode(fullPath, reqPath, info, depth, 0)
node, err := wt.buildFileTreeNode(safePath, reqPath, info, depth, 0)
if err != nil {
return nil, err
}
@@ -90,7 +90,7 @@ func (wt *WorkspaceTracker) GetFileTree(reqPath string, depth int) (*types.FileT
}
// buildFileTreeNode recursively builds a file tree node
func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.FileInfo, maxDepth, currentDepth int) (*types.FileTreeNode, error) {
func (wt *WorkspaceTracker) buildFileTreeNode(safePath, relPath string, info os.FileInfo, maxDepth, currentDepth int) (*types.FileTreeNode, error) {
node := &types.FileTreeNode{
Name: info.Name(),
Path: relPath,
@@ -104,7 +104,7 @@ func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.
}
// Read directory contents
entries, err := os.ReadDir(fullPath)
entries, err := os.ReadDir(safePath)
if err != nil {
return node, nil // Return node without children on error
}
@@ -118,7 +118,7 @@ func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.
continue
}
childFullPath := filepath.Join(fullPath, name)
childFullPath := filepath.Join(safePath, name)
childRelPath := filepath.Join(relPath, name)
childInfo, err := entry.Info()
@@ -137,36 +137,78 @@ func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.
return node, nil
}
// resolvedWorkDir returns the workspace directory with symlinks resolved.
func (wt *WorkspaceTracker) resolvedWorkDir() string {
resolved, err := filepath.EvalSymlinks(filepath.Clean(wt.workDir))
if err != nil {
return filepath.Clean(wt.workDir)
}
return resolved
}
// resolveSafePath resolves reqPath to an absolute path within workDir,
// rejecting any path traversal attempts.
// rejecting any path traversal attempts. The returned path is always
// constructed as filepath.Join(resolvedWorkDir, validatedRelPath) so that
// static analysis tools (CodeQL) can verify it stays within the workspace.
func (wt *WorkspaceTracker) resolveSafePath(reqPath string) (string, error) {
cleanWorkDir := filepath.Clean(wt.workDir)
cleanReqPath := filepath.Clean(reqPath)
var fullPath string
if filepath.IsAbs(cleanReqPath) && strings.HasPrefix(cleanReqPath, cleanWorkDir+string(os.PathSeparator)) {
fullPath = cleanReqPath
var safePath string
if filepath.IsAbs(cleanReqPath) {
safePath = cleanReqPath
} else {
fullPath = filepath.Join(wt.workDir, cleanReqPath)
safePath = filepath.Join(wt.workDir, cleanReqPath)
}
if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir {
return "", fmt.Errorf("path traversal detected")
// Resolve symlinks to prevent bypassing validation
realPath, err := filepath.EvalSymlinks(safePath)
if err != nil {
// If EvalSymlinks fails, the path might not exist yet (e.g., creating new file)
// In that case, resolve the parent directory
parentDir := filepath.Dir(safePath)
realParent, parentErr := filepath.EvalSymlinks(parentDir)
if parentErr != nil {
// Parent doesn't exist either, use the cleaned full path
realPath = safePath
} else {
realPath = filepath.Join(realParent, filepath.Base(safePath))
}
}
return fullPath, nil
// Resolve workspace directory symlinks for consistent comparison
realWorkDir, err := filepath.EvalSymlinks(cleanWorkDir)
if err != nil {
realWorkDir = cleanWorkDir
}
// Check that the real path is within the workspace
relPath, err := filepath.Rel(realWorkDir, realPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Ensure the relative path doesn't escape the workspace
if strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) || relPath == ".." {
return "", fmt.Errorf("path traversal detected: %s", reqPath)
}
// Reconstruct the absolute path from the trusted workspace root and the
// validated relative path. This ensures the returned path is provably
// inside the workspace, satisfying static-analysis taint checks.
return filepath.Join(realWorkDir, relPath), nil
}
// 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) {
fullPath, err := wt.resolveSafePath(reqPath)
safePath, err := wt.resolveSafePath(reqPath)
if err != nil {
return "", 0, false, err
}
// Check if file exists and is a regular file
info, err := os.Stat(fullPath)
info, err := os.Stat(safePath)
if err != nil {
return "", 0, false, fmt.Errorf("file not found: %w", err)
}
@@ -182,7 +224,7 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool,
}
// Read file content
file, err := os.Open(fullPath)
file, err := os.Open(safePath)
if err != nil {
return "", 0, false, fmt.Errorf("failed to open file: %w", err)
}
@@ -209,7 +251,7 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool,
// For symlinked files, resolves to the real path and rewrites the diff header
// so that git apply operates on the actual file.
func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, originalHash string) (string, error) {
fullPath, err := wt.resolveSafePath(reqPath)
safePath, err := wt.resolveSafePath(reqPath)
if err != nil {
return "", err
}
@@ -230,11 +272,22 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or
// If the file is a symlink, resolve to the real path and rewrite the diff header.
// git apply cannot patch through symlinks — it needs the real file path.
// Note: safePath is already resolved by resolveSafePath, so we check the
// unresolved path to detect whether the original request targets a symlink.
applyPath := reqPath
realPath, evalErr := filepath.EvalSymlinks(fullPath)
if evalErr == nil && realPath != fullPath {
// File is a symlink. Compute the real path relative to workDir.
realRel, relErr := filepath.Rel(cleanWorkDir, realPath)
cleanReqPath := filepath.Clean(reqPath)
// Validate path doesn't attempt traversal before constructing filesystem path
if strings.Contains(cleanReqPath, "..") || filepath.IsAbs(cleanReqPath) {
return "", fmt.Errorf("invalid path: %s", reqPath)
}
unresolvedPath := filepath.Join(wt.workDir, cleanReqPath)
if info, lErr := os.Lstat(unresolvedPath); lErr == nil && info.Mode()&os.ModeSymlink != 0 {
// File is a symlink. safePath already points to the real target.
realWorkDir, _ := filepath.EvalSymlinks(cleanWorkDir)
if realWorkDir == "" {
realWorkDir = cleanWorkDir
}
realRel, relErr := filepath.Rel(realWorkDir, safePath)
if relErr == nil {
unifiedDiff = rewriteDiffPaths(unifiedDiff, reqPath, realRel)
applyPath = realRel
@@ -271,7 +324,7 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or
newHash := calculateSHA256(string(updatedContent))
// Notify with the original relative path (not the resolved symlink target)
relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator))
relPath := strings.TrimPrefix(safePath, cleanWorkDir+string(os.PathSeparator))
wt.addPendingChange(relPath, types.FileOpWrite)
wt.logger.Debug("applied file diff using git apply",
@@ -312,19 +365,19 @@ func replaceDiffPath(line, prefix, oldPath, newPath string) string {
// CreateFile creates a new file in the workspace
func (wt *WorkspaceTracker) CreateFile(reqPath string) error {
fullPath, err := wt.resolveSafePath(reqPath)
safePath, err := wt.resolveSafePath(reqPath)
if err != nil {
return err
}
// Create intermediate directories
dir := filepath.Dir(fullPath)
dir := filepath.Dir(safePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directories: %w", err)
}
// Atomically create the file, failing if it already exists
f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
f, err := os.OpenFile(safePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
if os.IsExist(err) {
return fmt.Errorf("file already exists: %s", reqPath)
@@ -335,21 +388,29 @@ func (wt *WorkspaceTracker) CreateFile(reqPath string) error {
// Notify with the relative path
cleanWorkDir := filepath.Clean(wt.workDir)
relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator))
relPath := strings.TrimPrefix(safePath, cleanWorkDir+string(os.PathSeparator))
wt.addPendingChange(relPath, types.FileOpCreate)
return nil
}
// DeleteFile deletes a file from the workspace
// DeleteFile deletes a file or directory from the workspace.
func (wt *WorkspaceTracker) DeleteFile(reqPath string) error {
fullPath, err := wt.resolveSafePath(reqPath)
safePath, err := wt.resolveSafePath(reqPath)
if err != nil {
return err
}
cleanWorkDir := wt.resolvedWorkDir()
if safePath == cleanWorkDir {
return fmt.Errorf("cannot delete workspace root")
}
if err := wt.validateWorkspacePaths(safePath); err != nil {
return err
}
// Check if file exists
info, err := os.Stat(fullPath)
info, err := os.Stat(safePath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", reqPath)
@@ -357,24 +418,105 @@ func (wt *WorkspaceTracker) DeleteFile(reqPath string) error {
return fmt.Errorf("failed to stat file: %w", err)
}
// Don't allow deleting directories
if info.IsDir() {
return fmt.Errorf("cannot delete directory: %s", reqPath)
if err := os.RemoveAll(safePath); err != nil {
return fmt.Errorf("failed to delete directory: %w", err)
}
} else {
if err := os.Remove(safePath); err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
}
// Delete the file
if err := os.Remove(fullPath); err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
// Notify with the relative path
cleanWorkDir := filepath.Clean(wt.workDir)
relPath := strings.TrimPrefix(fullPath, cleanWorkDir+string(os.PathSeparator))
relPath := strings.TrimPrefix(safePath, cleanWorkDir+string(os.PathSeparator))
wt.addPendingChange(relPath, types.FileOpRemove)
return nil
}
// RenameFile renames/moves a file or directory in the workspace.
func (wt *WorkspaceTracker) RenameFile(oldPath, newPath string) error {
if oldPath == "" || newPath == "" {
return fmt.Errorf("old_path and new_path are required")
}
oldSafePath, err := wt.resolveSafePath(oldPath)
if err != nil {
return err
}
newSafePath, err := wt.resolveSafePath(newPath)
if err != nil {
return err
}
if err := wt.validateWorkspacePaths(oldSafePath, newSafePath); err != nil {
return err
}
if oldSafePath == newSafePath {
return nil
}
if err := validateSourceExists(oldSafePath, oldPath); err != nil {
return err
}
if err := validateTargetAvailable(newSafePath, newPath); err != nil {
return err
}
parentDir := filepath.Dir(newSafePath)
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return fmt.Errorf("failed to create target parent directories: %w", err)
}
if err := os.Rename(oldSafePath, newSafePath); err != nil {
return fmt.Errorf("failed to rename path: %w", err)
}
cleanWorkDir := wt.resolvedWorkDir()
oldRelPath := strings.TrimPrefix(oldSafePath, cleanWorkDir+string(os.PathSeparator))
newRelPath := strings.TrimPrefix(newSafePath, cleanWorkDir+string(os.PathSeparator))
wt.addPendingChange(oldRelPath, types.FileOpRename)
if newRelPath != oldRelPath {
wt.addPendingChange(newRelPath, types.FileOpRename)
}
return nil
}
// validateWorkspacePaths checks that all provided paths are strictly inside the workspace.
func (wt *WorkspaceTracker) validateWorkspacePaths(paths ...string) error {
cleanWorkDir := wt.resolvedWorkDir()
workDirPrefix := cleanWorkDir + string(os.PathSeparator)
for _, p := range paths {
if !strings.HasPrefix(p, workDirPrefix) {
return fmt.Errorf("path outside workspace")
}
}
return nil
}
func validateSourceExists(safePath, reqPath string) error {
_, err := os.Stat(safePath)
if err == nil {
return nil
}
if os.IsNotExist(err) {
return fmt.Errorf("path does not exist: %s", reqPath)
}
return fmt.Errorf("failed to stat path: %w", err)
}
func validateTargetAvailable(safePath, reqPath string) error {
_, err := os.Stat(safePath)
if err == nil {
return fmt.Errorf("target already exists: %s", reqPath)
}
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to stat target: %w", err)
}
// calculateSHA256 calculates the SHA256 hash of a string
func calculateSHA256(content string) string {
hash := sha256.Sum256([]byte(content))
@@ -89,6 +89,19 @@ func (wt *WorkspaceTracker) NotifyGitCommit(commit *types.GitCommitNotification)
wt.notifyWorkspaceStreamGitCommit(commit)
}
// NotifyGitReset notifies all subscribers about a git reset (HEAD moved backward).
// It also updates the cached HEAD SHA to the new position.
func (wt *WorkspaceTracker) NotifyGitReset(reset *types.GitResetNotification) {
// Update cached HEAD to the new position
if reset.CurrentHead != "" {
wt.gitStateMu.Lock()
wt.cachedHeadSHA = reset.CurrentHead
wt.gitStateMu.Unlock()
}
wt.notifyWorkspaceStreamGitReset(reset)
}
// notifyWorkspaceStreamGitReset sends git reset notification to all workspace stream subscribers
func (wt *WorkspaceTracker) notifyWorkspaceStreamGitReset(reset *types.GitResetNotification) {
wt.workspaceSubMu.RLock()
@@ -523,17 +523,56 @@ func TestDeleteFile(t *testing.T) {
}
})
t.Run("directory deletion rejected", func(t *testing.T) {
t.Run("directory deletion succeeds", func(t *testing.T) {
dir, wt := setupTestDir(t)
if err := os.Mkdir(filepath.Join(dir, "mydir"), 0o755); err != nil {
targetDir := filepath.Join(dir, "mydir")
if err := os.Mkdir(targetDir, 0o755); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
err := wt.DeleteFile("mydir")
if err == nil {
t.Fatal("expected error for directory deletion, got nil")
if err != nil {
t.Fatalf("expected directory deletion to succeed, got: %v", err)
}
if !strings.Contains(err.Error(), "cannot delete directory") {
t.Errorf("expected 'cannot delete directory' error, got: %v", err)
if _, err := os.Stat(targetDir); !os.IsNotExist(err) {
t.Fatalf("expected directory to be deleted, got err: %v", err)
}
})
}
func TestRenameFile(t *testing.T) {
t.Run("renames file", func(t *testing.T) {
dir, wt := setupTestDir(t)
oldPath := filepath.Join(dir, "from.txt")
newPath := filepath.Join(dir, "to.txt")
if err := os.WriteFile(oldPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("write failed: %v", err)
}
if err := wt.RenameFile("from.txt", "to.txt"); err != nil {
t.Fatalf("RenameFile failed: %v", err)
}
if _, err := os.Stat(newPath); err != nil {
t.Fatalf("expected new path to exist, got: %v", err)
}
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("expected old path to be gone, got: %v", err)
}
})
t.Run("renames directory", func(t *testing.T) {
dir, wt := setupTestDir(t)
oldDir := filepath.Join(dir, "dir-a")
newDir := filepath.Join(dir, "dir-b")
if err := os.Mkdir(oldDir, 0o755); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
if err := os.WriteFile(filepath.Join(oldDir, "file.txt"), []byte("hello"), 0o644); err != nil {
t.Fatalf("write failed: %v", err)
}
if err := wt.RenameFile("dir-a", "dir-b"); err != nil {
t.Fatalf("RenameFile failed: %v", err)
}
if _, err := os.Stat(filepath.Join(newDir, "file.txt")); err != nil {
t.Fatalf("expected file to be moved with directory, got: %v", err)
}
})
}
@@ -63,8 +63,9 @@ func (e *InferenceExecutor) Execute(ctx context.Context, req *PromptRequest) (*P
// not from user input. Only the model name (validated above) and prompt are user-provided.
args := e.buildCommand(cfg, req.Model)
// #nosec G204 -- Command comes from agent registry (hardcoded), model is validated above
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
// Command from hardcoded agent registry; model validated with regex `^[a-zA-Z0-9._:/-]*$`;
// prompt passed via stdin, not as command arg.
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // lgtm[go/command-injection] #nosec G204
cmd.Dir = e.workDir
var stdout, stderr bytes.Buffer
@@ -205,7 +205,7 @@ type FileCreateResponse struct {
Error string `json:"error,omitempty"`
}
// FileDeleteRequest represents a request to delete a file.
// FileDeleteRequest represents a request to delete a file or directory.
//
// HTTP endpoint: DELETE /api/v1/workspace/file
type FileDeleteRequest struct {
@@ -227,3 +227,29 @@ type FileDeleteResponse struct {
// Error contains error message if the request failed.
Error string `json:"error,omitempty"`
}
// FileRenameRequest represents a request to rename a file or directory.
//
// HTTP endpoint: POST /api/v1/workspace/file/rename
type FileRenameRequest struct {
// OldPath is the current path (relative to workspace root).
OldPath string `json:"old_path"`
// NewPath is the new path (relative to workspace root).
NewPath string `json:"new_path"`
}
// FileRenameResponse represents a response to a rename request.
type FileRenameResponse struct {
// OldPath is the original path.
OldPath string `json:"old_path"`
// NewPath is the new path.
NewPath string `json:"new_path"`
// Success indicates if the rename was successful.
Success bool `json:"success"`
// Error contains error message if the request failed.
Error string `json:"error,omitempty"`
}
@@ -0,0 +1,112 @@
package securityutil
import (
"fmt"
"regexp"
"strings"
)
// validBranchNameRegex matches safe git branch names.
// Allows alphanumeric, hyphens, underscores, slashes, and dots.
// Disallows: spaces, shell metacharacters, and control characters.
var validBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
// IsValidBranchName validates that a branch name is safe to use in git commands.
// Returns true if the branch name:
// - Is not empty and under 256 characters
// - Matches the alphanumeric pattern with safe special chars
// - Does not contain ".." (path traversal prevention)
// - Does not end with ".lock" (git internal file)
func IsValidBranchName(branch string) bool {
if branch == "" || len(branch) > 255 {
return false
}
// Disallow ".." to prevent path traversal
if strings.Contains(branch, "..") {
return false
}
// Disallow ending with ".lock"
if strings.HasSuffix(branch, ".lock") {
return false
}
return validBranchNameRegex.MatchString(branch)
}
// IsKnownSafeGitFlag returns true if the argument is a known safe git flag used by this codebase.
// This prevents argument injection where user input could introduce malicious flags.
// Only flags actually used by the Kandev codebase are whitelisted.
func IsKnownSafeGitFlag(arg string) bool {
// Whitelist of git flags actually used by our codebase
safeFlags := []string{
"-m", "-M", "--set-upstream", "--all", "--porcelain", "--short",
"--abbrev-ref", "--symbolic-full-name", "--verify", "--no-patch",
"--format", "--format=", "--stat", "--numstat", "-p", "-A",
"--amend", "--allow-empty", "--soft", "--mixed", "--hard",
"--cached", "--force", "--source=HEAD", "--staged", "--worktree",
"--", // Path separator - everything after this is treated as paths, not flags
}
for _, safe := range safeFlags {
if arg == safe || strings.HasPrefix(arg, safe) {
return true
}
}
return false
}
// IsKnownSafeGitLiteral returns true for git-specific literals that are safe to use in commands.
// This includes git refs, remotes, and special markers that don't need branch name validation.
func IsKnownSafeGitLiteral(arg string) bool {
// Common safe git literals
safeLiterals := []string{
"HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD",
"origin", "upstream", ".", "--",
}
for _, safe := range safeLiterals {
if arg == safe {
return true
}
}
// HEAD~N, HEAD^N, HEAD@{} patterns are safe
if strings.HasPrefix(arg, "HEAD~") || strings.HasPrefix(arg, "HEAD^") || strings.HasPrefix(arg, "HEAD@{") {
return true
}
return false
}
// LooksLikeCommitSHA returns true if the string looks like a git commit SHA (hex chars only).
// This allows commit SHAs to bypass branch name validation.
// Valid commit SHAs are 7-64 hexadecimal characters (short or full SHA).
func LooksLikeCommitSHA(arg string) bool {
if len(arg) < 7 || len(arg) > 64 {
return false
}
for _, c := range arg {
if !isHexChar(c) {
return false
}
}
return true
}
// ValidateBranchReference validates a branch reference like "origin/branch".
// Returns an error if the reference contains invalid branch or remote names.
func ValidateBranchReference(arg string) error {
parts := strings.SplitN(arg, "/", 2)
if len(parts) != 2 {
return nil // Not a reference format
}
remote, branch := parts[0], parts[1]
if !IsValidBranchName(remote) {
return fmt.Errorf("invalid remote name in reference '%s'", arg)
}
if !IsValidBranchName(branch) {
return fmt.Errorf("invalid branch name in reference '%s'", arg)
}
return nil
}
// isHexChar returns true if the rune is a hexadecimal character (0-9, a-f, A-F).
func isHexChar(r rune) bool {
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
}
@@ -0,0 +1,128 @@
// Package securityutil provides security-related utilities for command execution,
// input validation, and sanitization.
package securityutil
import (
"fmt"
"strings"
)
// ShellEscape escapes a string for safe use in shell commands.
// Returns the string wrapped in single quotes with internal single quotes escaped.
// If the string contains no special characters, it's returned as-is for readability.
func ShellEscape(s string) string {
if s == "" {
return "''"
}
// If no special characters, return as-is
if !strings.ContainsAny(s, " \t\n'\"\\$`!*?[](){};<>|&") {
return s
}
// Single-quote and escape internal single quotes
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
// SplitShellCommand splits a shell command string into arguments,
// respecting quoted strings and escape sequences.
// This is a simple shell-word parser that handles:
// - Single quotes (no escaping inside)
// - Double quotes (backslash escaping)
// - Backslash escaping outside quotes
// - Whitespace as argument separator
func SplitShellCommand(cmd string) ([]string, error) {
state := &parseState{
args: make([]string, 0),
current: &strings.Builder{},
}
for i, r := range cmd {
if state.escape {
state.current.WriteRune(r)
state.escape = false
continue
}
if !handleSpecialChar(r, state) {
state.current.WriteRune(r)
}
// Check for unclosed quotes at end
if i == len(cmd)-1 && (state.inSingleQuote || state.inDoubleQuote) {
return nil, fmt.Errorf("unclosed quote in command")
}
}
if state.current.Len() > 0 {
state.args = append(state.args, state.current.String())
}
return state.args, nil
}
// parseState tracks the current parsing state
type parseState struct {
inSingleQuote bool
inDoubleQuote bool
escape bool
args []string
current *strings.Builder
}
// handleSpecialChar processes special characters and returns true if handled
func handleSpecialChar(r rune, state *parseState) bool {
switch r {
case '\\':
return handleBackslash(state)
case '\'':
return handleSingleQuote(state)
case '"':
return handleDoubleQuote(state)
case ' ', '\t', '\n':
return handleWhitespace(state)
default:
return false
}
}
// handleBackslash processes backslash escape sequences
func handleBackslash(state *parseState) bool {
if state.inSingleQuote {
state.current.WriteRune('\\')
} else {
state.escape = true
}
return true
}
// handleSingleQuote processes single quote characters
func handleSingleQuote(state *parseState) bool {
if state.inDoubleQuote {
state.current.WriteRune('\'')
} else {
state.inSingleQuote = !state.inSingleQuote
}
return true
}
// handleDoubleQuote processes double quote characters
func handleDoubleQuote(state *parseState) bool {
if state.inSingleQuote {
state.current.WriteRune('"')
} else {
state.inDoubleQuote = !state.inDoubleQuote
}
return true
}
// handleWhitespace processes whitespace as argument separator
func handleWhitespace(state *parseState) bool {
if state.inSingleQuote || state.inDoubleQuote {
state.current.WriteRune(' ')
return true
}
if state.current.Len() > 0 {
state.args = append(state.args, state.current.String())
state.current.Reset()
}
return true
}
@@ -0,0 +1,120 @@
package securityutil
import (
"testing"
)
func TestShellEscape(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "empty string",
input: "",
expected: "''",
},
{
name: "simple path",
input: "/path/to/file.txt",
expected: "/path/to/file.txt",
},
{
name: "path with spaces",
input: "/path/to/my file.txt",
expected: "'/path/to/my file.txt'",
},
{
name: "path with single quote",
input: "/path/to/file's.txt",
expected: "'/path/to/file'\\''s.txt'",
},
{
name: "path with special chars",
input: "/path/to/file$name.txt",
expected: "'/path/to/file$name.txt'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ShellEscape(tt.input)
if result != tt.expected {
t.Errorf("ShellEscape(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestSplitShellCommand(t *testing.T) {
tests := []struct {
name string
input string
want []string
wantErr bool
}{
{
name: "simple command",
input: "echo hello",
want: []string{"echo", "hello"},
},
{
name: "command with single quotes",
input: "code '/path/to/my file.txt'",
want: []string{"code", "/path/to/my file.txt"},
},
{
name: "command with double quotes",
input: `code "/path/to/my file.txt"`,
want: []string{"code", "/path/to/my file.txt"},
},
{
name: "command with escaped single quote",
input: "code '/path/to/file'\\''s.txt'",
want: []string{"code", "/path/to/file's.txt"},
},
{
name: "complex command",
input: "code '/path with spaces/file.txt':10:5",
want: []string{"code", "/path with spaces/file.txt:10:5"},
},
{
name: "unclosed single quote",
input: "code '/path/to/file.txt",
wantErr: true,
},
{
name: "unclosed double quote",
input: `code "/path/to/file.txt`,
wantErr: true,
},
{
name: "escaped backslash",
input: `code "path\\file.txt"`,
want: []string{"code", `path\file.txt`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SplitShellCommand(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("SplitShellCommand() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if len(got) != len(tt.want) {
t.Errorf("SplitShellCommand() = %v, want %v", got, tt.want)
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("SplitShellCommand() arg[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
@@ -14,6 +14,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/common/securityutil"
"github.com/kandev/kandev/internal/editors/models"
"github.com/kandev/kandev/internal/editors/store"
taskmodels "github.com/kandev/kandev/internal/task/models"
@@ -503,7 +504,10 @@ func parseHostedURLConfig(data json.RawMessage) (hostedURLConfig, error) {
func launchCommand(commandTemplate, worktreePath, absPath string, line, column int) (string, error) {
expanded := expandCommandPlaceholders(commandTemplate, worktreePath, absPath, line, column)
parts := strings.Fields(expanded)
parts, err := securityutil.SplitShellCommand(expanded)
if err != nil {
return "", fmt.Errorf("invalid command template: %w", err)
}
if len(parts) == 0 {
return "", ErrEditorConfigInvalid
}
@@ -524,10 +528,11 @@ func expandCommandPlaceholders(template, worktreePath, absPath string, line, col
relPath = relative
}
}
// Shell-escape paths to handle spaces and special characters safely
replacements := map[string]string{
"{cwd}": worktreePath,
"{file}": absPath,
"{rel}": relPath,
"{cwd}": securityutil.ShellEscape(worktreePath),
"{file}": securityutil.ShellEscape(absPath),
"{rel}": securityutil.ShellEscape(relPath),
"{line}": strconv.Itoa(line),
"{column}": strconv.Itoa(column),
}
@@ -169,11 +169,26 @@ func extractTarEntry(tr *tar.Reader, header *tar.Header, destDir string) error {
case tar.TypeReg:
return writeFileFromTar(tr, target, os.FileMode(header.Mode))
case tar.TypeSymlink:
// Validate symlink target doesn't escape destDir
linkTarget := filepath.Join(filepath.Dir(target), header.Linkname)
if !strings.HasPrefix(filepath.Clean(linkTarget), filepath.Clean(destDir)) {
return fmt.Errorf("symlink %s -> %s escapes install directory", header.Name, header.Linkname)
// Validate symlink target to prevent path traversal attacks
// Reject absolute paths and paths containing ".."
if filepath.IsAbs(header.Linkname) {
return fmt.Errorf("symlink target must not be absolute: %s -> %s", header.Name, header.Linkname)
}
if strings.Contains(header.Linkname, "..") {
return fmt.Errorf("symlink target must not contain '..': %s -> %s", header.Name, header.Linkname)
}
// Resolve the symlink target path relative to the symlink's location
symlinkDir := filepath.Dir(target)
linkTarget := filepath.Join(symlinkDir, header.Linkname)
// Ensure the resolved symlink target is within destDir
cleanLinkTarget := filepath.Clean(linkTarget)
cleanDestDir := filepath.Clean(destDir)
if !strings.HasPrefix(cleanLinkTarget, cleanDestDir+string(os.PathSeparator)) && cleanLinkTarget != cleanDestDir {
return fmt.Errorf("symlink target escapes destination: %s -> %s", header.Name, header.Linkname)
}
// Remove existing symlink/file before creating (handles re-installs)
_ = os.Remove(target)
return os.Symlink(header.Linkname, target)
+3
View File
@@ -198,6 +198,7 @@ const (
ActionWorkspaceFileContentUpdate = "workspace.file.update"
ActionWorkspaceFileCreate = "workspace.file.create"
ActionWorkspaceFileDelete = "workspace.file.delete"
ActionWorkspaceFileRename = "workspace.file.rename"
ActionWorkspaceFilesSearch = "workspace.files.search"
ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification
@@ -242,6 +243,8 @@ const (
ActionWorktreeDiscard = "worktree.discard" // Discard changes to files
ActionWorktreeCreatePR = "worktree.create_pr" // Create a pull request
ActionWorktreeRevertCommit = "worktree.revert_commit" // Revert a commit (staged, no new commit)
ActionWorktreeRenameBranch = "worktree.rename_branch" // Rename the current branch
ActionWorktreeReset = "worktree.reset" // Reset HEAD to a commit (soft/hard)
// User actions
ActionUserGet = "user.get"
+4 -8
View File
@@ -1,3 +1,5 @@
import { djb2Hash } from "@/lib/utils/hash";
export type ReviewFile = {
path: string;
diff: string;
@@ -35,16 +37,10 @@ export function normalizeDiffContent(diffContent: string): string {
}
/**
* Simple djb2 hash of a string — fast, non-cryptographic.
* Hash diff content for change detection.
* Used to detect if a diff has changed since the user last reviewed it.
*/
export function hashDiff(content: string): string {
let hash = 5381;
for (let i = 0; i < content.length; i++) {
hash = ((hash << 5) + hash + content.charCodeAt(i)) | 0;
}
return hash.toString(36);
}
export const hashDiff = djb2Hash;
/**
* Build a hierarchical file tree from flat file paths.
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { IconGitCommit, IconGitPullRequest, IconLoader2, IconCheck } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -25,6 +26,7 @@ import { Checkbox } from "@kandev/ui/checkbox";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group";
// --- Discard Confirmation Dialog ---
@@ -77,6 +79,9 @@ type CommitDialogProps = {
stagedFileCount: number;
stagedAdditions: number;
stagedDeletions: number;
isAmend?: boolean;
onAmendChange?: (amend: boolean) => void;
lastCommitMessage?: string | null;
};
export function CommitDialog({
@@ -89,6 +94,9 @@ export function CommitDialog({
stagedFileCount,
stagedAdditions,
stagedDeletions,
isAmend = false,
onAmendChange,
lastCommitMessage,
}: CommitDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -96,7 +104,7 @@ export function CommitDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitCommit className="h-5 w-5" />
Commit Changes
{isAmend ? "Amend Commit" : "Commit Changes"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
@@ -105,8 +113,26 @@ export function CommitDialog({
stagedAdditions={stagedAdditions}
stagedDeletions={stagedDeletions}
/>
<Input
placeholder="Enter commit message..."
{onAmendChange && (
<div className="flex items-center space-x-2">
<Checkbox
id="amend-checkbox"
checked={isAmend}
onCheckedChange={(checked) => {
onAmendChange(checked === true);
// Pre-fill with last commit message when enabling amend
if (checked === true && lastCommitMessage && !commitMessage.trim()) {
onCommitMessageChange(lastCommitMessage);
}
}}
/>
<Label htmlFor="amend-checkbox" className="text-sm cursor-pointer">
Amend previous commit
</Label>
</div>
)}
<Textarea
placeholder={isAmend ? "Enter new commit message..." : "Enter commit message..."}
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
autoFocus
@@ -122,12 +148,12 @@ export function CommitDialog({
{isLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Committing...
{isAmend ? "Amending..." : "Committing..."}
</>
) : (
<>
<IconCheck className="h-4 w-4 mr-2" />
Commit
{isAmend ? "Amend" : "Commit"}
</>
)}
</Button>
@@ -294,3 +320,190 @@ function PRBranchInfo({
</div>
);
}
// --- Amend Commit Message Dialog ---
type AmendDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
amendMessage: string;
onAmendMessageChange: (value: string) => void;
onAmend: () => void;
isLoading: boolean;
};
export function AmendDialog({
open,
onOpenChange,
amendMessage,
onAmendMessageChange,
onAmend,
isLoading,
}: AmendDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitCommit className="h-5 w-5" />
Amend Commit Message
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">
Edit the message for the most recent commit.
</p>
<Textarea
placeholder="Enter new commit message..."
value={amendMessage}
onChange={(e) => onAmendMessageChange(e.target.value)}
rows={4}
className="resize-none"
autoFocus
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" className="cursor-pointer">
Cancel
</Button>
</DialogClose>
<Button
onClick={onAmend}
disabled={!amendMessage.trim() || isLoading}
className="cursor-pointer"
>
{isLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Amending...
</>
) : (
<>
<IconCheck className="h-4 w-4 mr-2" />
Amend
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// --- Reset to Commit Dialog ---
type ResetDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
commitSha: string | null;
onReset: (mode: "soft" | "hard") => void;
isLoading: boolean;
};
export function ResetDialog({
open,
onOpenChange,
commitSha,
onReset,
isLoading,
}: ResetDialogProps) {
const shortSha = commitSha?.slice(0, 7) ?? "";
const [mode, setMode] = useState<"soft" | "hard">("soft");
const [confirmation, setConfirmation] = useState("");
const isHardResetConfirmed = mode === "hard" && confirmation === shortSha;
const canReset = !!commitSha && (mode === "soft" || isHardResetConfirmed);
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Reset state when closing
setMode("soft");
setConfirmation("");
}
onOpenChange(newOpen);
};
const handleReset = () => {
if (canReset) {
onReset(mode);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>Reset to commit {shortSha}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<RadioGroup
value={mode}
onValueChange={(v) => setMode(v as "soft" | "hard")}
className="space-y-3"
>
<div className="flex items-start space-x-3">
<RadioGroupItem value="soft" id="reset-soft" className="mt-1" />
<div className="flex-1">
<Label htmlFor="reset-soft" className="font-medium cursor-pointer">
Soft Reset
</Label>
<p className="text-sm text-muted-foreground">
Move HEAD to this commit. Keep all changes staged.
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="hard" id="reset-hard" className="mt-1" />
<div className="flex-1">
<Label htmlFor="reset-hard" className="font-medium cursor-pointer text-destructive">
Hard Reset
</Label>
<p className="text-sm text-muted-foreground">
Discard all changes permanently. This cannot be undone.
</p>
</div>
</div>
</RadioGroup>
{mode === "hard" && (
<div className="space-y-2 p-3 border border-destructive/50 rounded-md bg-destructive/5">
<p className="text-xs text-destructive font-medium">
Type <code className="bg-muted px-1 rounded">{shortSha}</code> to confirm:
</p>
<Input
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
placeholder={shortSha}
className="font-mono h-8 text-sm"
autoComplete="off"
/>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isLoading}>
Cancel
</Button>
</DialogClose>
<Button
onClick={handleReset}
disabled={!canReset || isLoading}
variant={mode === "hard" ? "destructive" : "default"}
className="cursor-pointer"
>
{isLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Resetting...
</>
) : (
<>Reset</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -9,11 +9,12 @@ interface GitOps {
pull: (rebase?: boolean) => Promise<GitOperationResult>;
push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise<GitOperationResult>;
rebase: (baseBranch: string) => Promise<GitOperationResult>;
commit: (message: string, stageAll?: boolean) => Promise<GitOperationResult>;
commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise<GitOperationResult>;
stage: (paths?: string[]) => Promise<GitOperationResult>;
unstage: (paths?: string[]) => Promise<GitOperationResult>;
discard: (paths?: string[]) => Promise<GitOperationResult>;
revertCommit: (commitSHA: string) => Promise<GitOperationResult>;
reset: (commitSHA: string, mode: "soft" | "hard") => Promise<GitOperationResult>;
createPR: (
title: string,
body: string,
@@ -96,6 +97,7 @@ function useChangesDiscardCommitHandlers(
const [fileToDiscard, setFileToDiscard] = useState<string | null>(null);
const [commitDialogOpen, setCommitDialogOpen] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
const [isAmendCommit, setIsAmendCommit] = useState(false);
const handleDiscardClick = useCallback((filePath: string) => {
setFileToDiscard(filePath);
@@ -125,14 +127,36 @@ function useChangesDiscardCommitHandlers(
const handleOpenCommitDialog = useCallback(() => {
setCommitMessage("");
setIsAmendCommit(false);
setCommitDialogOpen(true);
}, []);
const handleCommit = useCallback(async () => {
if (!commitMessage.trim()) return;
setCommitDialogOpen(false);
await handleGitOperation(() => gitOps.commit(commitMessage.trim(), false), "Commit");
const operationName = isAmendCommit ? "Amend commit" : "Commit";
await handleGitOperation(
() => gitOps.commit(commitMessage.trim(), false, isAmendCommit),
operationName,
);
setCommitMessage("");
}, [commitMessage, handleGitOperation, gitOps]);
setIsAmendCommit(false);
}, [commitMessage, isAmendCommit, handleGitOperation, gitOps]);
// Amend dialog state (for editing last commit message directly)
const [amendDialogOpen, setAmendDialogOpen] = useState(false);
const [amendMessage, setAmendMessage] = useState("");
const handleOpenAmendDialog = useCallback((currentMessage: string) => {
setAmendMessage(currentMessage);
setAmendDialogOpen(true);
}, []);
const handleAmend = useCallback(async () => {
if (!amendMessage.trim()) return;
setAmendDialogOpen(false);
await handleGitOperation(() => gitOps.commit(amendMessage.trim(), false, true), "Amend commit");
setAmendMessage("");
}, [amendMessage, handleGitOperation, gitOps]);
return {
showDiscardDialog,
@@ -142,10 +166,48 @@ function useChangesDiscardCommitHandlers(
setCommitDialogOpen,
commitMessage,
setCommitMessage,
isAmendCommit,
setIsAmendCommit,
handleDiscardClick,
handleDiscardConfirm,
handleOpenCommitDialog,
handleCommit,
// Amend dialog
amendDialogOpen,
setAmendDialogOpen,
amendMessage,
setAmendMessage,
handleOpenAmendDialog,
handleAmend,
};
}
function useChangesResetHandlers(gitOps: GitOps, handleGitOperation: GitOperationFn) {
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const [resetCommitSha, setResetCommitSha] = useState<string | null>(null);
const handleOpenResetDialog = useCallback((sha: string) => {
setResetCommitSha(sha);
setResetDialogOpen(true);
}, []);
const handleReset = useCallback(
async (mode: "soft" | "hard") => {
if (!resetCommitSha) return;
setResetDialogOpen(false);
const operationName = mode === "hard" ? "Hard reset" : "Soft reset";
await handleGitOperation(() => gitOps.reset(resetCommitSha, mode), operationName);
setResetCommitSha(null);
},
[resetCommitSha, handleGitOperation, gitOps],
);
return {
resetDialogOpen,
setResetDialogOpen,
resetCommitSha,
handleOpenResetDialog,
handleReset,
};
}
@@ -217,6 +279,7 @@ export function useChangesDialogHandlers(
baseBranch: string | undefined,
) {
const discardCommit = useChangesDiscardCommitHandlers(gitOps, toast, handleGitOperation);
const reset = useChangesResetHandlers(gitOps, handleGitOperation);
const pr = useChangesPRHandlers(gitOps, toast, taskTitle, baseBranch);
return { ...discardCommit, ...pr };
return { ...discardCommit, ...reset, ...pr };
}
@@ -5,7 +5,6 @@ import {
IconGitPullRequest,
IconCloudUpload,
IconChevronDown,
IconArrowBackUp,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -21,6 +20,7 @@ import type { FileInfo } from "@/lib/state/store";
import { LineStat } from "@/components/diff-stat";
import { FileStatusIcon } from "./file-status-icon";
import { FileRow } from "./changes-panel-file-row";
import { CommitRow, type CommitItem } from "./commit-row";
// --- Timeline visual components ---
@@ -93,18 +93,13 @@ function TimelineSection({
// --- Commits section ---
type CommitItem = {
commit_sha: string;
commit_message: string;
insertions: number;
deletions: number;
};
type CommitsSectionProps = {
commits: CommitItem[];
isLast: boolean;
onOpenCommitDetail?: (sha: string) => void;
onRevertCommit?: (sha: string) => void;
onAmendCommit?: (currentMessage: string) => void;
onResetToCommit?: (sha: string) => void;
};
export function CommitsSection({
@@ -112,6 +107,8 @@ export function CommitsSection({
isLast,
onOpenCommitDetail,
onRevertCommit,
onAmendCommit,
onResetToCommit,
}: CommitsSectionProps) {
return (
<TimelineSection
@@ -122,39 +119,15 @@ export function CommitsSection({
>
<ul className="space-y-0.5">
{commits.map((commit, index) => (
<li
<CommitRow
key={`${commit.commit_sha}-${index}`}
className="group flex items-center gap-2 text-xs rounded-md px-1 py-1 -mx-1 hover:bg-muted/60 cursor-pointer"
onClick={() => onOpenCommitDetail?.(commit.commit_sha)}
>
<IconGitCommit className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<code className="font-mono text-muted-foreground text-[11px]">
{commit.commit_sha.slice(0, 7)}
</code>
<span className="flex-1 min-w-0 truncate text-foreground">{commit.commit_message}</span>
<span className="shrink-0 text-[11px] flex items-center gap-1">
<span className="text-emerald-500">+{commit.insertions}</span>{" "}
<span className="text-rose-500">-{commit.deletions}</span>
{index === 0 && onRevertCommit && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Revert commit"
className="ml-1 text-muted-foreground hover:text-foreground cursor-pointer opacity-0 group-hover:opacity-100 focus:opacity-100"
onClick={(e) => {
e.stopPropagation();
onRevertCommit(commit.commit_sha);
}}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Revert commit</TooltipContent>
</Tooltip>
)}
</span>
</li>
commit={commit}
isLatest={index === 0}
onOpenCommitDetail={onOpenCommitDetail}
onAmendCommit={onAmendCommit}
onRevertCommit={onRevertCommit}
onResetToCommit={onResetToCommit}
/>
))}
</ul>
</TimelineSection>
+33 -2
View File
@@ -9,7 +9,13 @@ import { hashDiff, normalizeDiffContent } from "@/components/review/types";
import type { FileInfo } from "@/lib/state/store";
import { useToast } from "@/components/toast-provider";
import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context";
import { DiscardDialog, CommitDialog, PRDialog } from "./changes-panel-dialogs";
import {
DiscardDialog,
CommitDialog,
PRDialog,
AmendDialog,
ResetDialog,
} from "./changes-panel-dialogs";
import { ChangesPanelHeader } from "./changes-panel-header";
import {
FileListSection,
@@ -233,6 +239,7 @@ function ChangesPanelDialogsSection({
stagedDeletions,
displayBranch,
baseBranch,
lastCommitMessage,
}: Pick<
ChangesPanelBodyProps,
| "dialogs"
@@ -242,7 +249,7 @@ function ChangesPanelDialogsSection({
| "stagedDeletions"
| "displayBranch"
| "baseBranch"
>) {
> & { lastCommitMessage?: string | null }) {
return (
<>
<DiscardDialog
@@ -261,6 +268,9 @@ function ChangesPanelDialogsSection({
stagedFileCount={stagedFileCount}
stagedAdditions={stagedAdditions}
stagedDeletions={stagedDeletions}
isAmend={dialogs.isAmendCommit}
onAmendChange={dialogs.setIsAmendCommit}
lastCommitMessage={lastCommitMessage}
/>
<PRDialog
open={dialogs.prDialogOpen}
@@ -276,6 +286,21 @@ function ChangesPanelDialogsSection({
displayBranch={displayBranch}
baseBranch={baseBranch}
/>
<AmendDialog
open={dialogs.amendDialogOpen}
onOpenChange={dialogs.setAmendDialogOpen}
amendMessage={dialogs.amendMessage}
onAmendMessageChange={dialogs.setAmendMessage}
onAmend={dialogs.handleAmend}
isLoading={isLoading}
/>
<ResetDialog
open={dialogs.resetDialogOpen}
onOpenChange={dialogs.setResetDialogOpen}
commitSha={dialogs.resetCommitSha}
onReset={dialogs.handleReset}
isLoading={isLoading}
/>
</>
);
}
@@ -353,6 +378,8 @@ function TimelineLocalChanges(props: TimelineProps) {
isLast={false}
onOpenCommitDetail={props.onOpenCommitDetail}
onRevertCommit={props.onRevertCommit}
onAmendCommit={props.dialogs.handleOpenAmendDialog}
onResetToCommit={props.dialogs.handleOpenResetDialog}
/>
)}
{showCommits && (
@@ -404,6 +431,9 @@ function ChangesPanelTimeline(props: TimelineProps) {
}
function ChangesPanelBody(props: ChangesPanelBodyProps) {
// Get last commit message for amend feature
const lastCommitMessage = props.commits?.[0]?.commit_message ?? null;
return (
<PanelBody className="flex flex-col">
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
@@ -422,6 +452,7 @@ function ChangesPanelBody(props: ChangesPanelBodyProps) {
stagedDeletions={props.stagedDeletions}
displayBranch={props.displayBranch}
baseBranch={props.baseBranch}
lastCommitMessage={lastCommitMessage}
/>
</PanelBody>
);
+202
View File
@@ -0,0 +1,202 @@
"use client";
import { IconGitCommit, IconArrowBackUp, IconPencil, IconHistoryToggle } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@kandev/ui/context-menu";
export type CommitItem = {
commit_sha: string;
commit_message: string;
insertions: number;
deletions: number;
};
/** Context menu for commit items */
function CommitContextMenu({
children,
commit,
isLatest,
onAmendCommit,
onRevertCommit,
onResetToCommit,
}: {
children: React.ReactNode;
commit: CommitItem;
isLatest: boolean;
onAmendCommit?: (currentMessage: string) => void;
onRevertCommit?: (sha: string) => void;
onResetToCommit?: (sha: string) => void;
}) {
const hasActions = onAmendCommit || onRevertCommit || onResetToCommit;
if (!hasActions) {
return <>{children}</>;
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{isLatest && onAmendCommit && (
<ContextMenuItem onSelect={() => onAmendCommit(commit.commit_message)}>
<IconPencil className="h-3.5 w-3.5" />
Amend message
</ContextMenuItem>
)}
{isLatest && onRevertCommit && (
<ContextMenuItem onSelect={() => onRevertCommit(commit.commit_sha)}>
<IconArrowBackUp className="h-3.5 w-3.5" />
Revert commit
</ContextMenuItem>
)}
{onResetToCommit && (
<ContextMenuItem onSelect={() => onResetToCommit(commit.commit_sha)}>
<IconHistoryToggle className="h-3.5 w-3.5" />
Reset to this commit
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
/** Hover action buttons for a commit row */
function CommitRowActions({
commit,
isLatest,
onAmendCommit,
onRevertCommit,
onResetToCommit,
}: {
commit: CommitItem;
isLatest: boolean;
onAmendCommit?: (currentMessage: string) => void;
onRevertCommit?: (sha: string) => void;
onResetToCommit?: (sha: string) => void;
}) {
return (
<span className="absolute right-2 top-0 bottom-0 hidden group-hover:flex items-center gap-1">
{isLatest && onAmendCommit && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Amend commit message"
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onAmendCommit(commit.commit_message);
}}
>
<IconPencil className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Amend commit message</TooltipContent>
</Tooltip>
)}
{isLatest && onRevertCommit && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Revert commit"
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onRevertCommit(commit.commit_sha);
}}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Revert commit</TooltipContent>
</Tooltip>
)}
{onResetToCommit && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Reset to this commit"
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onResetToCommit(commit.commit_sha);
}}
>
<IconHistoryToggle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Reset to this commit</TooltipContent>
</Tooltip>
)}
</span>
);
}
/** Individual commit row with hover actions */
export function CommitRow({
commit,
isLatest,
onOpenCommitDetail,
onAmendCommit,
onRevertCommit,
onResetToCommit,
}: {
commit: CommitItem;
isLatest: boolean;
onOpenCommitDetail?: (sha: string) => void;
onAmendCommit?: (currentMessage: string) => void;
onRevertCommit?: (sha: string) => void;
onResetToCommit?: (sha: string) => void;
}) {
const showActions = onResetToCommit || (isLatest && (onAmendCommit || onRevertCommit));
return (
<CommitContextMenu
commit={commit}
isLatest={isLatest}
onAmendCommit={onAmendCommit}
onRevertCommit={onRevertCommit}
onResetToCommit={onResetToCommit}
>
<li
role="button"
tabIndex={0}
className="group relative flex items-center gap-2 text-xs rounded-md px-1 py-1 -mx-1 hover:bg-muted/60 cursor-pointer"
onClick={() => onOpenCommitDetail?.(commit.commit_sha)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenCommitDetail?.(commit.commit_sha);
}
}}
>
<IconGitCommit className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<code className="font-mono text-muted-foreground text-[11px]">
{commit.commit_sha.slice(0, 7)}
</code>
<span className="flex-1 min-w-0 truncate text-foreground">{commit.commit_message}</span>
<span className="shrink-0 text-[11px] flex items-center gap-1 mr-1 group-hover:opacity-0">
<span className="text-emerald-500">+{commit.insertions}</span>{" "}
<span className="text-rose-500">-{commit.deletions}</span>
</span>
{showActions && (
<CommitRowActions
commit={commit}
isLatest={isLatest}
onAmendCommit={onAmendCommit}
onRevertCommit={onRevertCommit}
onResetToCommit={onResetToCommit}
/>
)}
</li>
</CommitContextMenu>
);
}
+72 -300
View File
@@ -6,64 +6,32 @@ import {
IconChevronDown,
IconFolder,
IconFolderOpen,
IconSearch,
IconTrash,
IconRefresh,
IconListTree,
IconFolderShare,
IconCopy,
IconCheck,
IconPlus,
} from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { FileIcon } from "@/components/ui/file-icon";
import type { FileTreeNode } from "@/lib/types/backend";
import type { FileInfo } from "@/lib/state/store";
import { InlineFileInput } from "./inline-file-input";
import { PanelHeaderBarSplit } from "./panel-primitives";
import { renderSessionOrLoadState } from "./file-browser-load-state";
import { compareTreeNodes } from "./file-tree-utils";
import {
FileContextMenu,
useFileRename,
TreeNodeName,
getGitStatusTextClass,
} from "./file-context-menu";
export {
compareTreeNodes,
mergeTreeNodes,
insertNodeInTree,
removeNodeFromTree,
renameNodeInTree,
} from "./file-tree-utils";
type GitFileStatus = FileInfo["status"] | undefined;
/** Sort comparator: directories first, then alphabetical by name. */
export const compareTreeNodes = (a: FileTreeNode, b: FileTreeNode): number => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
};
/**
* Merge a freshly-fetched tree node into an existing one, preserving
* already-loaded children so expanded folders don't collapse.
*/
export function mergeTreeNodes(existing: FileTreeNode, incoming: FileTreeNode): FileTreeNode {
if (!incoming.children) return { ...existing, ...incoming, children: existing.children };
if (!existing.children) return incoming;
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) {
return mergeTreeNodes(exChild, inChild);
}
return inChild;
});
return { ...existing, ...incoming, children: mergedChildren };
}
/** Insert a file node into a parent folder, keeping children sorted (dirs first, then alpha). */
export function insertNodeInTree(
root: FileTreeNode,
parentPath: string,
node: FileTreeNode,
): FileTreeNode {
if (root.path === parentPath || (parentPath === "" && root.path === "")) {
const children = [...(root.children ?? []), node].sort(compareTreeNodes);
return { ...root, children };
}
if (!root.children) return root;
return { ...root, children: root.children.map((c) => insertNodeInTree(c, parentPath, node)) };
}
type TreeNodeItemProps = {
node: FileTreeNode;
depth: number;
@@ -77,17 +45,12 @@ type TreeNodeItemProps = {
onToggleExpand: (node: FileTreeNode) => void;
onOpenFile: (path: string) => void;
onDeleteFile?: (path: string) => Promise<boolean>;
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
onCreateFileSubmit: (parentPath: string, name: string) => void;
onCancelCreate: () => void;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
};
export function removeNodeFromTree(root: FileTreeNode, targetPath: string): FileTreeNode {
if (!root.children) return root;
const filtered = root.children.filter((c) => c.path !== targetPath);
return { ...root, children: filtered.map((c) => removeNodeFromTree(c, targetPath)) };
}
function treeNodePaddingLeft(depth: number, isDir: boolean): string {
return `${depth * 12 + 8 + (isDir ? 0 : 20)}px`;
}
@@ -145,154 +108,72 @@ function TreeNodeFileIcon({
);
}
function getGitStatusTextClass(status: GitFileStatus): string {
switch (status) {
case "added":
case "untracked":
return "text-green-700 dark:text-green-600";
case "modified":
return "text-yellow-600";
default:
return "";
}
}
/** Delete button shown on hover for file nodes. */
function TreeNodeDeleteButton({
node,
tree,
setTree,
onDeleteFile,
}: {
node: FileTreeNode;
tree: FileTreeNode | null;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
onDeleteFile: (path: string) => Promise<boolean>;
}) {
/** Expanded directory children */
function TreeNodeChildren({ props, depth }: { props: TreeNodeItemProps; depth: number }) {
const { node, creatingInPath, onCreateFileSubmit, onCancelCreate } = props;
return (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-destructive cursor-pointer"
onClick={(e) => {
e.stopPropagation();
const snapshot = tree;
setTree((prev) => (prev ? removeNodeFromTree(prev, node.path) : prev));
onDeleteFile(node.path)
.then((ok) => {
if (!ok) setTree(snapshot);
})
.catch(() => {
setTree(snapshot);
});
}}
>
<IconTrash className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Delete file</TooltipContent>
</Tooltip>
<div>
{creatingInPath === node.path && (
<InlineFileInput
depth={depth + 1}
onSubmit={(name) => onCreateFileSubmit(node.path, name)}
onCancel={onCancelCreate}
/>
)}
{node.children?.map((child) => (
<TreeNodeItem key={child.path} {...props} node={child} depth={depth + 1} />
))}
</div>
);
}
export function TreeNodeItem({
node,
depth,
expandedPaths,
activeFolderPath,
activeFilePath,
visibleLoadingPaths,
creatingInPath,
fileStatuses,
tree,
onToggleExpand,
onOpenFile,
onDeleteFile,
onCreateFileSubmit,
onCancelCreate,
setTree,
}: TreeNodeItemProps) {
export function TreeNodeItem(props: TreeNodeItemProps) {
const { node, depth, expandedPaths, activeFolderPath, activeFilePath, visibleLoadingPaths } =
props;
const { fileStatuses, tree, onToggleExpand, onOpenFile, onDeleteFile, onRenameFile, setTree } =
props;
const isExpanded = expandedPaths.has(node.path);
const isLoading = visibleLoadingPaths.has(node.path);
const isActive = !node.is_dir && activeFilePath === node.path;
const isActiveFolder = node.is_dir && activeFolderPath === node.path;
const gitStatus = node.is_dir ? undefined : fileStatuses.get(node.path);
const rename = useFileRename(node, tree, setTree, onRenameFile);
const rowContent = (
<div
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",
isActiveFolder && "bg-muted/50",
)}
style={{ paddingLeft: treeNodePaddingLeft(depth, node.is_dir) }}
onClick={() => handleTreeNodeClick(node, onToggleExpand, onOpenFile)}
>
{node.is_dir && (
<span className="flex-shrink-0">
<TreeNodeExpandChevron isLoading={isLoading} isExpanded={isExpanded} />
</span>
)}
<TreeNodeFileIcon node={node} isExpanded={isExpanded} isActive={isActive} />
<TreeNodeName node={node} isActive={isActive} gitStatus={gitStatus} rename={rename} />
</div>
);
return (
<div>
<div
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",
isActiveFolder && "bg-muted/50",
)}
style={{ paddingLeft: treeNodePaddingLeft(depth, node.is_dir) }}
onClick={() => handleTreeNodeClick(node, onToggleExpand, onOpenFile)}
<FileContextMenu
node={node}
tree={tree}
setTree={setTree}
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
onStartRename={rename.handleStartRename}
>
{node.is_dir && (
<span className="flex-shrink-0">
<TreeNodeExpandChevron isLoading={isLoading} isExpanded={isExpanded} />
</span>
)}
<TreeNodeFileIcon node={node} isExpanded={isExpanded} isActive={isActive} />
<span
className={cn(
"flex-1 truncate group-hover:text-foreground",
isActive ? "text-foreground" : "text-muted-foreground",
node.is_dir ? "font-medium" : getGitStatusTextClass(gitStatus),
)}
>
{node.name}
</span>
{!node.is_dir && onDeleteFile && (
<div className="ml-auto flex items-center gap-1">
<TreeNodeDeleteButton
node={node}
tree={tree}
setTree={setTree}
onDeleteFile={onDeleteFile}
/>
</div>
)}
</div>
{node.is_dir && isExpanded && (
<div>
{creatingInPath === node.path && (
<InlineFileInput
depth={depth + 1}
onSubmit={(name) => onCreateFileSubmit(node.path, name)}
onCancel={onCancelCreate}
/>
)}
{node.children &&
[...node.children]
.sort(compareTreeNodes)
.map((child) => (
<TreeNodeItem
key={child.path}
node={child}
depth={depth + 1}
expandedPaths={expandedPaths}
activeFolderPath={activeFolderPath}
activeFilePath={activeFilePath}
visibleLoadingPaths={visibleLoadingPaths}
creatingInPath={creatingInPath}
fileStatuses={fileStatuses}
tree={tree}
onToggleExpand={onToggleExpand}
onOpenFile={onOpenFile}
onDeleteFile={onDeleteFile}
onCreateFileSubmit={onCreateFileSubmit}
onCancelCreate={onCancelCreate}
setTree={setTree}
/>
))}
</div>
)}
{rowContent}
</FileContextMenu>
{node.is_dir && isExpanded && <TreeNodeChildren props={props} depth={depth} />}
</div>
);
}
@@ -351,119 +232,7 @@ export function SearchResultsList({
);
}
type FileBrowserToolbarProps = {
displayPath: string;
fullPath: string;
copied: boolean;
expandedPathsSize: number;
onCopyPath: (text: string) => void;
onStartCreate?: () => void;
onOpenFolder: () => void;
onStartSearch: () => void;
onCollapseAll: () => void;
showCreateButton: boolean;
};
export function FileBrowserToolbar({
displayPath,
fullPath,
copied,
expandedPathsSize,
onCopyPath,
onStartCreate,
onOpenFolder,
onStartSearch,
onCollapseAll,
showCreateButton,
}: FileBrowserToolbarProps) {
return (
<PanelHeaderBarSplit
className="group/header"
left={
<>
<Tooltip>
<TooltipTrigger asChild>
<button
className="relative shrink-0 cursor-pointer"
onClick={() => {
if (fullPath) void onCopyPath(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 truncate text-xs font-medium text-muted-foreground">
{displayPath}
</span>
</>
}
right={
<>
{showCreateButton && onStartCreate && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={onStartCreate}
>
<IconPlus className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>New file</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={onOpenFolder}
>
<IconFolderShare className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Open workspace folder</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={onStartSearch}
>
<IconSearch className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Search files</TooltipContent>
</Tooltip>
{expandedPathsSize > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={onCollapseAll}
>
<IconListTree className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Collapse all</TooltipContent>
</Tooltip>
)}
</>
}
/>
);
}
export { FileBrowserToolbar } from "./file-browser-toolbar";
type FileBrowserContentAreaProps = {
isSearchActive: boolean;
@@ -483,6 +252,7 @@ type FileBrowserContentAreaProps = {
onOpenFile: (path: string) => void;
onToggleExpand: (node: FileTreeNode) => void;
onDeleteFile?: (path: string) => Promise<boolean>;
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
onCreateFileSubmit: (parentPath: string, name: string) => void;
onCancelCreate: () => void;
onRetry: () => void;
@@ -507,6 +277,7 @@ export function FileBrowserContentArea({
onOpenFile,
onToggleExpand,
onDeleteFile,
onRenameFile,
onCreateFileSubmit,
onCancelCreate,
onRetry,
@@ -559,6 +330,7 @@ export function FileBrowserContentArea({
onToggleExpand={onToggleExpand}
onOpenFile={onOpenFile}
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
onCreateFileSubmit={onCreateFileSubmit}
onCancelCreate={onCancelCreate}
setTree={setTree}
@@ -0,0 +1,131 @@
"use client";
import type { ReactNode } from "react";
import {
IconSearch,
IconListTree,
IconFolderShare,
IconFolderOpen,
IconCopy,
IconCheck,
IconPlus,
} from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { PanelHeaderBarSplit } from "./panel-primitives";
function ToolbarButton({
onClick,
label,
icon,
}: {
onClick: () => void;
label: string;
icon: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
aria-label={label}
onClick={onClick}
>
{icon}
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
type FileBrowserToolbarProps = {
displayPath: string;
fullPath: string;
copied: boolean;
expandedPathsSize: number;
onCopyPath: (text: string) => void;
onStartCreate?: () => void;
onOpenFolder: () => void;
onStartSearch: () => void;
onCollapseAll: () => void;
showCreateButton: boolean;
};
export function FileBrowserToolbar({
displayPath,
fullPath,
copied,
expandedPathsSize,
onCopyPath,
onStartCreate,
onOpenFolder,
onStartSearch,
onCollapseAll,
showCreateButton,
}: FileBrowserToolbarProps) {
return (
<PanelHeaderBarSplit
className="group/header"
left={
<>
<Tooltip>
<TooltipTrigger asChild>
<button
className="relative shrink-0 cursor-pointer"
aria-label="Copy workspace path"
onClick={() => {
if (fullPath) void onCopyPath(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 truncate text-xs font-medium text-muted-foreground">
{displayPath}
</span>
</>
}
right={
<>
{showCreateButton && onStartCreate && (
<ToolbarButton
onClick={onStartCreate}
label="New file"
icon={<IconPlus className="h-3.5 w-3.5" />}
/>
)}
<ToolbarButton
onClick={onOpenFolder}
label="Open workspace folder"
icon={<IconFolderShare className="h-3.5 w-3.5" />}
/>
<ToolbarButton
onClick={onStartSearch}
label="Search files"
icon={<IconSearch className="h-3.5 w-3.5" />}
/>
{expandedPathsSize > 0 && (
<ToolbarButton
onClick={onCollapseAll}
label="Collapse all"
icon={<IconListTree className="h-3.5 w-3.5" />}
/>
)}
</>
}
/>
);
}
@@ -86,6 +86,7 @@ type FileBrowserProps = {
onOpenFile: (file: OpenFileTab) => void;
onCreateFile?: (path: string) => Promise<boolean>;
onDeleteFile?: (path: string) => Promise<boolean>;
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
activeFilePath?: string | null;
};
@@ -163,6 +164,7 @@ export function FileBrowser({
onOpenFile,
onCreateFile,
onDeleteFile,
onRenameFile,
activeFilePath,
}: FileBrowserProps) {
const { session, isFailed: isSessionFailed, errorMessage: sessionError } = useSession(sessionId);
@@ -247,6 +249,7 @@ export function FileBrowser({
onOpenFile={openFileByPath}
onToggleExpand={toggleExpand}
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
onCreateFileSubmit={handleCreateFileSubmit}
onCancelCreate={handleCancelCreate}
onRetry={() => void treeState.loadTree({ resetRetry: true })}
@@ -0,0 +1,298 @@
"use client";
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Input } from "@kandev/ui/input";
import { IconPencil, IconTrash } from "@tabler/icons-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
ContextMenuSeparator,
} from "@kandev/ui/context-menu";
import { cn } from "@/lib/utils";
import { useToast } from "@/components/toast-provider";
import type { FileTreeNode } from "@/lib/types/backend";
import type { FileInfo } from "@/lib/state/store";
import {
removeNodeFromTree,
renameNodeInTree,
treeContainsPath,
countFilesInTree,
} from "./file-tree-utils";
type GitFileStatus = FileInfo["status"] | undefined;
/** Context menu for file nodes with Rename and Delete options */
export function FileContextMenu({
children,
node,
tree,
setTree,
onDeleteFile,
onRenameFile,
onStartRename,
}: {
children: React.ReactNode;
node: FileTreeNode;
tree: FileTreeNode | null;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
onDeleteFile?: (path: string) => Promise<boolean>;
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
onStartRename: () => void;
}) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const isDirectory = node.is_dir;
const fileCount = countFilesInTree(node);
const handleConfirmDelete = useCallback(() => {
setDeleteDialogOpen(false);
if (!onDeleteFile) return;
const snapshot = tree;
setTree((prev) => (prev ? removeNodeFromTree(prev, node.path) : prev));
onDeleteFile(node.path)
.then((ok) => {
if (!ok) setTree(snapshot);
})
.catch(() => {
setTree(snapshot);
});
}, [tree, setTree, node.path, onDeleteFile]);
const handleDelete = useCallback(() => {
if (!onDeleteFile) return;
if (isDirectory) {
setDeleteDialogOpen(true);
return;
}
const snapshot = tree;
setTree((prev) => (prev ? removeNodeFromTree(prev, node.path) : prev));
onDeleteFile(node.path)
.then((ok) => {
if (!ok) setTree(snapshot);
})
.catch(() => {
setTree(snapshot);
});
}, [tree, setTree, node.path, onDeleteFile, isDirectory]);
const hasActions = onDeleteFile || onRenameFile;
if (!hasActions) {
return <>{children}</>;
}
return (
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{onRenameFile && (
<ContextMenuItem onSelect={onStartRename}>
<IconPencil className="h-3.5 w-3.5" />
Rename
</ContextMenuItem>
)}
{onRenameFile && onDeleteFile && <ContextMenuSeparator />}
{onDeleteFile && (
<ContextMenuItem variant="destructive" onSelect={handleDelete}>
<IconTrash className="h-3.5 w-3.5" />
Delete
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
{isDirectory && (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete folder?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <span className="font-semibold">{node.name}</span> and{" "}
<span className="font-semibold">{fileCount}</span>{" "}
{fileCount === 1 ? "file" : "files"} inside it. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
variant="destructive"
className="cursor-pointer"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
)}
</AlertDialog>
);
}
/** Hook for managing inline file rename state */
export function useFileRename(
node: FileTreeNode,
tree: FileTreeNode | null,
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>,
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>,
) {
const { toast } = useToast();
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(node.name);
const handleStartRename = useCallback(() => {
setRenameValue(node.name);
setIsRenaming(true);
}, [node.name]);
const handleCancelRename = useCallback(() => {
setIsRenaming(false);
setRenameValue(node.name);
}, [node.name]);
const handleConfirmRename = useCallback(() => {
const newName = renameValue.trim();
if (!newName || newName === node.name || !onRenameFile) {
handleCancelRename();
return;
}
if (newName.includes("/") || newName.includes("\\")) {
toast({
title: "Invalid name",
description: "File names cannot contain path separators",
variant: "error",
});
handleCancelRename();
return;
}
const parentPath = node.path.includes("/")
? node.path.substring(0, node.path.lastIndexOf("/"))
: "";
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
const snapshot = tree;
setIsRenaming(false);
if (tree && treeContainsPath(tree, newPath)) {
toast({
title: "Failed to rename item",
description: `Target already exists: ${newPath}`,
variant: "error",
});
handleCancelRename();
return;
}
setTree((prev) => (prev ? renameNodeInTree(prev, node.path, newPath) : prev));
onRenameFile(node.path, newPath)
.then((ok) => {
if (!ok) setTree(snapshot);
})
.catch(() => {
setTree(snapshot);
});
}, [renameValue, node.name, node.path, onRenameFile, tree, setTree, handleCancelRename, toast]);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleConfirmRename();
} else if (e.key === "Escape") {
handleCancelRename();
}
},
[handleConfirmRename, handleCancelRename],
);
return {
isRenaming,
renameValue,
setRenameValue,
handleStartRename,
handleConfirmRename,
handleRenameKeyDown,
};
}
/** Inline rename input or static file name */
export function TreeNodeName({
node,
isActive,
gitStatus,
rename,
}: {
node: FileTreeNode;
isActive: boolean;
gitStatus: GitFileStatus;
rename: ReturnType<typeof useFileRename>;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const blurEnabledRef = useRef(false);
useEffect(() => {
if (rename.isRenaming) {
blurEnabledRef.current = false;
const focusTimer = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 150);
const blurTimer = setTimeout(() => {
blurEnabledRef.current = true;
}, 400);
return () => {
clearTimeout(focusTimer);
clearTimeout(blurTimer);
};
}
}, [rename.isRenaming]);
const handleBlur = useCallback(() => {
if (blurEnabledRef.current) {
rename.handleConfirmRename();
}
}, [rename]);
if (rename.isRenaming) {
return (
<Input
ref={inputRef}
value={rename.renameValue}
onChange={(e) => rename.setRenameValue(e.target.value)}
onKeyDown={rename.handleRenameKeyDown}
onBlur={handleBlur}
onClick={(e) => e.stopPropagation()}
className="h-5 text-xs px-1 py-0 flex-1 min-w-0"
/>
);
}
return (
<span
className={cn(
"flex-1 truncate group-hover:text-foreground",
isActive ? "text-foreground" : "text-muted-foreground",
node.is_dir ? "font-medium" : getGitStatusTextClass(gitStatus),
)}
>
{node.name}
</span>
);
}
export function getGitStatusTextClass(status: GitFileStatus): string {
switch (status) {
case "added":
case "untracked":
return "text-green-700 dark:text-green-600";
case "modified":
return "text-yellow-600";
default:
return "";
}
}
@@ -0,0 +1,89 @@
import type { FileTreeNode } from "@/lib/types/backend";
/** Sort comparator: directories first, then alphabetical by name. */
export const compareTreeNodes = (a: FileTreeNode, b: FileTreeNode): number => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
};
/**
* Merge a freshly-fetched tree node into an existing one, preserving
* already-loaded children so expanded folders don't collapse.
*/
export function mergeTreeNodes(existing: FileTreeNode, incoming: FileTreeNode): FileTreeNode {
if (!incoming.children) return { ...existing, ...incoming, children: existing.children };
if (!existing.children) return incoming;
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) {
return mergeTreeNodes(exChild, inChild);
}
return inChild;
});
return { ...existing, ...incoming, children: mergedChildren };
}
/** Insert a file node into a parent folder, keeping children sorted (dirs first, then alpha). */
export function insertNodeInTree(
root: FileTreeNode,
parentPath: string,
node: FileTreeNode,
): FileTreeNode {
if (root.path === parentPath || (parentPath === "" && root.path === "")) {
const children = [...(root.children ?? []), node].sort(compareTreeNodes);
return { ...root, children };
}
if (!root.children) return root;
return { ...root, children: root.children.map((c) => insertNodeInTree(c, parentPath, node)) };
}
export function removeNodeFromTree(root: FileTreeNode, targetPath: string): FileTreeNode {
if (!root.children) return root;
const filtered = root.children.filter((c) => c.path !== targetPath);
return { ...root, children: filtered.map((c) => removeNodeFromTree(c, targetPath)) };
}
/** Rename a node in the tree, updating its name and path. */
function replacePathPrefix(path: string, oldPrefix: string, newPrefix: string): string {
if (path === oldPrefix) return newPrefix;
if (path.startsWith(`${oldPrefix}/`)) return `${newPrefix}${path.slice(oldPrefix.length)}`;
return path;
}
function renameSubtree(node: FileTreeNode, oldPath: string, newPath: string): FileTreeNode {
const nextPath = replacePathPrefix(node.path, oldPath, newPath);
const nextName = nextPath.split("/").pop() || nextPath;
const nextChildren = node.children?.map((child) => renameSubtree(child, oldPath, newPath));
return {
...node,
name: nextName,
path: nextPath,
children: nextChildren,
};
}
export function treeContainsPath(root: FileTreeNode, targetPath: string): boolean {
if (root.path === targetPath) return true;
if (!root.children) return false;
return root.children.some((child) => treeContainsPath(child, targetPath));
}
export function countFilesInTree(node: FileTreeNode): number {
if (!node.children || node.children.length === 0) return node.is_dir ? 0 : 1;
const base = node.is_dir ? 0 : 1;
return node.children.reduce((sum, child) => sum + countFilesInTree(child), base);
}
export function renameNodeInTree(
root: FileTreeNode,
oldPath: string,
newPath: string,
): FileTreeNode {
if (root.path === oldPath) {
return renameSubtree(root, oldPath, newPath);
}
if (!root.children) return root;
const nextChildren = root.children.map((c) => renameNodeInTree(c, oldPath, newPath));
return { ...root, children: nextChildren.sort(compareTreeNodes) };
}
+2 -1
View File
@@ -17,7 +17,7 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) {
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const activeFilePath = useDockviewStore((s) => s.activeFilePath);
const isArchived = useIsTaskArchived();
const { createFile, deleteFile } = useFileOperations(activeSessionId ?? null);
const { createFile, deleteFile, renameFile } = useFileOperations(activeSessionId ?? null);
const handleCreateFile = useCallback(
async (path: string): Promise<boolean> => {
@@ -51,6 +51,7 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) {
onOpenFile={onOpenFile}
onCreateFile={handleCreateFile}
onDeleteFile={deleteFile}
onRenameFile={renameFile}
activeFilePath={activeFilePath}
/>
) : (
@@ -275,9 +275,11 @@ export function useFilesPanelData(onOpenFile: (file: OpenFileTab) => void) {
const gitStatus = useSessionGitStatus(activeSessionId);
const { commits } = useSessionCommits(activeSessionId ?? null);
const openEditor = useOpenSessionInEditor(activeSessionId ?? null);
const { createFile: baseCreateFile, deleteFile: hookDeleteFile } = useFileOperations(
activeSessionId ?? null,
);
const {
createFile: baseCreateFile,
deleteFile: hookDeleteFile,
renameFile: hookRenameFile,
} = useFileOperations(activeSessionId ?? null);
const { reviews } = useSessionFileReviews(activeSessionId);
const { diff: cumulativeDiff } = useCumulativeDiff(activeSessionId);
const { openFile: panelOpenFile } = usePanelActions();
@@ -347,6 +349,7 @@ export function useFilesPanelData(onOpenFile: (file: OpenFileTab) => void) {
reviewedCount,
totalFileCount,
hookDeleteFile,
hookRenameFile,
handleCreateFile,
handleOpenFileInDocumentPanel,
handleOpenInEditor,
+54 -21
View File
@@ -1,6 +1,6 @@
"use client";
import { memo } from "react";
import { memo, useMemo } from "react";
import { TabsContent } from "@kandev/ui/tabs";
import { SessionPanel, SessionPanelContent } from "@kandev/ui/pannel-session";
import { FileBrowser } from "@/components/task/file-browser";
@@ -15,6 +15,40 @@ import {
} from "./task-files-panel-hooks";
import { DiffTabContent, DiscardDialog } from "./task-files-panel-parts";
function FilesTabContent({
sessionId,
onOpenFile,
handleCreateFile,
hookDeleteFile,
hookRenameFile,
activeFilePath,
}: {
sessionId: string | null;
onOpenFile: (file: OpenFileTab) => void;
handleCreateFile: (path: string) => Promise<boolean>;
hookDeleteFile: (path: string) => Promise<boolean>;
hookRenameFile: (oldPath: string, newPath: string) => Promise<boolean>;
activeFilePath?: string | null;
}) {
if (!sessionId) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
No task selected
</div>
);
}
return (
<FileBrowser
sessionId={sessionId}
onOpenFile={onOpenFile}
onCreateFile={handleCreateFile}
onDeleteFile={hookDeleteFile}
onRenameFile={hookRenameFile}
activeFilePath={activeFilePath}
/>
);
}
type TaskFilesPanelProps = {
onSelectDiff: (path: string, content?: string) => void;
onOpenFile: (file: OpenFileTab) => void;
@@ -33,6 +67,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({
reviewedCount,
totalFileCount,
hookDeleteFile,
hookRenameFile,
handleCreateFile,
handleOpenFileInDocumentPanel,
handleOpenInEditor,
@@ -57,13 +92,16 @@ const TaskFilesPanel = memo(function TaskFilesPanel({
handleDiscardConfirm,
} = useDiscardDialog(activeSessionId);
const tabs: SessionTab[] = [
{
id: "diff",
label: `Diff files${changedFiles.length > 0 ? ` (${changedFiles.length})` : ""}`,
},
{ id: "files", label: "All files" },
];
const tabs: SessionTab[] = useMemo(
() => [
{
id: "diff",
label: `Diff files${changedFiles.length > 0 ? ` (${changedFiles.length})` : ""}`,
},
{ id: "files", label: "All files" },
],
[changedFiles.length],
);
return (
<SessionPanel borderSide="left">
@@ -97,19 +135,14 @@ const TaskFilesPanel = memo(function TaskFilesPanel({
</TabsContent>
<TabsContent value="files" className="flex-1 min-h-0">
<SessionPanelContent>
{activeSessionId ? (
<FileBrowser
sessionId={activeSessionId}
onOpenFile={onOpenFile}
onCreateFile={handleCreateFile}
onDeleteFile={hookDeleteFile}
activeFilePath={activeFilePath}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
No task selected
</div>
)}
<FilesTabContent
sessionId={activeSessionId}
onOpenFile={onOpenFile}
handleCreateFile={handleCreateFile}
hookDeleteFile={hookDeleteFile}
hookRenameFile={hookRenameFile}
activeFilePath={activeFilePath}
/>
</SessionPanelContent>
</TabsContent>
</SessionTabs>
+171 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { memo, useState } from "react";
import { memo, useState, useRef, useEffect, useCallback } from "react";
import Link from "next/link";
import {
IconBug,
@@ -9,8 +9,10 @@ import {
IconCheck,
IconHome,
IconSettings,
IconPencil,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import {
Breadcrumb,
BreadcrumbItem,
@@ -32,6 +34,7 @@ import { PRTopbarButton } from "@/components/github/pr-topbar-button";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { DEBUG_UI } from "@/lib/config";
import { toast } from "sonner";
type TaskTopBarProps = {
taskId?: string | null;
@@ -85,7 +88,23 @@ const TaskTopBar = memo(function TaskTopBar({
remoteStatusError,
}: TaskTopBarProps) {
const git = useSessionGit(activeSessionId);
const displayBranch = worktreeBranch || baseBranch;
// Prefer live git status branch (updates after rename), fallback to session worktree branch
const displayBranch = git.branch || worktreeBranch || baseBranch;
// Callback for renaming branch
const handleRenameBranch = useCallback(
async (newName: string) => {
const result = await git.renameBranch(newName);
if (result.success) {
toast.success(`Branch renamed to "${newName}"`);
} else {
const msg = result.error || "Failed to rename branch";
toast.error(msg);
throw new Error(msg);
}
},
[git],
);
return (
<header className="@container/topbar grid grid-cols-[1fr_auto_1fr] items-center px-3 py-1 border-b border-border">
@@ -104,6 +123,7 @@ const TaskTopBar = memo(function TaskTopBar({
remoteCreatedAt={remoteCreatedAt}
remoteCheckedAt={remoteCheckedAt}
remoteStatusError={remoteStatusError}
onRenameBranch={activeSessionId ? handleRenameBranch : undefined}
/>
{workflowSteps && workflowSteps.length > 0 && (
<WorkflowStepper
@@ -181,19 +201,151 @@ function PathRow({ label, path }: { label: string; path: string }) {
);
}
/** Inline branch rename input */
function BranchRenameInput({
editValue,
onEditValueChange,
onConfirm,
onCancel,
isRenaming,
}: {
editValue: string;
onEditValueChange: (v: string) => void;
onConfirm: () => void;
onCancel: () => void;
isRenaming: boolean;
}) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm();
} else if (e.key === "Escape") {
onCancel();
}
},
[onConfirm, onCancel],
);
return (
<div className="flex items-center gap-1.5 rounded-md px-1 h-7 bg-muted/40 min-w-0 max-w-full">
<IconGitBranch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Input
ref={inputRef}
value={editValue}
onChange={(e) => onEditValueChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={onConfirm}
disabled={isRenaming}
className="h-5 text-xs px-1 py-0 w-32 bg-background border-primary/50"
/>
</div>
);
}
/** Hook for branch rename editing state */
function useBranchRenameEdit(
displayBranch: string | undefined,
onRenameBranch: ((newName: string) => Promise<void>) | undefined,
) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(displayBranch ?? "");
const [isRenaming, setIsRenaming] = useState(false);
useEffect(() => {
if (!isEditing) setEditValue(displayBranch ?? "");
}, [displayBranch, isEditing]);
const handleStartEdit = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsEditing(true);
}, []);
const handleCancelEdit = useCallback(() => {
setIsEditing(false);
setEditValue(displayBranch ?? "");
}, [displayBranch]);
const handleConfirmRename = useCallback(async () => {
const trimmed = editValue.trim();
if (!onRenameBranch || !trimmed || trimmed === displayBranch?.trim() || isRenaming) {
handleCancelEdit();
return;
}
setIsRenaming(true);
try {
await onRenameBranch(trimmed);
setIsEditing(false);
} catch {
// Error is handled by onRenameBranch (shows toast), keep edit mode open
} finally {
setIsRenaming(false);
}
}, [onRenameBranch, editValue, displayBranch, handleCancelEdit, isRenaming]);
return {
isEditing,
editValue,
setEditValue,
isRenaming,
handleStartEdit,
handleCancelEdit,
handleConfirmRename,
};
}
function BranchPathPopover({
displayBranch,
repositoryPath,
worktreePath,
onRenameBranch,
}: {
displayBranch?: string;
repositoryPath?: string | null;
worktreePath?: string | null;
onRenameBranch?: (newName: string) => Promise<void>;
}) {
const [copiedBranch, handleCopyBranch] = useCopyToClipboard();
const [popoverOpen, setPopoverOpen] = useState(false);
const {
isEditing,
editValue,
setEditValue,
isRenaming,
handleStartEdit: startEdit,
handleCancelEdit,
handleConfirmRename,
} = useBranchRenameEdit(displayBranch, onRenameBranch);
const handleStartEdit = useCallback(
(e: React.MouseEvent) => {
startEdit(e);
setPopoverOpen(false);
},
[startEdit],
);
if (!displayBranch) return null;
if (isEditing) {
return (
<BranchRenameInput
editValue={editValue}
onEditValueChange={setEditValue}
onConfirm={handleConfirmRename}
onCancel={handleCancelEdit}
isRenaming={isRenaming}
/>
);
}
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Tooltip>
@@ -202,6 +354,17 @@ function BranchPathPopover({
<div className="group flex items-center gap-1.5 rounded-md px-2 h-7 bg-muted/40 hover:bg-muted/60 cursor-pointer transition-colors min-w-0 max-w-full">
<IconGitBranch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground truncate">{displayBranch}</span>
{onRenameBranch && (
<button
type="button"
onClick={handleStartEdit}
className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer p-0.5 hover:bg-muted rounded"
aria-label="Rename branch"
title="Rename branch"
>
<IconPencil className="h-3 w-3 text-muted-foreground" />
</button>
)}
<CopyIconButton
copied={copiedBranch}
onClick={(e) => {
@@ -213,7 +376,9 @@ function BranchPathPopover({
</div>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">Current branch</TooltipContent>
<TooltipContent side="right">
{onRenameBranch ? "Click to see paths, or click pencil to rename" : "Click to see paths"}
</TooltipContent>
</Tooltip>
<PopoverContent side="bottom" sideOffset={5} className="p-0 w-auto max-w-[600px] gap-1">
<div className="px-2 pt-1 pb-2 space-y-1.5">
@@ -241,6 +406,7 @@ function TopBarLeft({
remoteCreatedAt,
remoteCheckedAt,
remoteStatusError,
onRenameBranch,
}: {
taskId?: string | null;
activeSessionId?: string | null;
@@ -256,6 +422,7 @@ function TopBarLeft({
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
onRenameBranch?: (newName: string) => Promise<void>;
}) {
return (
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
@@ -303,6 +470,7 @@ function TopBarLeft({
displayBranch={displayBranch}
repositoryPath={repositoryPath}
worktreePath={worktreePath}
onRenameBranch={onRenameBranch}
/>
</div>
@@ -52,12 +52,14 @@ export type SessionGit = {
rebase: (baseBranch: string) => Promise<GitOperationResult>;
merge: (baseBranch: string) => Promise<GitOperationResult>;
abort: (operation: "merge" | "rebase") => Promise<GitOperationResult>;
commit: (message: string, stageAll?: boolean) => Promise<GitOperationResult>;
commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise<GitOperationResult>;
stage: (paths?: string[]) => Promise<GitOperationResult>;
stageAll: () => Promise<GitOperationResult>;
unstage: (paths?: string[]) => Promise<GitOperationResult>;
discard: (paths?: string[]) => Promise<GitOperationResult>;
revertCommit: (commitSHA: string) => Promise<GitOperationResult>;
renameBranch: (newName: string) => Promise<GitOperationResult>;
reset: (commitSHA: string, mode: "soft" | "hard") => Promise<GitOperationResult>;
createPR: (
title: string,
body: string,
@@ -133,6 +135,8 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
unstage: gitOps.unstage,
discard: gitOps.discard,
revertCommit: gitOps.revertCommit,
renameBranch: gitOps.renameBranch,
reset: gitOps.reset,
createPR: gitOps.createPR,
};
}
+47 -4
View File
@@ -1,16 +1,59 @@
import { useState, useCallback } from "react";
/**
* Fallback copy method for non-secure contexts (HTTP)
* where navigator.clipboard is not available
*/
function fallbackCopy(text: string): boolean {
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
let success = false;
try {
success = document.execCommand("copy");
} catch {
success = false;
}
document.body.removeChild(textArea);
return success;
}
export function useCopyToClipboard(duration = 2000) {
const [copied, setCopied] = useState(false);
const copy = useCallback(
async (text: string) => {
try {
await navigator.clipboard.writeText(text);
let success = false;
// Try modern clipboard API first
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
success = true;
} catch {
// Fall through to fallback
}
}
// Fallback for non-secure contexts
if (!success) {
success = fallbackCopy(text);
}
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), duration);
} catch (err) {
console.error("Failed to copy:", err);
} else {
console.error("Failed to copy to clipboard");
}
},
[duration],
+36 -4
View File
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { getWebSocketClient } from "@/lib/ws/connection";
import { createFile, deleteFile } from "@/lib/ws/workspace-files";
import { createFile, deleteFile, renameFile } from "@/lib/ws/workspace-files";
import { useToast } from "@/components/toast-provider";
const ERROR_VARIANT = "error" as const;
@@ -46,7 +46,7 @@ export function useFileOperations(sessionId: string | null) {
const response = await deleteFile(client, sessionId, path);
if (!response.success) {
toast({
title: "Failed to delete file",
title: "Failed to delete item",
description: response.error || UNKNOWN_ERROR,
variant: ERROR_VARIANT,
});
@@ -55,7 +55,7 @@ export function useFileOperations(sessionId: string | null) {
return true;
} catch (error) {
toast({
title: "Failed to delete file",
title: "Failed to delete item",
description: error instanceof Error ? error.message : UNKNOWN_ERROR,
variant: ERROR_VARIANT,
});
@@ -65,5 +65,37 @@ export function useFileOperations(sessionId: string | null) {
[sessionId, toast],
);
return { createFile: handleCreateFile, deleteFile: handleDeleteFile };
const handleRenameFile = useCallback(
async (oldPath: string, newPath: string): Promise<boolean> => {
const client = getWebSocketClient();
if (!client || !sessionId) return false;
try {
const response = await renameFile(client, sessionId, oldPath, newPath);
if (!response.success) {
toast({
title: "Failed to rename item",
description: response.error || UNKNOWN_ERROR,
variant: ERROR_VARIANT,
});
return false;
}
return true;
} catch (error) {
toast({
title: "Failed to rename item",
description: error instanceof Error ? error.message : UNKNOWN_ERROR,
variant: ERROR_VARIANT,
});
return false;
}
},
[sessionId, toast],
);
return {
createFile: handleCreateFile,
deleteFile: handleDeleteFile,
renameFile: handleRenameFile,
};
}
+17 -3
View File
@@ -27,11 +27,13 @@ interface UseGitOperationsReturn {
rebase: (baseBranch: string) => Promise<GitOperationResult>;
merge: (baseBranch: string) => Promise<GitOperationResult>;
abort: (operation: "merge" | "rebase") => Promise<GitOperationResult>;
commit: (message: string, stageAll?: boolean) => Promise<GitOperationResult>;
commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise<GitOperationResult>;
stage: (paths?: string[]) => Promise<GitOperationResult>;
unstage: (paths?: string[]) => Promise<GitOperationResult>;
discard: (paths?: string[]) => Promise<GitOperationResult>;
revertCommit: (commitSHA: string) => Promise<GitOperationResult>;
renameBranch: (newName: string) => Promise<GitOperationResult>;
reset: (commitSHA: string, mode: "soft" | "hard") => Promise<GitOperationResult>;
createPR: (
title: string,
body: string,
@@ -69,8 +71,12 @@ function buildGitOperationCallbacks(executeOperation: ExecuteOperation) {
const abort = async (operation: "merge" | "rebase") =>
executeOperation<GitOperationResult>("worktree.abort", { operation });
const commit = async (message: string, stageAll = true) =>
executeOperation<GitOperationResult>("worktree.commit", { message, stage_all: stageAll });
const commit = async (message: string, stageAll = true, amend = false) =>
executeOperation<GitOperationResult>("worktree.commit", {
message,
stage_all: stageAll,
amend,
});
const stage = async (paths?: string[]) =>
executeOperation<GitOperationResult>("worktree.stage", { paths: paths ?? [] });
@@ -84,6 +90,12 @@ function buildGitOperationCallbacks(executeOperation: ExecuteOperation) {
const revertCommit = async (commitSHA: string) =>
executeOperation<GitOperationResult>("worktree.revert_commit", { commit_sha: commitSHA });
const renameBranch = async (newName: string) =>
executeOperation<GitOperationResult>("worktree.rename_branch", { new_name: newName });
const reset = async (commitSHA: string, mode: "soft" | "hard") =>
executeOperation<GitOperationResult>("worktree.reset", { commit_sha: commitSHA, mode });
const createPR = async (
title: string,
body: string,
@@ -108,6 +120,8 @@ function buildGitOperationCallbacks(executeOperation: ExecuteOperation) {
unstage,
discard,
revertCommit,
renameBranch,
reset,
createPR,
};
}
@@ -86,6 +86,59 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = {
type ImmerSet = Parameters<typeof createSessionRuntimeSlice>[0];
function buildTerminalShellProcessActions(set: ImmerSet) {
return {
setTerminalOutput: (terminalId: string, data: string) =>
set((draft) => {
const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId);
if (existing) {
existing.output.push(data);
} else {
draft.terminal.terminals.push({ id: terminalId, output: [data] });
}
}),
appendShellOutput: (sessionId: string, data: string) =>
set((draft) => {
draft.shell.outputs[sessionId] = (draft.shell.outputs[sessionId] || "") + data;
}),
setShellStatus: (
sessionId: string,
status: { available: boolean; running?: boolean; shell?: string; cwd?: string },
) =>
set((draft) => {
draft.shell.statuses[sessionId] = status;
}),
clearShellOutput: (sessionId: string) =>
set((draft) => {
draft.shell.outputs[sessionId] = "";
}),
appendProcessOutput: (processId: string, data: string) =>
set((draft) => {
const next = (draft.processes.outputsByProcessId[processId] || "") + data;
draft.processes.outputsByProcessId[processId] = trimProcessOutput(next);
}),
upsertProcessStatus: (status: Parameters<SessionRuntimeSlice["upsertProcessStatus"]>[0]) =>
set((draft) => {
draft.processes.processesById[status.processId] = status;
const list = draft.processes.processIdsBySessionId[status.sessionId] || [];
if (!list.includes(status.processId)) {
draft.processes.processIdsBySessionId[status.sessionId] = [...list, status.processId];
}
if (status.kind === "dev") {
draft.processes.devProcessBySessionId[status.sessionId] = status.processId;
}
}),
clearProcessOutput: (processId: string) =>
set((draft) => {
draft.processes.outputsByProcessId[processId] = "";
}),
setActiveProcess: (sessionId: string, processId: string) =>
set((draft) => {
draft.processes.activeProcessBySessionId[sessionId] = processId;
}),
};
}
function buildGitSnapshotActions(set: ImmerSet) {
return {
setGitSnapshots: (
@@ -152,51 +205,7 @@ export const createSessionRuntimeSlice: StateCreator<
SessionRuntimeSlice
> = (set) => ({
...defaultSessionRuntimeState,
setTerminalOutput: (terminalId, data) =>
set((draft) => {
const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId);
if (existing) {
existing.output.push(data);
} else {
draft.terminal.terminals.push({ id: terminalId, output: [data] });
}
}),
appendShellOutput: (sessionId, data) =>
set((draft) => {
draft.shell.outputs[sessionId] = (draft.shell.outputs[sessionId] || "") + data;
}),
setShellStatus: (sessionId, status) =>
set((draft) => {
draft.shell.statuses[sessionId] = status;
}),
clearShellOutput: (sessionId) =>
set((draft) => {
draft.shell.outputs[sessionId] = "";
}),
appendProcessOutput: (processId, data) =>
set((draft) => {
const next = (draft.processes.outputsByProcessId[processId] || "") + data;
draft.processes.outputsByProcessId[processId] = trimProcessOutput(next);
}),
upsertProcessStatus: (status) =>
set((draft) => {
draft.processes.processesById[status.processId] = status;
const list = draft.processes.processIdsBySessionId[status.sessionId] || [];
if (!list.includes(status.processId)) {
draft.processes.processIdsBySessionId[status.sessionId] = [...list, status.processId];
}
if (status.kind === "dev") {
draft.processes.devProcessBySessionId[status.sessionId] = status.processId;
}
}),
clearProcessOutput: (processId) =>
set((draft) => {
draft.processes.outputsByProcessId[processId] = "";
}),
setActiveProcess: (sessionId, processId) =>
set((draft) => {
draft.processes.activeProcessBySessionId[sessionId] = processId;
}),
...buildTerminalShellProcessActions(set),
setGitStatus: (sessionId, gitStatus) =>
set((draft) => {
const existing = draft.gitStatus.bySessionId[sessionId];
@@ -223,7 +232,15 @@ export const createSessionRuntimeSlice: StateCreator<
addSessionCommit: (sessionId, commit) =>
set((draft) => {
const existing = draft.sessionCommits.bySessionId[sessionId] || [];
draft.sessionCommits.bySessionId[sessionId] = [commit, ...existing];
// For amend: only replace HEAD (first entry) if it has the same parent
if (existing.length > 0 && existing[0].parent_sha === commit.parent_sha) {
// Replace HEAD commit (this is an amend)
existing[0] = commit;
draft.sessionCommits.bySessionId[sessionId] = existing;
} else {
// Normal commit: prepend to list
draft.sessionCommits.bySessionId[sessionId] = [commit, ...existing];
}
}),
clearSessionCommits: (sessionId) =>
set((draft) => {
+20 -12
View File
@@ -1,5 +1,7 @@
import { createTwoFilesPatch } from "diff";
import { djb2Hash } from "./hash";
/**
* Generate a unified diff between original and modified content
* @param original - Original file content
@@ -26,21 +28,27 @@ export function generateUnifiedDiff(original: string, modified: string, filename
}
/**
* Calculate SHA256 hash of content
* Calculate hash of content for change detection.
* Uses Web Crypto API (SHA-256) in secure contexts (HTTPS, localhost).
* Falls back to djb2 in non-secure contexts (HTTP) — sufficient for detecting
* content changes, not for security purposes.
* @param content - Content to hash
* @returns Hex-encoded SHA256 hash
* @returns Hex-encoded hash string
*/
export async function calculateHash(content: string): Promise<string> {
// 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;
// crypto.subtle is only available in secure contexts (HTTPS, localhost)
if (typeof crypto !== "undefined" && crypto.subtle) {
try {
const encoder = new TextEncoder();
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
} catch {
// Fall through to djb2
}
}
return djb2Hash(content);
}
/**
+15
View File
@@ -0,0 +1,15 @@
/**
* Simple djb2 hash — fast, non-cryptographic.
* Used for change detection (e.g., detecting if file content changed).
* Not suitable for security purposes.
*
* @param str - String to hash
* @returns Hex-encoded hash string
*/
export function djb2Hash(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(16);
}
+26
View File
@@ -124,3 +124,29 @@ export async function deleteFile(
path,
});
}
/**
* File rename response from backend
*/
export type FileRenameResponse = {
old_path: string;
new_path: string;
success: boolean;
error?: string;
};
/**
* Rename a file or directory in the workspace
*/
export async function renameFile(
client: WebSocketClient,
sessionId: string,
oldPath: string,
newPath: string,
): Promise<FileRenameResponse> {
return client.request<FileRenameResponse>("workspace.file.rename", {
session_id: sessionId,
old_path: oldPath,
new_path: newPath,
});
}
+11 -8
View File
@@ -1,15 +1,18 @@
import type { NextConfig } from "next";
const defaultDevOrigins = [
"localhost",
"localhost:3000",
"192.168.1.116", // Maintainer dev server
"100.105.155.17", // Maintainer dev server (Tailscale)
];
const extraDevOrigins = process.env.NEXT_ALLOWED_DEV_ORIGINS
? process.env.NEXT_ALLOWED_DEV_ORIGINS.split(",").map((s) => s.trim())
: [];
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: [
"nova",
"nova:3000",
"localhost",
"localhost:3000",
"192.168.1.116",
"192.168.1.116:3000",
],
allowedDevOrigins: [...defaultDevOrigins, ...extraDevOrigins],
transpilePackages: ["@kandev/ui", "@kandev/theme", "@kandev/types"],
turbopack: {},
};
+1 -1
View File
@@ -35,5 +35,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "e2e"]
}