feat: Git status tracking refactor with persistent snapshots and commit history (#86)
This commit is contained in:
@@ -321,25 +321,42 @@ func main() {
|
||||
|
||||
var result []*ws.Message
|
||||
|
||||
// Send git status if available in session metadata
|
||||
if session.Metadata != nil {
|
||||
if gitStatus, ok := session.Metadata["git_status"]; ok {
|
||||
gitStatusData, ok := gitStatus.(map[string]interface{})
|
||||
if !ok {
|
||||
gitStatusData = map[string]interface{}{"data": gitStatus}
|
||||
}
|
||||
// Ensure session_id is included
|
||||
gitStatusData["session_id"] = sessionID
|
||||
if session.TaskID != "" {
|
||||
gitStatusData["task_id"] = session.TaskID
|
||||
}
|
||||
// Send git status from latest snapshot
|
||||
latestSnapshot, err := taskRepo.GetLatestGitSnapshot(ctx, sessionID)
|
||||
if err == nil && latestSnapshot != nil {
|
||||
// Extract file lists from snapshot metadata
|
||||
metadata := latestSnapshot.Metadata
|
||||
modified, _ := metadata["modified"].([]interface{})
|
||||
added, _ := metadata["added"].([]interface{})
|
||||
deleted, _ := metadata["deleted"].([]interface{})
|
||||
untracked, _ := metadata["untracked"].([]interface{})
|
||||
renamed, _ := metadata["renamed"].([]interface{})
|
||||
timestamp, _ := metadata["timestamp"].(string)
|
||||
|
||||
gitStatusNotification, err := ws.NewNotification("session.git.status", gitStatusData)
|
||||
if err == nil {
|
||||
result = append(result, gitStatusNotification)
|
||||
}
|
||||
gitStatusData := map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"task_id": session.TaskID,
|
||||
"branch": latestSnapshot.Branch,
|
||||
"remote_branch": latestSnapshot.RemoteBranch,
|
||||
"ahead": latestSnapshot.Ahead,
|
||||
"behind": latestSnapshot.Behind,
|
||||
"files": latestSnapshot.Files,
|
||||
"modified": modified,
|
||||
"added": added,
|
||||
"deleted": deleted,
|
||||
"untracked": untracked,
|
||||
"renamed": renamed,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
gitStatusNotification, err := ws.NewNotification("session.git.status", gitStatusData)
|
||||
if err == nil {
|
||||
result = append(result, gitStatusNotification)
|
||||
}
|
||||
}
|
||||
|
||||
// Send context window if available in session metadata
|
||||
if session.Metadata != nil {
|
||||
if contextWindow, ok := session.Metadata["context_window"]; ok {
|
||||
notification, err := ws.NewNotification(ws.ActionSessionStateChanged, map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
@@ -357,7 +374,7 @@ func main() {
|
||||
|
||||
return result, nil
|
||||
})
|
||||
log.Info("Session data provider configured for session subscriptions (git status)")
|
||||
log.Info("Session data provider configured for session subscriptions (git status from snapshots)")
|
||||
|
||||
waitForAgentctlControlHealthy(ctx, cfg, log)
|
||||
if err := orchestratorSvc.Start(ctx); err != nil {
|
||||
|
||||
@@ -37,6 +37,7 @@ func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||
d.RegisterFunc(ws.ActionWorktreeCommit, h.wsCommit)
|
||||
d.RegisterFunc(ws.ActionWorktreeStage, h.wsStage)
|
||||
d.RegisterFunc(ws.ActionWorktreeCreatePR, h.wsCreatePR)
|
||||
d.RegisterFunc(ws.ActionSessionCommitDiff, h.wsCommitDiff)
|
||||
}
|
||||
|
||||
// GitPullRequest for worktree.pull action
|
||||
@@ -91,6 +92,12 @@ type GitCreatePRRequest struct {
|
||||
BaseBranch string `json:"base_branch"`
|
||||
}
|
||||
|
||||
// GitShowCommitRequest for session.commit_diff action
|
||||
type GitShowCommitRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
}
|
||||
|
||||
// wsPull handles worktree.pull action
|
||||
func (h *GitHandlers) wsPull(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req GitPullRequest
|
||||
@@ -298,6 +305,33 @@ func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Mess
|
||||
return ws.NewResponse(msg.ID, msg.Action, result)
|
||||
}
|
||||
|
||||
// wsCommitDiff handles session.commit_diff action
|
||||
func (h *GitHandlers) wsCommitDiff(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req GitShowCommitRequest
|
||||
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")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.GitShowCommit(ctx, req.CommitSHA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("show commit failed: %w", err)
|
||||
}
|
||||
|
||||
return ws.NewResponse(msg.ID, msg.Action, result)
|
||||
}
|
||||
|
||||
// getAgentCtlClient gets the agentctl client for a session
|
||||
func (h *GitHandlers) getAgentCtlClient(sessionID string) (*client.Client, error) {
|
||||
execution, ok := h.lifecycleMgr.GetExecutionBySessionID(sessionID)
|
||||
|
||||
@@ -71,6 +71,8 @@ type GitStatusEventPayload struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Branch string `json:"branch"`
|
||||
RemoteBranch string `json:"remote_branch,omitempty"`
|
||||
HeadCommit string `json:"head_commit,omitempty"`
|
||||
BaseCommit string `json:"base_commit,omitempty"`
|
||||
Modified []string `json:"modified"`
|
||||
Added []string `json:"added"`
|
||||
Deleted []string `json:"deleted"`
|
||||
@@ -87,6 +89,27 @@ func (p GitStatusEventPayload) GetSessionID() string {
|
||||
return p.SessionID
|
||||
}
|
||||
|
||||
// GitCommitEventPayload is the payload for git commit events.
|
||||
type GitCommitEventPayload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
ParentSHA string `json:"parent_sha"`
|
||||
Message string `json:"message"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorEmail string `json:"author_email"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
Insertions int `json:"insertions"`
|
||||
Deletions int `json:"deletions"`
|
||||
CommittedAt string `json:"committed_at"`
|
||||
}
|
||||
|
||||
// GetSessionID returns the session ID for this event (used by event routing).
|
||||
func (p GitCommitEventPayload) GetSessionID() string {
|
||||
return p.SessionID
|
||||
}
|
||||
|
||||
// FileChangeEventPayload is the payload for file change notifications.
|
||||
type FileChangeEventPayload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
|
||||
@@ -207,6 +207,42 @@ func (p *EventPublisher) PublishGitStatus(execution *AgentExecution, update *age
|
||||
}
|
||||
}
|
||||
|
||||
// PublishGitCommit publishes a git commit event to the event bus.
|
||||
func (p *EventPublisher) PublishGitCommit(execution *AgentExecution, commit *agentctl.GitCommitNotification) {
|
||||
if p.eventBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := execution.SessionID
|
||||
|
||||
payload := GitCommitEventPayload{
|
||||
TaskID: execution.TaskID,
|
||||
SessionID: sessionID,
|
||||
AgentID: execution.ID,
|
||||
CommitSHA: commit.CommitSHA,
|
||||
ParentSHA: commit.ParentSHA,
|
||||
Message: commit.Message,
|
||||
AuthorName: commit.AuthorName,
|
||||
AuthorEmail: commit.AuthorEmail,
|
||||
FilesChanged: commit.FilesChanged,
|
||||
Insertions: commit.Insertions,
|
||||
Deletions: commit.Deletions,
|
||||
CommittedAt: commit.CommittedAt.Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
event := bus.NewEvent(events.GitCommitCreated, "agent-manager", payload)
|
||||
subject := events.BuildGitCommitSubject(sessionID)
|
||||
|
||||
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
|
||||
p.logger.Error("failed to publish git commit event",
|
||||
zap.String("instance_id", execution.ID),
|
||||
zap.String("task_id", execution.TaskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("commit_sha", commit.CommitSHA),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// PublishFileChange publishes a file change notification to the event bus.
|
||||
func (p *EventPublisher) PublishFileChange(execution *AgentExecution, notification *agentctl.FileChangeNotification) {
|
||||
if p.eventBus == nil {
|
||||
|
||||
@@ -144,39 +144,7 @@ func (s *ExecutionStore) UpdateError(executionID string, errorMsg string) {
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateMetadata updates the metadata of an agent execution using the provided function.
|
||||
// The function is called outside the lock to prevent deadlocks - it receives a copy of
|
||||
// the current metadata and returns the new metadata to set.
|
||||
func (s *ExecutionStore) UpdateMetadata(executionID string, updateFn func(metadata map[string]interface{}) map[string]interface{}) {
|
||||
// First, get a copy of current metadata
|
||||
s.mu.RLock()
|
||||
execution, exists := s.executions[executionID]
|
||||
if !exists {
|
||||
s.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Copy metadata to avoid races
|
||||
currentMetadata := make(map[string]interface{})
|
||||
if execution.Metadata != nil {
|
||||
for k, v := range execution.Metadata {
|
||||
currentMetadata[k] = v
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Call update function outside the lock
|
||||
newMetadata := updateFn(currentMetadata)
|
||||
|
||||
// Apply the result
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Re-check existence (could have been removed)
|
||||
if execution, exists = s.executions[executionID]; exists {
|
||||
execution.Metadata = newMetadata
|
||||
}
|
||||
}
|
||||
|
||||
// WithLock executes a function with the store lock held, providing access to the execution.
|
||||
// Returns ErrExecutionNotFound if the execution doesn't exist.
|
||||
|
||||
@@ -109,6 +109,7 @@ func NewManager(
|
||||
mgr.streamManager = NewStreamManager(log, StreamCallbacks{
|
||||
OnAgentEvent: mgr.handleAgentEvent,
|
||||
OnGitStatus: mgr.handleGitStatusUpdate,
|
||||
OnGitCommit: mgr.handleGitCommitCreated,
|
||||
OnFileChange: mgr.handleFileChangeNotification,
|
||||
OnShellOutput: mgr.handleShellOutput,
|
||||
OnShellExit: mgr.handleShellExit,
|
||||
@@ -1069,27 +1070,16 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age
|
||||
|
||||
// handleGitStatusUpdate processes git status updates from the workspace tracker
|
||||
func (m *Manager) handleGitStatusUpdate(execution *AgentExecution, update *agentctl.GitStatusUpdate) {
|
||||
// Store git status in execution metadata
|
||||
m.executionStore.UpdateMetadata(execution.ID, func(metadata map[string]interface{}) map[string]interface{} {
|
||||
metadata["git_status"] = map[string]interface{}{
|
||||
"branch": update.Branch,
|
||||
"remote_branch": update.RemoteBranch,
|
||||
"modified": update.Modified,
|
||||
"added": update.Added,
|
||||
"deleted": update.Deleted,
|
||||
"untracked": update.Untracked,
|
||||
"renamed": update.Renamed,
|
||||
"ahead": update.Ahead,
|
||||
"behind": update.Behind,
|
||||
"timestamp": update.Timestamp,
|
||||
}
|
||||
return metadata
|
||||
})
|
||||
|
||||
// Publish git status update to event bus for WebSocket streaming
|
||||
// Publish git status update to event bus for WebSocket streaming and persistence
|
||||
m.eventPublisher.PublishGitStatus(execution, update)
|
||||
}
|
||||
|
||||
// handleGitCommitCreated processes git commit events from the workspace tracker
|
||||
func (m *Manager) handleGitCommitCreated(execution *AgentExecution, commit *agentctl.GitCommitNotification) {
|
||||
// Publish commit event to event bus for WebSocket streaming and orchestrator handling
|
||||
m.eventPublisher.PublishGitCommit(execution, commit)
|
||||
}
|
||||
|
||||
// handleFileChangeNotification processes file change notifications from the workspace tracker
|
||||
func (m *Manager) handleFileChangeNotification(execution *AgentExecution, notification *agentctl.FileChangeNotification) {
|
||||
m.eventPublisher.PublishFileChange(execution, notification)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type StreamCallbacks struct {
|
||||
OnAgentEvent func(execution *AgentExecution, event agentctl.AgentEvent)
|
||||
OnGitStatus func(execution *AgentExecution, update *agentctl.GitStatusUpdate)
|
||||
OnGitCommit func(execution *AgentExecution, commit *agentctl.GitCommitNotification)
|
||||
OnFileChange func(execution *AgentExecution, notification *agentctl.FileChangeNotification)
|
||||
OnShellOutput func(execution *AgentExecution, data string)
|
||||
OnShellExit func(execution *AgentExecution, code int)
|
||||
@@ -120,6 +121,11 @@ func (sm *StreamManager) connectWorkspaceStream(execution *AgentExecution) {
|
||||
sm.callbacks.OnGitStatus(execution, update)
|
||||
}
|
||||
},
|
||||
OnGitCommit: func(commit *agentctl.GitCommitNotification) {
|
||||
if sm.callbacks.OnGitCommit != nil {
|
||||
sm.callbacks.OnGitCommit(execution, commit)
|
||||
}
|
||||
},
|
||||
OnFileChange: func(notification *agentctl.FileChangeNotification) {
|
||||
if sm.callbacks.OnFileChange != nil {
|
||||
sm.callbacks.OnFileChange(execution, notification)
|
||||
|
||||
@@ -254,6 +254,7 @@ func (c *Client) BaseURL() string {
|
||||
// These types are defined in the streams subpackage and re-exported through types.
|
||||
type (
|
||||
GitStatusUpdate = types.GitStatusUpdate
|
||||
GitCommitNotification = types.GitCommitNotification
|
||||
FileInfo = types.FileInfo
|
||||
FileListUpdate = types.FileListUpdate
|
||||
FileEntry = types.FileEntry
|
||||
@@ -565,6 +566,7 @@ type WorkspaceStreamCallbacks struct {
|
||||
OnShellOutput func(data string)
|
||||
OnShellExit func(code int)
|
||||
OnGitStatus func(update *GitStatusUpdate)
|
||||
OnGitCommit func(notification *GitCommitNotification)
|
||||
OnFileChange func(notification *FileChangeNotification)
|
||||
OnFileList func(update *FileListUpdate)
|
||||
OnProcessOutput func(output *types.ProcessOutput)
|
||||
@@ -641,6 +643,10 @@ func (c *Client) StreamWorkspace(ctx context.Context, callbacks WorkspaceStreamC
|
||||
if callbacks.OnGitStatus != nil && msg.GitStatus != nil {
|
||||
callbacks.OnGitStatus(msg.GitStatus)
|
||||
}
|
||||
case types.WorkspaceMessageTypeGitCommit:
|
||||
if callbacks.OnGitCommit != nil && msg.GitCommit != nil {
|
||||
callbacks.OnGitCommit(msg.GitCommit)
|
||||
}
|
||||
case types.WorkspaceMessageTypeFileChange:
|
||||
if callbacks.OnFileChange != nil && msg.FileChange != nil {
|
||||
callbacks.OnFileChange(msg.FileChange)
|
||||
|
||||
@@ -197,3 +197,47 @@ func (c *Client) gitOperation(ctx context.Context, path string, payload interfac
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CommitDiffResult represents the result of getting a commit's diff.
|
||||
type CommitDiffResult struct {
|
||||
Success bool `json:"success"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
Message string `json:"message"`
|
||||
Author string `json:"author"`
|
||||
Date string `json:"date"`
|
||||
Files map[string]interface{} `json:"files"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
Insertions int `json:"insertions"`
|
||||
Deletions int `json:"deletions"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// GitShowCommit gets the diff for a specific commit.
|
||||
func (c *Client) GitShowCommit(ctx context.Context, commitSHA string) (*CommitDiffResult, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/git/commit/"+commitSHA, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := readResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var result CommitDiffResult
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w",
|
||||
resp.StatusCode, truncateBody(respBody), err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return &result, fmt.Errorf("git show commit failed with status %d: %s", resp.StatusCode, result.Error)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ type GitStageRequest struct {
|
||||
Paths []string `json:"paths"` // Empty = stage all
|
||||
}
|
||||
|
||||
// GitShowCommitRequest for GET /api/v1/git/commit/:sha
|
||||
type GitShowCommitRequest struct {
|
||||
CommitSHA string `uri:"sha" binding:"required"`
|
||||
}
|
||||
|
||||
// GitCreatePRRequest for POST /api/v1/git/create-pr
|
||||
type GitCreatePRRequest struct {
|
||||
Title string `json:"title"`
|
||||
@@ -283,6 +288,32 @@ func (s *Server) handleGitCreatePR(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleGitShowCommit handles GET /api/v1/git/commit/:sha
|
||||
func (s *Server) handleGitShowCommit(c *gin.Context) {
|
||||
var req GitShowCommitRequest
|
||||
if err := c.ShouldBindUri(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, process.CommitDiffResult{
|
||||
Success: false,
|
||||
Error: "invalid request: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
gitOp := s.procMgr.GitOperator()
|
||||
result, err := gitOp.ShowCommit(c.Request.Context(), req.CommitSHA)
|
||||
if err != nil {
|
||||
s.logger.Error("git show commit failed", zap.String("commit_sha", req.CommitSHA), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, process.CommitDiffResult{
|
||||
Success: false,
|
||||
CommitSHA: req.CommitSHA,
|
||||
Error: err.Error(),
|
||||
})
|
||||
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) {
|
||||
|
||||
@@ -104,6 +104,7 @@ func (s *Server) setupRoutes() {
|
||||
api.POST("/git/commit", s.handleGitCommit)
|
||||
api.POST("/git/stage", s.handleGitStage)
|
||||
api.POST("/git/create-pr", s.handleGitCreatePR)
|
||||
api.GET("/git/commit/:sha", s.handleGitShowCommit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -55,8 +57,9 @@ type GitOperationResult struct {
|
||||
|
||||
// GitOperator executes git operations in a workspace directory.
|
||||
type GitOperator struct {
|
||||
workDir string
|
||||
logger *logger.Logger
|
||||
workDir string
|
||||
logger *logger.Logger
|
||||
workspaceTracker *WorkspaceTracker
|
||||
|
||||
mu sync.Mutex // Prevents concurrent git operations
|
||||
inProgress bool
|
||||
@@ -64,10 +67,11 @@ type GitOperator struct {
|
||||
}
|
||||
|
||||
// NewGitOperator creates a new GitOperator for the given workspace directory.
|
||||
func NewGitOperator(workDir string, log *logger.Logger) *GitOperator {
|
||||
func NewGitOperator(workDir string, log *logger.Logger, workspaceTracker *WorkspaceTracker) *GitOperator {
|
||||
return &GitOperator{
|
||||
workDir: workDir,
|
||||
logger: log.WithFields(zap.String("component", "git-operator")),
|
||||
workDir: workDir,
|
||||
logger: log.WithFields(zap.String("component", "git-operator")),
|
||||
workspaceTracker: workspaceTracker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +398,41 @@ func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool)
|
||||
|
||||
result.Success = true
|
||||
g.logger.Info("commit completed", zap.String("message", message))
|
||||
|
||||
// Publish commit notification if we have a workspace tracker
|
||||
if g.workspaceTracker != nil {
|
||||
// Get commit details
|
||||
commitSHA, _ := g.runGitCommand(ctx, "rev-parse", "HEAD")
|
||||
parentSHA, _ := g.runGitCommand(ctx, "rev-parse", "HEAD~1")
|
||||
|
||||
// Get commit info (author name|author email)
|
||||
authorInfo, _ := g.runGitCommand(ctx, "show", "-s", "--format=%an|%ae", "HEAD")
|
||||
authorParts := strings.Split(strings.TrimSpace(authorInfo), "|")
|
||||
authorName := ""
|
||||
authorEmail := ""
|
||||
if len(authorParts) >= 2 {
|
||||
authorName = authorParts[0]
|
||||
authorEmail = authorParts[1]
|
||||
}
|
||||
|
||||
// Get commit stats
|
||||
filesChanged, insertions, deletions := g.getCommitStats(ctx, strings.TrimSpace(commitSHA))
|
||||
|
||||
commit := &streams.GitCommitNotification{
|
||||
CommitSHA: strings.TrimSpace(commitSHA),
|
||||
ParentSHA: strings.TrimSpace(parentSHA),
|
||||
Message: message,
|
||||
AuthorName: authorName,
|
||||
AuthorEmail: authorEmail,
|
||||
FilesChanged: filesChanged,
|
||||
Insertions: insertions,
|
||||
Deletions: deletions,
|
||||
CommittedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
g.workspaceTracker.NotifyGitCommit(commit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -466,7 +505,151 @@ func (g *GitOperator) Abort(ctx context.Context, operation string) (*GitOperatio
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CommitDiffResult represents the result of getting a commit's diff.
|
||||
type CommitDiffResult struct {
|
||||
Success bool `json:"success"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
Message string `json:"message"`
|
||||
Author string `json:"author"`
|
||||
Date string `json:"date"`
|
||||
Files map[string]interface{} `json:"files"` // FileInfo objects with diff content
|
||||
FilesChanged int `json:"files_changed"`
|
||||
Insertions int `json:"insertions"`
|
||||
Deletions int `json:"deletions"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ShowCommit gets the diff for a specific commit using git show.
|
||||
func (g *GitOperator) ShowCommit(ctx context.Context, commitSHA string) (*CommitDiffResult, error) {
|
||||
result := &CommitDiffResult{
|
||||
CommitSHA: commitSHA,
|
||||
}
|
||||
|
||||
// Validate commit SHA (basic validation - alphanumeric only)
|
||||
if commitSHA == "" || len(commitSHA) > 64 {
|
||||
result.Error = "invalid commit SHA"
|
||||
return result, nil
|
||||
}
|
||||
for _, c := range commitSHA {
|
||||
isDigit := c >= '0' && c <= '9'
|
||||
isLowerHex := c >= 'a' && c <= 'f'
|
||||
isUpperHex := c >= 'A' && c <= 'F'
|
||||
if !isDigit && !isLowerHex && !isUpperHex {
|
||||
result.Error = "invalid commit SHA: must be hexadecimal"
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get commit metadata
|
||||
formatOutput, err := g.runGitCommand(ctx, "show", "--no-patch", "--format=%H%n%s%n%an <%ae>%n%aI", commitSHA)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to get commit info: %s", err.Error())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(formatOutput), "\n")
|
||||
if len(lines) >= 4 {
|
||||
result.CommitSHA = lines[0]
|
||||
result.Message = lines[1]
|
||||
result.Author = lines[2]
|
||||
result.Date = lines[3]
|
||||
}
|
||||
|
||||
// Get the diff with stats
|
||||
diffOutput, err := g.runGitCommand(ctx, "show", "--format=", "--stat", "--numstat", "-p", commitSHA)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to get commit diff: %s", err.Error())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Parse the diff output into files
|
||||
result.Files = g.parseCommitDiff(diffOutput)
|
||||
result.FilesChanged = len(result.Files)
|
||||
|
||||
// Calculate total insertions/deletions
|
||||
for _, fileInfo := range result.Files {
|
||||
if fi, ok := fileInfo.(map[string]interface{}); ok {
|
||||
if additions, ok := fi["additions"].(int); ok {
|
||||
result.Insertions += additions
|
||||
}
|
||||
if deletions, ok := fi["deletions"].(int); ok {
|
||||
result.Deletions += deletions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseCommitDiff parses git show output into file info map
|
||||
func (g *GitOperator) parseCommitDiff(output string) map[string]interface{} {
|
||||
files := make(map[string]interface{})
|
||||
|
||||
// Split by "diff --git" to get individual file diffs
|
||||
parts := strings.Split(output, "diff --git ")
|
||||
if len(parts) <= 1 {
|
||||
return files
|
||||
}
|
||||
|
||||
for _, part := range parts[1:] {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Re-add the "diff --git " prefix
|
||||
diffContent := "diff --git " + part
|
||||
|
||||
// Extract file path from the diff header
|
||||
// Format: diff --git a/path/to/file b/path/to/file
|
||||
lines := strings.SplitN(diffContent, "\n", 2)
|
||||
if len(lines) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
header := lines[0]
|
||||
// Extract path from "diff --git a/path b/path"
|
||||
headerParts := strings.Split(header, " ")
|
||||
if len(headerParts) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the b/path part and remove the "b/" prefix
|
||||
bPath := headerParts[len(headerParts)-1]
|
||||
filePath := strings.TrimPrefix(bPath, "b/")
|
||||
|
||||
// Determine file status from diff content
|
||||
status := "modified"
|
||||
if strings.Contains(diffContent, "new file mode") {
|
||||
status = "added"
|
||||
} else if strings.Contains(diffContent, "deleted file mode") {
|
||||
status = "deleted"
|
||||
} else if strings.Contains(diffContent, "rename from") {
|
||||
status = "renamed"
|
||||
}
|
||||
|
||||
// Count additions and deletions
|
||||
additions := 0
|
||||
deletions := 0
|
||||
for _, line := range strings.Split(diffContent, "\n") {
|
||||
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
|
||||
additions++
|
||||
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
|
||||
deletions++
|
||||
}
|
||||
}
|
||||
|
||||
files[filePath] = map[string]interface{}{
|
||||
"status": status,
|
||||
"staged": false,
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"diff": diffContent,
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// tryLock attempts to acquire the operation lock without blocking.
|
||||
// Returns true if the lock was acquired, false if an operation is in progress.
|
||||
@@ -576,3 +759,53 @@ func (g *GitOperator) CreatePR(ctx context.Context, title, body, baseBranch stri
|
||||
g.logger.Info("PR created", zap.String("url", result.PRURL))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getCommitStats returns the number of files changed, insertions, and deletions for a commit
|
||||
func (g *GitOperator) getCommitStats(ctx context.Context, commitSHA string) (filesChanged, insertions, deletions int) {
|
||||
// git show --stat --format="" HEAD gives us the stat summary
|
||||
output, err := g.runGitCommand(ctx, "show", "--stat", "--format=", commitSHA)
|
||||
if err != nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
// The last non-empty line contains the summary like:
|
||||
// " 3 files changed, 10 insertions(+), 5 deletions(-)"
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(lines) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
summary := lines[len(lines)-1]
|
||||
// Parse: "N files changed, M insertions(+), K deletions(-)"
|
||||
// Some variants may be missing insertions or deletions
|
||||
|
||||
// Match files changed
|
||||
if idx := strings.Index(summary, " file"); idx > 0 {
|
||||
part := strings.TrimSpace(summary[:idx])
|
||||
// Get just the number
|
||||
parts := strings.Fields(part)
|
||||
if len(parts) > 0 {
|
||||
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &filesChanged)
|
||||
}
|
||||
}
|
||||
|
||||
// Match insertions
|
||||
if idx := strings.Index(summary, " insertion"); idx > 0 {
|
||||
// Find the number before " insertion"
|
||||
start := strings.LastIndex(summary[:idx], " ") + 1
|
||||
if start > 0 && start < idx {
|
||||
_, _ = fmt.Sscanf(summary[start:idx], "%d", &insertions)
|
||||
}
|
||||
}
|
||||
|
||||
// Match deletions
|
||||
if idx := strings.Index(summary, " deletion"); idx > 0 {
|
||||
// Find the number before " deletion"
|
||||
start := strings.LastIndex(summary[:idx], " ") + 1
|
||||
if start > 0 && start < idx {
|
||||
_, _ = fmt.Sscanf(summary[start:idx], "%d", &deletions)
|
||||
}
|
||||
}
|
||||
|
||||
return filesChanged, insertions, deletions
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ func (m *Manager) GitOperator() *GitOperator {
|
||||
defer m.gitOperatorMu.Unlock()
|
||||
|
||||
if m.gitOperator == nil {
|
||||
m.gitOperator = NewGitOperator(m.cfg.WorkDir, m.logger)
|
||||
m.gitOperator = NewGitOperator(m.cfg.WorkDir, m.logger, m.workspaceTracker)
|
||||
}
|
||||
return m.gitOperator
|
||||
}
|
||||
|
||||
@@ -209,6 +209,31 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUp
|
||||
update.RemoteBranch = strings.TrimSpace(string(remoteOut))
|
||||
}
|
||||
|
||||
// Get current HEAD commit SHA
|
||||
headCmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD")
|
||||
headCmd.Dir = wt.workDir
|
||||
if headOut, err := headCmd.Output(); err == nil {
|
||||
update.HeadCommit = strings.TrimSpace(string(headOut))
|
||||
}
|
||||
|
||||
// Get base commit SHA (base branch HEAD)
|
||||
// Use remote branch if available, fall back to main/master
|
||||
baseBranch := update.RemoteBranch
|
||||
if baseBranch == "" {
|
||||
// Try main first, then master
|
||||
baseBranch = "main"
|
||||
checkCmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "main")
|
||||
checkCmd.Dir = wt.workDir
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
baseBranch = "master"
|
||||
}
|
||||
}
|
||||
baseCmd := exec.CommandContext(ctx, "git", "rev-parse", baseBranch)
|
||||
baseCmd.Dir = wt.workDir
|
||||
if baseOut, err := baseCmd.Output(); err == nil {
|
||||
update.BaseCommit = strings.TrimSpace(string(baseOut))
|
||||
}
|
||||
|
||||
// Get ahead/behind counts
|
||||
if update.RemoteBranch != "" {
|
||||
countCmd := exec.CommandContext(ctx, "git", "rev-list", "--left-right", "--count", update.Branch+"..."+update.RemoteBranch)
|
||||
@@ -427,8 +452,12 @@ func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *type
|
||||
fileInfo.Additions = len(lines)
|
||||
fileInfo.Deletions = 0
|
||||
|
||||
// Format as a diff
|
||||
// Format as a proper git diff with all required headers
|
||||
// The @git-diff-view/react library requires the full git diff format
|
||||
var diffBuilder strings.Builder
|
||||
diffBuilder.WriteString("diff --git a/" + filePath + " b/" + filePath + "\n")
|
||||
diffBuilder.WriteString("new file mode 100644\n")
|
||||
diffBuilder.WriteString("index 0000000..0000000\n")
|
||||
diffBuilder.WriteString("--- /dev/null\n")
|
||||
diffBuilder.WriteString("+++ b/" + filePath + "\n")
|
||||
diffBuilder.WriteString("@@ -0,0 +1," + strconv.Itoa(len(lines)) + " @@\n")
|
||||
@@ -557,6 +586,26 @@ func (wt *WorkspaceTracker) notifyWorkspaceStreamGitStatus(update types.GitStatu
|
||||
}
|
||||
}
|
||||
|
||||
// notifyWorkspaceStreamGitCommit sends git commit notification to all workspace stream subscribers
|
||||
func (wt *WorkspaceTracker) notifyWorkspaceStreamGitCommit(commit *types.GitCommitNotification) {
|
||||
wt.workspaceSubMu.RLock()
|
||||
defer wt.workspaceSubMu.RUnlock()
|
||||
|
||||
msg := types.NewWorkspaceGitCommit(commit)
|
||||
for sub := range wt.workspaceStreamSubscribers {
|
||||
select {
|
||||
case sub <- msg:
|
||||
default:
|
||||
// Subscriber is slow, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyGitCommit notifies all subscribers about a new git commit
|
||||
func (wt *WorkspaceTracker) NotifyGitCommit(commit *types.GitCommitNotification) {
|
||||
wt.notifyWorkspaceStreamGitCommit(commit)
|
||||
}
|
||||
|
||||
// notifyWorkspaceStreamFileChange sends file change notification to all workspace stream subscribers
|
||||
func (wt *WorkspaceTracker) notifyWorkspaceStreamFileChange(notification types.FileChangeNotification) {
|
||||
wt.workspaceSubMu.RLock()
|
||||
|
||||
@@ -37,6 +37,12 @@ type GitStatusUpdate struct {
|
||||
// RemoteBranch is the tracked remote branch (e.g., "origin/main").
|
||||
RemoteBranch string `json:"remote_branch,omitempty"`
|
||||
|
||||
// HeadCommit is the current HEAD commit SHA.
|
||||
HeadCommit string `json:"head_commit,omitempty"`
|
||||
|
||||
// BaseCommit is the base branch HEAD commit SHA (for comparison/diff).
|
||||
BaseCommit string `json:"base_commit,omitempty"`
|
||||
|
||||
// Files contains detailed information about each changed file.
|
||||
Files map[string]FileInfo `json:"files,omitempty"`
|
||||
}
|
||||
@@ -66,3 +72,35 @@ type FileInfo struct {
|
||||
Diff string `json:"diff,omitempty"`
|
||||
}
|
||||
|
||||
// GitCommitNotification is sent when a new commit is detected in the workspace.
|
||||
type GitCommitNotification struct {
|
||||
// Timestamp is when this notification was created.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// CommitSHA is the SHA of the new commit.
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
|
||||
// ParentSHA is the SHA of the parent commit.
|
||||
ParentSHA string `json:"parent_sha"`
|
||||
|
||||
// Message is the commit message.
|
||||
Message string `json:"message"`
|
||||
|
||||
// AuthorName is the name of the commit author.
|
||||
AuthorName string `json:"author_name"`
|
||||
|
||||
// AuthorEmail is the email of the commit author.
|
||||
AuthorEmail string `json:"author_email"`
|
||||
|
||||
// FilesChanged is the number of files changed in the commit.
|
||||
FilesChanged int `json:"files_changed"`
|
||||
|
||||
// Insertions is the number of lines added.
|
||||
Insertions int `json:"insertions"`
|
||||
|
||||
// Deletions is the number of lines deleted.
|
||||
Deletions int `json:"deletions"`
|
||||
|
||||
// CommittedAt is when the commit was made.
|
||||
CommittedAt time.Time `json:"committed_at"`
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ type (
|
||||
PermissionRespondResponse = streams.PermissionRespondResponse
|
||||
|
||||
// Git stream types
|
||||
GitStatusUpdate = streams.GitStatusUpdate
|
||||
FileInfo = streams.FileInfo
|
||||
GitStatusUpdate = streams.GitStatusUpdate
|
||||
GitCommitNotification = streams.GitCommitNotification
|
||||
FileInfo = streams.FileInfo
|
||||
|
||||
// File stream types
|
||||
FileChangeNotification = streams.FileChangeNotification
|
||||
@@ -153,6 +154,7 @@ const (
|
||||
WorkspaceMessageTypeShellResize WorkspaceMessageType = "shell_resize"
|
||||
WorkspaceMessageTypeProcessOutput WorkspaceMessageType = "process_output"
|
||||
WorkspaceMessageTypeProcessStatus WorkspaceMessageType = "process_status"
|
||||
WorkspaceMessageTypeGitCommit WorkspaceMessageType = "git_commit"
|
||||
)
|
||||
|
||||
// WorkspaceStreamMessage is the unified message format for the workspace stream.
|
||||
@@ -183,6 +185,9 @@ type WorkspaceStreamMessage struct {
|
||||
ProcessOutput *ProcessOutput `json:"process_output,omitempty"`
|
||||
ProcessStatus *ProcessStatusUpdate `json:"process_status,omitempty"`
|
||||
|
||||
// Git commit fields (for git_commit)
|
||||
GitCommit *GitCommitNotification `json:"git_commit,omitempty"`
|
||||
|
||||
// Error fields (for error)
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -214,6 +219,15 @@ func NewWorkspaceGitStatus(status *GitStatusUpdate) WorkspaceStreamMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// NewWorkspaceGitCommit creates a git commit notification message
|
||||
func NewWorkspaceGitCommit(commit *GitCommitNotification) WorkspaceStreamMessage {
|
||||
return WorkspaceStreamMessage{
|
||||
Type: WorkspaceMessageTypeGitCommit,
|
||||
Timestamp: timeNowUnixMilli(),
|
||||
GitCommit: commit,
|
||||
}
|
||||
}
|
||||
|
||||
// NewWorkspaceFileChange creates a file change message
|
||||
func NewWorkspaceFileChange(notification *FileChangeNotification) WorkspaceStreamMessage {
|
||||
return WorkspaceStreamMessage{
|
||||
|
||||
@@ -108,6 +108,9 @@ const (
|
||||
const (
|
||||
GitStatusUpdated = "git.status.updated" // Git status changed in workspace
|
||||
FileChangeNotified = "file.change.notified" // File changed in workspace
|
||||
GitCommitCreated = "git.commit.created" // New commit created in workspace
|
||||
GitSnapshotCreated = "git.snapshot.created" // New git snapshot created
|
||||
GitCommitRecorded = "git.commit.recorded" // New session commit recorded to database
|
||||
)
|
||||
|
||||
// Event types for shell I/O
|
||||
@@ -187,6 +190,36 @@ func BuildGitStatusWildcardSubject() string {
|
||||
return GitStatusUpdated + ".*"
|
||||
}
|
||||
|
||||
// BuildGitCommitSubject returns the event subject for git commit events for a session.
|
||||
func BuildGitCommitSubject(sessionID string) string {
|
||||
return GitCommitCreated + "." + sessionID
|
||||
}
|
||||
|
||||
// BuildGitCommitWildcardSubject creates a wildcard subscription for all git commit events
|
||||
func BuildGitCommitWildcardSubject() string {
|
||||
return GitCommitCreated + ".*"
|
||||
}
|
||||
|
||||
// BuildGitSnapshotSubject creates a git snapshot subject for a specific session
|
||||
func BuildGitSnapshotSubject(sessionID string) string {
|
||||
return GitSnapshotCreated + "." + sessionID
|
||||
}
|
||||
|
||||
// BuildGitSnapshotWildcardSubject creates a wildcard subscription for all git snapshot events
|
||||
func BuildGitSnapshotWildcardSubject() string {
|
||||
return GitSnapshotCreated + ".*"
|
||||
}
|
||||
|
||||
// BuildGitCommitRecordedSubject creates a git commit recorded subject for a specific session
|
||||
func BuildGitCommitRecordedSubject(sessionID string) string {
|
||||
return GitCommitRecorded + "." + sessionID
|
||||
}
|
||||
|
||||
// BuildGitCommitRecordedWildcardSubject creates a wildcard subscription for all git commit recorded events
|
||||
func BuildGitCommitRecordedWildcardSubject() string {
|
||||
return GitCommitRecorded + ".*"
|
||||
}
|
||||
|
||||
// BuildFileChangeSubject creates a file change subject for a specific session
|
||||
func BuildFileChangeSubject(sessionID string) string {
|
||||
return FileChangeNotified + "." + sessionID
|
||||
|
||||
@@ -26,6 +26,8 @@ func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventB
|
||||
}
|
||||
|
||||
b.subscribe(eventBus, events.BuildGitStatusWildcardSubject(), ws.ActionSessionGitStatus)
|
||||
b.subscribe(eventBus, events.BuildGitSnapshotWildcardSubject(), ws.ActionSessionGitSnapshot)
|
||||
b.subscribe(eventBus, events.BuildGitCommitRecordedWildcardSubject(), ws.ActionSessionGitCommit)
|
||||
b.subscribe(eventBus, events.BuildFileChangeWildcardSubject(), ws.ActionWorkspaceFileChanges)
|
||||
b.subscribe(eventBus, events.BuildShellOutputWildcardSubject(), ws.ActionSessionShellOutput)
|
||||
b.subscribe(eventBus, events.BuildShellExitWildcardSubject(), ws.ActionSessionShellOutput)
|
||||
|
||||
@@ -3,6 +3,7 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -423,7 +424,7 @@ func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID strin
|
||||
}
|
||||
}
|
||||
|
||||
// handleGitStatusUpdated handles git status updates and persists them to agent session metadata
|
||||
// handleGitStatusUpdated handles git status updates by creating git snapshots
|
||||
func (s *Service) handleGitStatusUpdated(ctx context.Context, data watcher.GitStatusData) {
|
||||
s.logger.Debug("handling git status update",
|
||||
zap.String("task_id", data.TaskID),
|
||||
@@ -435,47 +436,99 @@ func (s *Service) handleGitStatusUpdated(ctx context.Context, data watcher.GitSt
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.repo.GetTaskSession(ctx, data.TaskSessionID)
|
||||
if err != nil {
|
||||
s.logger.Debug("no task session for git status update",
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.Error(err))
|
||||
return
|
||||
// Create git snapshot instead of storing in session metadata
|
||||
snapshot := &models.GitSnapshot{
|
||||
SessionID: data.TaskSessionID,
|
||||
SnapshotType: models.SnapshotTypeStatusUpdate,
|
||||
Branch: data.Branch,
|
||||
RemoteBranch: data.RemoteBranch,
|
||||
HeadCommit: data.HeadCommit,
|
||||
BaseCommit: data.BaseCommit,
|
||||
Ahead: data.Ahead,
|
||||
Behind: data.Behind,
|
||||
Files: data.Files,
|
||||
TriggeredBy: "git_status_event",
|
||||
Metadata: map[string]interface{}{
|
||||
"modified": data.Modified,
|
||||
"added": data.Added,
|
||||
"deleted": data.Deleted,
|
||||
"untracked": data.Untracked,
|
||||
"renamed": data.Renamed,
|
||||
"timestamp": data.Timestamp,
|
||||
},
|
||||
}
|
||||
|
||||
// Update session metadata with git status
|
||||
if session.Metadata == nil {
|
||||
session.Metadata = make(map[string]interface{})
|
||||
}
|
||||
session.Metadata["git_status"] = map[string]interface{}{
|
||||
"branch": data.Branch,
|
||||
"remote_branch": data.RemoteBranch,
|
||||
"modified": data.Modified,
|
||||
"added": data.Added,
|
||||
"deleted": data.Deleted,
|
||||
"untracked": data.Untracked,
|
||||
"renamed": data.Renamed,
|
||||
"ahead": data.Ahead,
|
||||
"behind": data.Behind,
|
||||
"files": data.Files,
|
||||
"timestamp": data.Timestamp,
|
||||
}
|
||||
|
||||
// Persist to database asynchronously
|
||||
// Persist snapshot to database asynchronously, but skip if duplicate of latest
|
||||
go func() {
|
||||
if err := s.repo.UpdateTaskSession(context.Background(), session); err != nil {
|
||||
s.logger.Error("failed to update agent session with git status",
|
||||
bgCtx := context.Background()
|
||||
|
||||
// Check if this is a duplicate of the latest snapshot
|
||||
latest, err := s.repo.GetLatestGitSnapshot(bgCtx, data.TaskSessionID)
|
||||
if err == nil && latest != nil {
|
||||
// Compare key fields to detect duplicates
|
||||
if s.isSnapshotDuplicate(latest, snapshot) {
|
||||
s.logger.Debug("skipping duplicate git snapshot",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.TaskSessionID))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.CreateGitSnapshot(bgCtx, snapshot); err != nil {
|
||||
s.logger.Error("failed to create git snapshot",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", session.ID),
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("persisted git status to agent session",
|
||||
s.logger.Debug("created git snapshot",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", session.ID))
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.String("snapshot_id", snapshot.ID))
|
||||
|
||||
// Publish event to notify frontend
|
||||
if s.eventBus != nil {
|
||||
event := bus.NewEvent(events.GitSnapshotCreated, "orchestrator", map[string]interface{}{
|
||||
"session_id": data.TaskSessionID,
|
||||
"snapshot": snapshot,
|
||||
})
|
||||
_ = s.eventBus.Publish(bgCtx, events.BuildGitSnapshotSubject(data.TaskSessionID), event)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// isSnapshotDuplicate checks if two snapshots have the same content
|
||||
func (s *Service) isSnapshotDuplicate(existing, new *models.GitSnapshot) bool {
|
||||
// Different snapshot types are never duplicates
|
||||
if existing.SnapshotType != new.SnapshotType {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare branch and commit info
|
||||
if existing.Branch != new.Branch ||
|
||||
existing.HeadCommit != new.HeadCommit ||
|
||||
existing.Ahead != new.Ahead ||
|
||||
existing.Behind != new.Behind {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare file counts first (quick check)
|
||||
existingFileCount := len(existing.Files)
|
||||
newFileCount := len(new.Files)
|
||||
if existingFileCount != newFileCount {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare file paths (if same count, check if same files)
|
||||
for path := range new.Files {
|
||||
if _, exists := existing.Files[path]; !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// handleContextWindowUpdated handles context window updates and persists them to session metadata
|
||||
func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.ContextWindowData) {
|
||||
s.logger.Debug("handling context window update",
|
||||
@@ -584,3 +637,68 @@ func (s *Service) handlePermissionRequest(ctx context.Context, data watcher.Perm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleGitCommitCreated handles git commit events by creating session commit records
|
||||
func (s *Service) handleGitCommitCreated(ctx context.Context, data watcher.GitCommitData) {
|
||||
s.logger.Debug("handling git commit created",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("commit_sha", data.CommitSHA))
|
||||
|
||||
if data.TaskSessionID == "" {
|
||||
s.logger.Debug("missing session_id for git commit event",
|
||||
zap.String("task_id", data.TaskID))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse committed_at timestamp
|
||||
var committedAt time.Time
|
||||
if data.CommittedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339Nano, data.CommittedAt); err == nil {
|
||||
committedAt = t
|
||||
} else {
|
||||
committedAt = time.Now().UTC()
|
||||
}
|
||||
} else {
|
||||
committedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
// Create session commit record
|
||||
commit := &models.SessionCommit{
|
||||
SessionID: data.TaskSessionID,
|
||||
CommitSHA: data.CommitSHA,
|
||||
ParentSHA: data.ParentSHA,
|
||||
AuthorName: data.AuthorName,
|
||||
AuthorEmail: data.AuthorEmail,
|
||||
CommitMessage: data.Message,
|
||||
CommittedAt: committedAt,
|
||||
FilesChanged: data.FilesChanged,
|
||||
Insertions: data.Insertions,
|
||||
Deletions: data.Deletions,
|
||||
}
|
||||
|
||||
// Persist commit record to database asynchronously
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if err := s.repo.CreateSessionCommit(bgCtx, commit); err != nil {
|
||||
s.logger.Error("failed to create session commit record",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.String("commit_sha", data.CommitSHA),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("created session commit record",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.String("commit_sha", data.CommitSHA))
|
||||
|
||||
// Publish event to notify frontend
|
||||
if s.eventBus != nil {
|
||||
event := bus.NewEvent(events.GitCommitRecorded, "orchestrator", map[string]interface{}{
|
||||
"session_id": data.TaskSessionID,
|
||||
"commit": commit,
|
||||
})
|
||||
_ = s.eventBus.Publish(bgCtx, events.BuildGitCommitRecordedSubject(data.TaskSessionID), event)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ func NewService(
|
||||
OnACPSessionCreated: s.handleACPSessionCreated,
|
||||
OnPermissionRequest: s.handlePermissionRequest,
|
||||
OnGitStatusUpdated: s.handleGitStatusUpdated,
|
||||
OnGitCommitCreated: s.handleGitCommitCreated,
|
||||
OnContextWindowUpdated: s.handleContextWindowUpdated,
|
||||
}
|
||||
s.watcher = watcher.NewWatcher(eventBus, handlers, log)
|
||||
|
||||
@@ -60,6 +60,8 @@ type GitStatusData struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Branch string `json:"branch"`
|
||||
RemoteBranch string `json:"remote_branch"`
|
||||
HeadCommit string `json:"head_commit"`
|
||||
BaseCommit string `json:"base_commit"`
|
||||
Modified []string `json:"modified"`
|
||||
Added []string `json:"added"`
|
||||
Deleted []string `json:"deleted"`
|
||||
@@ -71,6 +73,22 @@ type GitStatusData struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// GitCommitData contains data from git commit events
|
||||
type GitCommitData struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskSessionID string `json:"session_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
ParentSHA string `json:"parent_sha"`
|
||||
Message string `json:"message"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorEmail string `json:"author_email"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
Insertions int `json:"insertions"`
|
||||
Deletions int `json:"deletions"`
|
||||
CommittedAt string `json:"committed_at"`
|
||||
}
|
||||
|
||||
// ContextWindowData contains data from context window events
|
||||
type ContextWindowData struct {
|
||||
TaskID string `json:"task_id"`
|
||||
@@ -108,6 +126,9 @@ type EventHandlers struct {
|
||||
// Git status events
|
||||
OnGitStatusUpdated func(ctx context.Context, data GitStatusData)
|
||||
|
||||
// Git commit events
|
||||
OnGitCommitCreated func(ctx context.Context, data GitCommitData)
|
||||
|
||||
// Context window events
|
||||
OnContextWindowUpdated func(ctx context.Context, data ContextWindowData)
|
||||
}
|
||||
@@ -182,6 +203,12 @@ func (w *Watcher) Start(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Subscribe to git commit events
|
||||
if err := w.subscribeToGitCommitEvents(); err != nil {
|
||||
w.unsubscribeAll()
|
||||
return err
|
||||
}
|
||||
|
||||
// Subscribe to context window events
|
||||
if err := w.subscribeToContextWindowEvents(); err != nil {
|
||||
w.unsubscribeAll()
|
||||
@@ -506,6 +533,50 @@ func (w *Watcher) createGitStatusHandler() bus.EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeToGitCommitEvents subscribes to git commit events
|
||||
func (w *Watcher) subscribeToGitCommitEvents() error {
|
||||
if w.handlers.OnGitCommitCreated == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use wildcard to subscribe to all git commit events (git.commit.created.{session_id})
|
||||
subject := events.BuildGitCommitWildcardSubject()
|
||||
|
||||
// Use regular subscription (each instance needs all messages)
|
||||
sub, err := w.eventBus.Subscribe(subject, w.createGitCommitHandler())
|
||||
if err != nil {
|
||||
w.logger.Error("Failed to subscribe to git commit events",
|
||||
zap.String("subject", subject),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
w.subscriptions = append(w.subscriptions, sub)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createGitCommitHandler creates a bus.EventHandler for git commit events
|
||||
func (w *Watcher) createGitCommitHandler() bus.EventHandler {
|
||||
return func(ctx context.Context, event *bus.Event) error {
|
||||
var data GitCommitData
|
||||
if err := w.parseEventData(event.Data, &data); err != nil {
|
||||
w.logger.Error("Failed to parse git commit event data",
|
||||
zap.String("event_type", event.Type),
|
||||
zap.String("event_id", event.ID),
|
||||
zap.Error(err))
|
||||
return nil // Don't return error to continue processing other events
|
||||
}
|
||||
|
||||
w.logger.Debug("Handling git commit event",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("commit_sha", data.CommitSHA))
|
||||
|
||||
if w.handlers.OnGitCommitCreated != nil {
|
||||
w.handlers.OnGitCommitCreated(ctx, data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeToContextWindowEvents subscribes to context window events
|
||||
func (w *Watcher) subscribeToContextWindowEvents() error {
|
||||
if w.handlers.OnContextWindowUpdated == nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/dto"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
@@ -145,3 +146,18 @@ func (c *TaskController) UpdateTaskState(ctx context.Context, req dto.UpdateTask
|
||||
}
|
||||
return dto.FromTask(task), nil
|
||||
}
|
||||
|
||||
// GetGitSnapshots retrieves git snapshots for a session
|
||||
func (c *TaskController) GetGitSnapshots(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) {
|
||||
return c.service.GetGitSnapshots(ctx, sessionID, limit)
|
||||
}
|
||||
|
||||
// GetSessionCommits retrieves commits for a session
|
||||
func (c *TaskController) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) {
|
||||
return c.service.GetSessionCommits(ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetCumulativeDiff retrieves the cumulative diff from base commit to current HEAD
|
||||
func (c *TaskController) GetCumulativeDiff(ctx context.Context, sessionID string) (*models.CumulativeDiff, error) {
|
||||
return c.service.GetCumulativeDiff(ctx, sessionID)
|
||||
}
|
||||
|
||||
@@ -311,6 +311,31 @@ func (m *mockRepository) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Git Snapshot operations
|
||||
func (m *mockRepository) CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) GetGitSnapshotsBySession(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Session Commit operations
|
||||
func (m *mockRepository) CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service {
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{
|
||||
Level: "error",
|
||||
|
||||
@@ -61,6 +61,10 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) {
|
||||
dispatcher.RegisterFunc(ws.ActionTaskMove, h.wsMoveTask)
|
||||
dispatcher.RegisterFunc(ws.ActionTaskState, h.wsUpdateTaskState)
|
||||
dispatcher.RegisterFunc(ws.ActionTaskSessionList, h.wsListTaskSessions)
|
||||
// Git snapshot and commit handlers
|
||||
dispatcher.RegisterFunc(ws.ActionSessionGitSnapshots, h.wsGetGitSnapshots)
|
||||
dispatcher.RegisterFunc(ws.ActionSessionGitCommits, h.wsGetSessionCommits)
|
||||
dispatcher.RegisterFunc(ws.ActionSessionCumulativeDiff, h.wsGetCumulativeDiff)
|
||||
}
|
||||
|
||||
// HTTP handlers
|
||||
@@ -575,3 +579,80 @@ func (h *TaskHandlers) wsUpdateTaskState(ctx context.Context, msg *ws.Message) (
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
// Git Snapshot and Commit Handlers
|
||||
|
||||
type wsGetGitSnapshotsRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) wsGetGitSnapshots(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsGetGitSnapshotsRequest
|
||||
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)
|
||||
}
|
||||
|
||||
snapshots, err := h.controller.GetGitSnapshots(ctx, req.SessionID, req.Limit)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get git snapshots", zap.Error(err), zap.String("session_id", req.SessionID))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get git snapshots", nil)
|
||||
}
|
||||
|
||||
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
||||
"session_id": req.SessionID,
|
||||
"snapshots": snapshots,
|
||||
})
|
||||
}
|
||||
|
||||
type wsGetSessionCommitsRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) wsGetSessionCommits(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsGetSessionCommitsRequest
|
||||
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)
|
||||
}
|
||||
|
||||
commits, err := h.controller.GetSessionCommits(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get session commits", zap.Error(err), zap.String("session_id", req.SessionID))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get session commits", nil)
|
||||
}
|
||||
|
||||
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
||||
"session_id": req.SessionID,
|
||||
"commits": commits,
|
||||
})
|
||||
}
|
||||
|
||||
type wsGetCumulativeDiffRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) wsGetCumulativeDiff(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsGetCumulativeDiffRequest
|
||||
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)
|
||||
}
|
||||
|
||||
diff, err := h.controller.GetCumulativeDiff(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get cumulative diff", zap.Error(err), zap.String("session_id", req.SessionID))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get cumulative diff", nil)
|
||||
}
|
||||
|
||||
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
||||
"cumulative_diff": diff,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SnapshotType represents the type of git snapshot
|
||||
type SnapshotType string
|
||||
|
||||
const (
|
||||
// SnapshotTypeStatusUpdate is for regular git status updates
|
||||
SnapshotTypeStatusUpdate SnapshotType = "status_update"
|
||||
// SnapshotTypePreCommit is for snapshots taken before a commit
|
||||
SnapshotTypePreCommit SnapshotType = "pre_commit"
|
||||
// SnapshotTypePostCommit is for snapshots taken after a commit
|
||||
SnapshotTypePostCommit SnapshotType = "post_commit"
|
||||
// SnapshotTypePreStage is for snapshots taken before staging
|
||||
SnapshotTypePreStage SnapshotType = "pre_stage"
|
||||
// SnapshotTypePostStage is for snapshots taken after staging
|
||||
SnapshotTypePostStage SnapshotType = "post_stage"
|
||||
)
|
||||
|
||||
// GitSnapshot represents a git status snapshot at a specific point in time
|
||||
type GitSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
SnapshotType SnapshotType `json:"snapshot_type"`
|
||||
Branch string `json:"branch"`
|
||||
RemoteBranch string `json:"remote_branch"`
|
||||
HeadCommit string `json:"head_commit"`
|
||||
BaseCommit string `json:"base_commit"`
|
||||
Ahead int `json:"ahead"`
|
||||
Behind int `json:"behind"`
|
||||
Files map[string]interface{} `json:"files"` // FileInfo objects with diff content
|
||||
TriggeredBy string `json:"triggered_by"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SessionCommit represents a commit made during a task session
|
||||
type SessionCommit struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
ParentSHA string `json:"parent_sha"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorEmail string `json:"author_email"`
|
||||
CommitMessage string `json:"commit_message"`
|
||||
CommittedAt time.Time `json:"committed_at"`
|
||||
PreCommitSnapshotID string `json:"pre_commit_snapshot_id"`
|
||||
PostCommitSnapshotID string `json:"post_commit_snapshot_id"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
Insertions int `json:"insertions"`
|
||||
Deletions int `json:"deletions"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CumulativeDiff represents the cumulative diff from base commit to current HEAD
|
||||
type CumulativeDiff struct {
|
||||
SessionID string `json:"session_id"`
|
||||
BaseCommit string `json:"base_commit"`
|
||||
HeadCommit string `json:"head_commit"`
|
||||
TotalCommits int `json:"total_commits"`
|
||||
Files map[string]interface{} `json:"files"` // Cumulative file changes
|
||||
}
|
||||
|
||||
@@ -91,6 +91,17 @@ type Repository interface {
|
||||
DeleteTaskSessionWorktree(ctx context.Context, id string) error
|
||||
DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error
|
||||
|
||||
// Git Snapshot operations
|
||||
CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error
|
||||
GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error)
|
||||
GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error)
|
||||
GetGitSnapshotsBySession(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error)
|
||||
|
||||
// Session Commit operations
|
||||
CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error
|
||||
GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error)
|
||||
GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error)
|
||||
|
||||
// Repository operations
|
||||
CreateRepository(ctx context.Context, repository *models.Repository) error
|
||||
GetRepository(ctx context.Context, id string) (*models.Repository, error)
|
||||
|
||||
@@ -17,10 +17,7 @@ func NewWithDB(dbConn *sql.DB) (*Repository, error) {
|
||||
return newRepository(dbConn, false)
|
||||
}
|
||||
|
||||
// New creates a new SQLite repository with a database connection (takes ownership).
|
||||
func New(dbConn *sql.DB) (*Repository, error) {
|
||||
return newRepository(dbConn, true)
|
||||
}
|
||||
|
||||
|
||||
func newRepository(dbConn *sql.DB, ownsDB bool) (*Repository, error) {
|
||||
repo := &Repository{db: dbConn, ownsDB: ownsDB}
|
||||
@@ -360,6 +357,54 @@ func (r *Repository) initSchema() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Git tracking tables
|
||||
gitSchema := `
|
||||
CREATE TABLE IF NOT EXISTS task_session_git_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
snapshot_type TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
remote_branch TEXT DEFAULT '',
|
||||
head_commit TEXT DEFAULT '',
|
||||
base_commit TEXT DEFAULT '',
|
||||
ahead INTEGER DEFAULT 0,
|
||||
behind INTEGER DEFAULT 0,
|
||||
files TEXT DEFAULT '{}',
|
||||
triggered_by TEXT DEFAULT '',
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_git_snapshots_session ON task_session_git_snapshots(session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_git_snapshots_type ON task_session_git_snapshots(session_id, snapshot_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_session_commits (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
commit_sha TEXT NOT NULL,
|
||||
parent_sha TEXT DEFAULT '',
|
||||
author_name TEXT DEFAULT '',
|
||||
author_email TEXT DEFAULT '',
|
||||
commit_message TEXT DEFAULT '',
|
||||
committed_at DATETIME NOT NULL,
|
||||
pre_commit_snapshot_id TEXT DEFAULT '',
|
||||
post_commit_snapshot_id TEXT DEFAULT '',
|
||||
files_changed INTEGER DEFAULT 0,
|
||||
insertions INTEGER DEFAULT 0,
|
||||
deletions INTEGER DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_commits_session ON task_session_commits(session_id, committed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_commits_sha ON task_session_commits(commit_sha);
|
||||
`
|
||||
|
||||
if _, err := r.db.Exec(gitSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure columns exist for schema migrations
|
||||
if err := r.ensureColumn("boards", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// Package sqlite provides SQLite-based repository implementations.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
// CreateGitSnapshot inserts a new git snapshot into the database.
|
||||
func (r *Repository) CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error {
|
||||
if snapshot.ID == "" {
|
||||
snapshot.ID = uuid.New().String()
|
||||
}
|
||||
if snapshot.CreatedAt.IsZero() {
|
||||
snapshot.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
filesJSON := "{}"
|
||||
if snapshot.Files != nil {
|
||||
filesBytes, err := json.Marshal(snapshot.Files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize git snapshot files: %w", err)
|
||||
}
|
||||
filesJSON = string(filesBytes)
|
||||
}
|
||||
|
||||
metadataJSON := "{}"
|
||||
if snapshot.Metadata != nil {
|
||||
metadataBytes, err := json.Marshal(snapshot.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize git snapshot metadata: %w", err)
|
||||
}
|
||||
metadataJSON = string(metadataBytes)
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO task_session_git_snapshots (
|
||||
id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit,
|
||||
ahead, behind, files, triggered_by, metadata, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, snapshot.ID, snapshot.SessionID, string(snapshot.SnapshotType), snapshot.Branch,
|
||||
snapshot.RemoteBranch, snapshot.HeadCommit, snapshot.BaseCommit, snapshot.Ahead,
|
||||
snapshot.Behind, filesJSON, snapshot.TriggeredBy, metadataJSON, snapshot.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLatestGitSnapshot retrieves the most recent git snapshot for a session.
|
||||
// Returns sql.ErrNoRows if no snapshot is found.
|
||||
func (r *Repository) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) {
|
||||
snapshot := &models.GitSnapshot{}
|
||||
var snapshotType string
|
||||
var filesJSON string
|
||||
var metadataJSON string
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit,
|
||||
ahead, behind, files, triggered_by, metadata, created_at
|
||||
FROM task_session_git_snapshots
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, sessionID).Scan(
|
||||
&snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch,
|
||||
&snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit,
|
||||
&snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy,
|
||||
&metadataJSON, &snapshot.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot.SnapshotType = models.SnapshotType(snapshotType)
|
||||
if filesJSON != "" && filesJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(filesJSON), &snapshot.Files); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize git snapshot files: %w", err)
|
||||
}
|
||||
}
|
||||
if metadataJSON != "" && metadataJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &snapshot.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize git snapshot metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// GetFirstGitSnapshot retrieves the oldest git snapshot for a session (first one created).
|
||||
// Returns sql.ErrNoRows if no snapshot is found.
|
||||
func (r *Repository) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) {
|
||||
snapshot := &models.GitSnapshot{}
|
||||
var snapshotType string
|
||||
var filesJSON string
|
||||
var metadataJSON string
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit,
|
||||
ahead, behind, files, triggered_by, metadata, created_at
|
||||
FROM task_session_git_snapshots
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC LIMIT 1
|
||||
`, sessionID).Scan(
|
||||
&snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch,
|
||||
&snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit,
|
||||
&snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy,
|
||||
&metadataJSON, &snapshot.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot.SnapshotType = models.SnapshotType(snapshotType)
|
||||
if filesJSON != "" && filesJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(filesJSON), &snapshot.Files); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize git snapshot files: %w", err)
|
||||
}
|
||||
}
|
||||
if metadataJSON != "" && metadataJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &snapshot.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize git snapshot metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// GetGitSnapshotsBySession retrieves all git snapshots for a session, ordered by created_at descending.
|
||||
// If limit > 0, only that many snapshots are returned.
|
||||
// Returns an empty slice if no snapshots are found.
|
||||
func (r *Repository) GetGitSnapshotsBySession(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) {
|
||||
query := `
|
||||
SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit,
|
||||
ahead, behind, files, triggered_by, metadata, created_at
|
||||
FROM task_session_git_snapshots
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var result []*models.GitSnapshot
|
||||
for rows.Next() {
|
||||
snapshot := &models.GitSnapshot{}
|
||||
var snapshotType string
|
||||
var filesJSON string
|
||||
var metadataJSON string
|
||||
|
||||
err := rows.Scan(
|
||||
&snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch,
|
||||
&snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit,
|
||||
&snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy,
|
||||
&metadataJSON, &snapshot.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot.SnapshotType = models.SnapshotType(snapshotType)
|
||||
if filesJSON != "" && filesJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(filesJSON), &snapshot.Files); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize git snapshot files: %w", err)
|
||||
}
|
||||
}
|
||||
if metadataJSON != "" && metadataJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &snapshot.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize git snapshot metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateSessionCommit inserts a new commit record into the database.
|
||||
func (r *Repository) CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error {
|
||||
if commit.ID == "" {
|
||||
commit.ID = uuid.New().String()
|
||||
}
|
||||
if commit.CreatedAt.IsZero() {
|
||||
commit.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO task_session_commits (
|
||||
id, session_id, commit_sha, parent_sha, author_name, author_email,
|
||||
commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id,
|
||||
files_changed, insertions, deletions, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, commit.ID, commit.SessionID, commit.CommitSHA, commit.ParentSHA,
|
||||
commit.AuthorName, commit.AuthorEmail, commit.CommitMessage, commit.CommittedAt,
|
||||
commit.PreCommitSnapshotID, commit.PostCommitSnapshotID, commit.FilesChanged,
|
||||
commit.Insertions, commit.Deletions, commit.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSessionCommits retrieves all commits for a session, ordered by committed_at descending.
|
||||
// Returns an empty slice if no commits are found.
|
||||
func (r *Repository) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, session_id, commit_sha, parent_sha, author_name, author_email,
|
||||
commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id,
|
||||
files_changed, insertions, deletions, created_at
|
||||
FROM task_session_commits
|
||||
WHERE session_id = ?
|
||||
ORDER BY committed_at DESC
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var result []*models.SessionCommit
|
||||
for rows.Next() {
|
||||
commit := &models.SessionCommit{}
|
||||
err := rows.Scan(
|
||||
&commit.ID, &commit.SessionID, &commit.CommitSHA, &commit.ParentSHA,
|
||||
&commit.AuthorName, &commit.AuthorEmail, &commit.CommitMessage,
|
||||
&commit.CommittedAt, &commit.PreCommitSnapshotID, &commit.PostCommitSnapshotID,
|
||||
&commit.FilesChanged, &commit.Insertions, &commit.Deletions, &commit.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, commit)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLatestSessionCommit retrieves the most recent commit for a session.
|
||||
// Returns sql.ErrNoRows if no commit is found.
|
||||
func (r *Repository) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) {
|
||||
commit := &models.SessionCommit{}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, session_id, commit_sha, parent_sha, author_name, author_email,
|
||||
commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id,
|
||||
files_changed, insertions, deletions, created_at
|
||||
FROM task_session_commits
|
||||
WHERE session_id = ?
|
||||
ORDER BY committed_at DESC LIMIT 1
|
||||
`, sessionID).Scan(
|
||||
&commit.ID, &commit.SessionID, &commit.CommitSHA, &commit.ParentSHA,
|
||||
&commit.AuthorName, &commit.AuthorEmail, &commit.CommitMessage,
|
||||
&commit.CommittedAt, &commit.PreCommitSnapshotID, &commit.PostCommitSnapshotID,
|
||||
&commit.FilesChanged, &commit.Insertions, &commit.Deletions, &commit.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
@@ -2121,3 +2121,58 @@ func (s *Service) publishRepositoryScriptEvent(ctx context.Context, eventType st
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// GetGitSnapshots retrieves git snapshots for a session
|
||||
func (s *Service) GetGitSnapshots(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) {
|
||||
return s.repo.GetGitSnapshotsBySession(ctx, sessionID, limit)
|
||||
}
|
||||
|
||||
// GetLatestGitSnapshot retrieves the latest git snapshot for a session
|
||||
func (s *Service) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) {
|
||||
return s.repo.GetLatestGitSnapshot(ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetFirstGitSnapshot retrieves the first git snapshot for a session (oldest)
|
||||
func (s *Service) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) {
|
||||
return s.repo.GetFirstGitSnapshot(ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetSessionCommits retrieves commits for a session
|
||||
func (s *Service) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) {
|
||||
return s.repo.GetSessionCommits(ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetLatestSessionCommit retrieves the latest commit for a session
|
||||
func (s *Service) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) {
|
||||
return s.repo.GetLatestSessionCommit(ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetCumulativeDiff computes the cumulative diff from base commit to current HEAD
|
||||
// by using the first snapshot's base_commit and the latest snapshot's files
|
||||
func (s *Service) GetCumulativeDiff(ctx context.Context, sessionID string) (*models.CumulativeDiff, error) {
|
||||
// Get the first snapshot to find the base commit
|
||||
firstSnapshot, err := s.repo.GetFirstGitSnapshot(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get first git snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Get the latest snapshot for current state
|
||||
latestSnapshot, err := s.repo.GetLatestGitSnapshot(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get latest git snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Count total commits for this session
|
||||
commits, err := s.repo.GetSessionCommits(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get session commits: %w", err)
|
||||
}
|
||||
|
||||
return &models.CumulativeDiff{
|
||||
SessionID: sessionID,
|
||||
BaseCommit: firstSnapshot.BaseCommit,
|
||||
HeadCommit: latestSnapshot.HeadCommit,
|
||||
TotalCommits: len(commits),
|
||||
Files: latestSnapshot.Files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -167,7 +167,13 @@ const (
|
||||
ActionSessionShellOutput = "session.shell.output" // Shell output notification (also used for exit with type: "exit")
|
||||
|
||||
// Session git status
|
||||
ActionSessionGitStatus = "session.git.status"
|
||||
ActionSessionGitStatus = "session.git.status"
|
||||
ActionSessionGitSnapshots = "session.git.snapshots" // Get git snapshots for a session
|
||||
ActionSessionGitCommits = "session.git.commits" // Get commits for a session
|
||||
ActionSessionCumulativeDiff = "session.cumulative_diff" // Get cumulative diff from base branch
|
||||
ActionSessionCommitDiff = "session.commit_diff" // Get diff for a specific commit
|
||||
ActionSessionGitSnapshot = "session.git.snapshot" // Notification: new git snapshot created
|
||||
ActionSessionGitCommit = "session.git.commit" // Notification: new session commit recorded
|
||||
|
||||
// Process runner actions
|
||||
ActionSessionProcessOutput = "session.process.output"
|
||||
|
||||
@@ -12,37 +12,39 @@ import { FILE_EXTENSION_COLORS } from '@/lib/types/backend';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
|
||||
|
||||
import type { SelectedDiff } from './task-layout';
|
||||
|
||||
type TaskCenterPanelProps = {
|
||||
selectedDiffPath: string | null;
|
||||
selectedDiff: SelectedDiff | null;
|
||||
openFileRequest: OpenFileTab | null;
|
||||
onDiffPathHandled: () => void;
|
||||
onDiffHandled: () => void;
|
||||
onFileOpenHandled: () => void;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
selectedDiffPath: externalSelectedDiffPath,
|
||||
selectedDiff: externalSelectedDiff,
|
||||
openFileRequest,
|
||||
onDiffPathHandled,
|
||||
onDiffHandled,
|
||||
onFileOpenHandled,
|
||||
sessionId = null,
|
||||
}: TaskCenterPanelProps) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const [leftTab, setLeftTab] = useState('chat');
|
||||
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
|
||||
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [openFileTabs, setOpenFileTabs] = useState<OpenFileTab[]>([]);
|
||||
|
||||
// Handle external diff path selection
|
||||
// Handle external diff selection
|
||||
useEffect(() => {
|
||||
if (externalSelectedDiffPath) {
|
||||
if (externalSelectedDiff) {
|
||||
queueMicrotask(() => {
|
||||
setSelectedDiffPath(externalSelectedDiffPath);
|
||||
setSelectedDiff(externalSelectedDiff);
|
||||
setLeftTab('changes');
|
||||
onDiffPathHandled();
|
||||
onDiffHandled();
|
||||
});
|
||||
}
|
||||
}, [externalSelectedDiffPath, onDiffPathHandled]);
|
||||
}, [externalSelectedDiff, onDiffHandled]);
|
||||
|
||||
// Handle external file open request
|
||||
useEffect(() => {
|
||||
@@ -119,8 +121,8 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
|
||||
<TabsContent value="changes" className="flex-1 min-h-0">
|
||||
<TaskChangesPanel
|
||||
selectedDiffPath={selectedDiffPath}
|
||||
onClearSelected={() => setSelectedDiffPath(null)}
|
||||
selectedDiff={selectedDiff}
|
||||
onClearSelected={() => setSelectedDiff(null)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
|
||||
import { IconArrowBackUp, IconCopy, IconLayoutColumns, IconLayoutRows } from '@tabler/icons-react';
|
||||
import {
|
||||
IconArrowBackUp,
|
||||
IconCopy,
|
||||
IconLayoutColumns,
|
||||
IconLayoutRows,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
@@ -12,49 +17,129 @@ import { cn } from '@/lib/utils';
|
||||
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
|
||||
import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff';
|
||||
import type { FileInfo } from '@/lib/state/slices';
|
||||
import type { SelectedDiff } from './task-layout';
|
||||
|
||||
type TaskChangesPanelProps = {
|
||||
selectedDiffPath: string | null;
|
||||
selectedDiff: SelectedDiff | null;
|
||||
onClearSelected: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_DIFF_MODE: 'unified' | 'split' = 'unified';
|
||||
|
||||
/**
|
||||
* Normalize diff content for the @git-diff-view/react library.
|
||||
*
|
||||
* Handles two edge cases from the backend:
|
||||
* 1. Multiple diffs concatenated with "--- Staged changes ---" separator
|
||||
* (we take the staged diff as it's what matters for the commit)
|
||||
* 2. Diffs missing the "diff --git" header line (for untracked files)
|
||||
*
|
||||
* The library expects each hunk to be a complete diff string with headers.
|
||||
*/
|
||||
function parseDiffIntoHunks(diffContent: string, filePath?: string): string[] {
|
||||
if (!diffContent || typeof diffContent !== 'string') return [];
|
||||
|
||||
let trimmed = diffContent.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
// Handle concatenated diffs with "--- Staged changes ---" separator
|
||||
// Take the staged diff (after the separator) as it's the authoritative one
|
||||
const stagedSeparator = '--- Staged changes ---';
|
||||
if (trimmed.includes(stagedSeparator)) {
|
||||
const parts = trimmed.split(stagedSeparator);
|
||||
// Use the staged diff (second part) if available
|
||||
trimmed = (parts[1] || parts[0]).trim();
|
||||
}
|
||||
|
||||
// If diff is missing the "diff --git" header, add it
|
||||
// This happens for untracked files where backend generates incomplete diff
|
||||
if (!trimmed.startsWith('diff --git') && trimmed.includes('@@')) {
|
||||
const path = filePath || 'file';
|
||||
const header = `diff --git a/${path} b/${path}\nnew file mode 100644\nindex 0000000..0000000\n`;
|
||||
trimmed = header + trimmed;
|
||||
}
|
||||
|
||||
// Return as single hunk - the library handles parsing internally
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
selectedDiffPath,
|
||||
selectedDiff,
|
||||
onClearSelected,
|
||||
}: TaskChangesPanelProps) {
|
||||
const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(() =>
|
||||
getLocalStorage('task-diff-view-mode', DEFAULT_DIFF_MODE)
|
||||
);
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const gitStatus = useSessionGitStatus(activeSessionId);
|
||||
// Fetch cumulative diff (all commits combined) when no specific file is selected
|
||||
const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(
|
||||
selectedDiff ? null : activeSessionId
|
||||
);
|
||||
|
||||
// Convert git status files to array for display
|
||||
const changedFiles = useMemo<FileInfo[]>(() => {
|
||||
const selectedDiffPath = selectedDiff?.path ?? null;
|
||||
// Use provided diff content (from commit or snapshot) or look up from current git status
|
||||
const hasProvidedContent = Boolean(selectedDiff?.content);
|
||||
|
||||
// Convert git status files to array for display (uncommitted changes)
|
||||
const uncommittedFiles = useMemo<FileInfo[]>(() => {
|
||||
if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Object.values(gitStatus.files) as FileInfo[];
|
||||
}, [gitStatus]);
|
||||
const diffTargets = useMemo(
|
||||
() => (selectedDiffPath ? [selectedDiffPath] : changedFiles.map((file: FileInfo) => file.path)),
|
||||
[selectedDiffPath, changedFiles]
|
||||
);
|
||||
|
||||
// Convert cumulative diff files to array (committed changes)
|
||||
const committedFiles = useMemo<Array<{ path: string; diff: string; additions: number; deletions: number }>>(() => {
|
||||
if (!cumulativeDiff?.files || Object.keys(cumulativeDiff.files).length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(cumulativeDiff.files)
|
||||
.filter(([, file]) => file.diff) // Only include files with diffs
|
||||
.map(([path, file]) => ({
|
||||
path,
|
||||
diff: file.diff!,
|
||||
additions: file.additions ?? 0,
|
||||
deletions: file.deletions ?? 0,
|
||||
}));
|
||||
}, [cumulativeDiff]);
|
||||
|
||||
const diffModeEnum = diffViewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified;
|
||||
const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
|
||||
const selectedFile = selectedDiffPath && gitStatus ? gitStatus.files[selectedDiffPath] : null;
|
||||
const selectedDiffContent = selectedFile?.diff ?? '';
|
||||
const isSingleDiffSelected = Boolean(selectedDiffPath && selectedFile);
|
||||
const selectedFile = selectedDiffPath && gitStatus?.files ? gitStatus.files[selectedDiffPath] : null;
|
||||
// Use provided content if available, otherwise fall back to git status
|
||||
const selectedDiffContent = selectedDiff?.content ?? selectedFile?.diff ?? '';
|
||||
const isSingleDiffSelected = Boolean(selectedDiffPath && (hasProvidedContent || selectedFile));
|
||||
|
||||
// Helper to get file language from extension
|
||||
const getFileLang = (path: string) => {
|
||||
const ext = path.split('.').pop() || '';
|
||||
const langMap: Record<string, string> = {
|
||||
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
||||
py: 'python', go: 'go', rs: 'rust', java: 'java',
|
||||
cpp: 'cpp', c: 'c', css: 'css', html: 'html',
|
||||
json: 'json', md: 'markdown', yaml: 'yaml', yml: 'yaml',
|
||||
};
|
||||
return langMap[ext] || 'plaintext';
|
||||
};
|
||||
|
||||
const hasUncommitted = uncommittedFiles.length > 0;
|
||||
const hasCommitted = committedFiles.length > 0;
|
||||
const hasAnyChanges = hasUncommitted || hasCommitted;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Header toolbar */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
{selectedDiffPath ?? 'All files'}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
{selectedDiffPath ?? 'All files'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{selectedDiffPath && (
|
||||
<Tooltip>
|
||||
@@ -66,10 +151,10 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
className="h-7 px-2 text-xs cursor-pointer"
|
||||
onClick={onClearSelected}
|
||||
>
|
||||
All changes
|
||||
Clear
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Show all changes</TooltipContent>
|
||||
<TooltipContent>Show all uncommitted changes</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
@@ -90,7 +175,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy file contents</TooltipContent>
|
||||
<TooltipContent>Copy diff</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -156,62 +241,103 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<SessionPanelContent>
|
||||
{changedFiles.length === 0 ? (
|
||||
{isSingleDiffSelected && selectedDiffContent ? (
|
||||
// Render single file diff (from commit, snapshot, or uncommitted)
|
||||
<div className="space-y-4">
|
||||
<DiffView
|
||||
data={{
|
||||
hunks: parseDiffIntoHunks(selectedDiffContent, selectedDiffPath!),
|
||||
oldFile: { fileName: selectedDiffPath!, fileLang: getFileLang(selectedDiffPath!) },
|
||||
newFile: { fileName: selectedDiffPath!, fileLang: getFileLang(selectedDiffPath!) },
|
||||
}}
|
||||
diffViewMode={diffModeEnum}
|
||||
diffViewTheme={diffTheme}
|
||||
/>
|
||||
</div>
|
||||
) : cumulativeLoading ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No changes detected
|
||||
Loading changes...
|
||||
</div>
|
||||
) : !hasAnyChanges ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No changes
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{diffTargets.map((path) => {
|
||||
const file = gitStatus?.files?.[path];
|
||||
if (!file || !file.diff) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detect file language from extension
|
||||
const ext = path.split('.').pop() || '';
|
||||
const langMap: Record<string, string> = {
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
py: 'python',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
c: 'c',
|
||||
css: 'css',
|
||||
html: 'html',
|
||||
json: 'json',
|
||||
md: 'markdown',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
};
|
||||
const fileLang = langMap[ext] || 'plaintext';
|
||||
|
||||
return (
|
||||
<div key={path} className="space-y-2">
|
||||
{!selectedDiffPath && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
{path}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<DiffView
|
||||
data={{
|
||||
hunks: [file.diff],
|
||||
oldFile: { fileName: path, fileLang },
|
||||
newFile: { fileName: path, fileLang },
|
||||
}}
|
||||
diffViewMode={diffModeEnum}
|
||||
diffViewTheme={diffTheme}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{/* Uncommitted changes section */}
|
||||
{hasUncommitted && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Uncommitted ({uncommittedFiles.length})
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{uncommittedFiles.map((file) => {
|
||||
if (!file.diff) return null;
|
||||
const fileLang = getFileLang(file.path);
|
||||
return (
|
||||
<div key={`uncommitted-${file.path}`} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
{file.path}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-emerald-500">+{file.additions}</span>
|
||||
{' / '}
|
||||
<span className="text-rose-500">-{file.deletions}</span>
|
||||
</span>
|
||||
</div>
|
||||
<DiffView
|
||||
data={{
|
||||
hunks: parseDiffIntoHunks(file.diff, file.path),
|
||||
oldFile: { fileName: file.path, fileLang },
|
||||
newFile: { fileName: file.path, fileLang },
|
||||
}}
|
||||
diffViewMode={diffModeEnum}
|
||||
diffViewTheme={diffTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Committed changes section */}
|
||||
{hasCommitted && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Committed ({committedFiles.length})
|
||||
</div>
|
||||
{committedFiles.map((file) => {
|
||||
if (!file.diff) return null;
|
||||
const fileLang = getFileLang(file.path);
|
||||
return (
|
||||
<div key={`committed-${file.path}`} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
{file.path}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-emerald-500">+{file.additions}</span>
|
||||
{' / '}
|
||||
<span className="text-rose-500">-{file.deletions}</span>
|
||||
</span>
|
||||
</div>
|
||||
<DiffView
|
||||
data={{
|
||||
hunks: parseDiffIntoHunks(file.diff, file.path),
|
||||
oldFile: { fileName: file.path, fileLang },
|
||||
newFile: { fileName: file.path, fileLang },
|
||||
}}
|
||||
diffViewMode={diffModeEnum}
|
||||
diffViewTheme={diffTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SessionPanelContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { memo, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
IconArrowBackUp,
|
||||
IconExternalLink,
|
||||
@@ -8,19 +8,26 @@ import {
|
||||
IconCircleFilled,
|
||||
IconMinus,
|
||||
IconCheck,
|
||||
IconChevronRight,
|
||||
IconGitCommit,
|
||||
IconLoader2,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { TabsContent } from '@kandev/ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session';
|
||||
import { LineStat } from '@/components/diff-stat';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor';
|
||||
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
|
||||
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import type { FileInfo } from '@/lib/state/store';
|
||||
import { FileBrowser } from '@/components/task/file-browser';
|
||||
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
|
||||
type OpenFileTab = {
|
||||
path: string;
|
||||
@@ -29,7 +36,7 @@ type OpenFileTab = {
|
||||
};
|
||||
|
||||
type TaskFilesPanelProps = {
|
||||
onSelectDiffPath: (path: string) => void;
|
||||
onSelectDiff: (path: string, content?: string) => void;
|
||||
onOpenFile: (file: OpenFileTab) => void;
|
||||
};
|
||||
|
||||
@@ -80,12 +87,56 @@ const splitPath = (path: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFile }: TaskFilesPanelProps) {
|
||||
const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }: TaskFilesPanelProps) {
|
||||
const [topTab, setTopTab] = useState<'diff' | 'files'>('diff');
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const gitStatus = useSessionGitStatus(activeSessionId);
|
||||
const { commits } = useSessionCommits(activeSessionId ?? null);
|
||||
const openEditor = useOpenSessionInEditor(activeSessionId ?? null);
|
||||
const gitOps = useGitOperations(activeSessionId ?? null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// State for commit diffs
|
||||
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
|
||||
const [commitDiffs, setCommitDiffs] = useState<Record<string, Record<string, FileInfo>>>({});
|
||||
const [loadingCommitSha, setLoadingCommitSha] = useState<string | null>(null);
|
||||
|
||||
// Fetch commit diff
|
||||
const fetchCommitDiff = useCallback(async (commitSha: string) => {
|
||||
if (!activeSessionId || commitDiffs[commitSha]) return;
|
||||
|
||||
setLoadingCommitSha(commitSha);
|
||||
try {
|
||||
const ws = getWebSocketClient();
|
||||
if (!ws) return;
|
||||
const response = await ws.request<{ success: boolean; files: Record<string, FileInfo> }>(
|
||||
'session.commit_diff',
|
||||
{ session_id: activeSessionId, commit_sha: commitSha },
|
||||
10000
|
||||
);
|
||||
if (response?.success && response.files) {
|
||||
setCommitDiffs((prev) => ({ ...prev, [commitSha]: response.files }));
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to load commit diff',
|
||||
description: err instanceof Error ? err.message : 'An unexpected error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setLoadingCommitSha(null);
|
||||
}
|
||||
}, [activeSessionId, commitDiffs, toast]);
|
||||
|
||||
// Toggle commit expansion
|
||||
const toggleCommit = useCallback((commitSha: string) => {
|
||||
if (expandedCommit === commitSha) {
|
||||
setExpandedCommit(null);
|
||||
} else {
|
||||
setExpandedCommit(commitSha);
|
||||
void fetchCommitDiff(commitSha);
|
||||
}
|
||||
}, [expandedCommit, fetchCommitDiff]);
|
||||
|
||||
// Convert git status files to array for display
|
||||
const changedFiles = useMemo(() => {
|
||||
@@ -123,101 +174,179 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi
|
||||
>
|
||||
<TabsContent value="diff" className="flex-1 min-h-0">
|
||||
<SessionPanelContent>
|
||||
{changedFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No changes detected
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{changedFiles.map((file) => {
|
||||
const { folder, file: name } = splitPath(file.path);
|
||||
return (
|
||||
<li
|
||||
key={file.path}
|
||||
className="group flex items-center justify-between gap-3 text-sm rounded-md px-1 py-0.5 -mx-1 hover:bg-muted/60 cursor-pointer"
|
||||
onClick={() => onSelectDiffPath(file.path)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Staged indicator */}
|
||||
{file.staged ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center h-4 w-4 rounded bg-emerald-500/20 text-emerald-600">
|
||||
<IconCheck className="h-3 w-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Staged for commit</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center h-4 w-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void gitOps.stage([file.path]);
|
||||
}}
|
||||
>
|
||||
<IconPlus className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stage file</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button type="button" className="min-w-0 text-left cursor-pointer">
|
||||
<p className="truncate text-foreground">
|
||||
<span className="text-foreground/60">{folder}/</span>
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
</p>
|
||||
{file.oldPath && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Renamed from: {file.oldPath}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Uncommitted changes section */}
|
||||
{changedFiles.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Uncommitted ({changedFiles.length})
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{changedFiles.map((file) => {
|
||||
const { folder, file: name } = splitPath(file.path);
|
||||
return (
|
||||
<li
|
||||
key={file.path}
|
||||
className="group flex items-center justify-between gap-2 text-sm rounded-md px-1 py-0.5 -mx-1 hover:bg-muted/60 cursor-pointer"
|
||||
onClick={() => onSelectDiff(file.path)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{file.staged ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center h-4 w-4 rounded bg-emerald-500/20 text-emerald-600">
|
||||
<IconCheck className="h-3 w-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Staged for commit</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center h-4 w-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void gitOps.stage([file.path]);
|
||||
}}
|
||||
>
|
||||
<IconPlus className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stage file</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button type="button" className="min-w-0 text-left cursor-pointer">
|
||||
<p className="truncate text-foreground text-xs">
|
||||
<span className="text-foreground/60">{folder}/</span>
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconArrowBackUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Discard changes</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!activeSessionId) return;
|
||||
void openEditor.open({ filePath: file.path });
|
||||
}}
|
||||
>
|
||||
<IconExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open in editor</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<LineStat added={file.plus} removed={file.minus} />
|
||||
<StatusIcon status={file.status} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commits section */}
|
||||
{commits.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Commits ({commits.length})
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{commits.map((commit) => {
|
||||
const isExpanded = expandedCommit === commit.commit_sha;
|
||||
const files = commitDiffs[commit.commit_sha];
|
||||
const isLoading = loadingCommitSha === commit.commit_sha;
|
||||
return (
|
||||
<li key={commit.commit_sha}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 text-left rounded-md px-1 py-1 -mx-1 hover:bg-muted/60 cursor-pointer"
|
||||
onClick={() => toggleCommit(commit.commit_sha)}
|
||||
>
|
||||
<IconChevronRight
|
||||
className={cn(
|
||||
'h-3 w-3 text-muted-foreground transition-transform shrink-0',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<IconGitCommit className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs truncate">
|
||||
<code className="font-mono text-muted-foreground">{commit.commit_sha.slice(0, 7)}</code>
|
||||
{' '}
|
||||
<span className="text-foreground">{commit.commit_message}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs shrink-0">
|
||||
<span className="text-emerald-500">+{commit.insertions}</span>
|
||||
{'/'}
|
||||
<span className="text-rose-500">-{commit.deletions}</span>
|
||||
</span>
|
||||
{isLoading && (
|
||||
<IconLoader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && files && (
|
||||
<ul className="ml-6 mt-1 space-y-0.5">
|
||||
{Object.entries(files).map(([path, file]) => {
|
||||
const { folder, file: fileName } = splitPath(path);
|
||||
return (
|
||||
<li
|
||||
key={path}
|
||||
className="flex items-center gap-2 text-xs rounded px-1 py-0.5 hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => onSelectDiff(path, file.diff)}
|
||||
>
|
||||
<StatusIcon status={file.status} />
|
||||
<span className="truncate flex-1">
|
||||
<span className="text-foreground/60">{folder}/</span>
|
||||
<span className="text-foreground">{fileName}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs">
|
||||
<span className="text-emerald-500">+{file.additions ?? 0}</span>
|
||||
{'/'}
|
||||
<span className="text-rose-500">-{file.deletions ?? 0}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconArrowBackUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Discard changes</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!activeSessionId) return;
|
||||
void openEditor.open({ filePath: file.path });
|
||||
}}
|
||||
>
|
||||
<IconExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open in editor</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<LineStat added={file.plus} removed={file.minus} />
|
||||
<StatusIcon status={file.status} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{changedFiles.length === 0 && commits.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No changes detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SessionPanelContent>
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="mt-3 flex-1 min-h-0">
|
||||
|
||||
@@ -34,6 +34,11 @@ type TaskLayoutProps = {
|
||||
defaultLayouts?: Record<string, Layout>;
|
||||
};
|
||||
|
||||
export type SelectedDiff = {
|
||||
path: string;
|
||||
content?: string; // Optional: if provided, use this instead of looking up from git status
|
||||
};
|
||||
|
||||
export const TaskLayout = memo(function TaskLayout({
|
||||
workspaceId,
|
||||
boardId,
|
||||
@@ -41,7 +46,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
repository = null,
|
||||
defaultLayouts = {},
|
||||
}: TaskLayoutProps) {
|
||||
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
|
||||
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(null);
|
||||
const [openFileRequest, setOpenFileRequest] = useState<OpenFileTab | null>(null);
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const layoutBySession = useLayoutStore((state) => state.columnsBySessionId);
|
||||
@@ -52,8 +57,8 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
);
|
||||
const hasDevScript = Boolean(repository?.dev_script?.trim());
|
||||
|
||||
const handleSelectDiffPath = (path: string) => {
|
||||
setSelectedDiffPath(path);
|
||||
const handleSelectDiff = (path: string, content?: string) => {
|
||||
setSelectedDiff({ path, content });
|
||||
};
|
||||
|
||||
const handleOpenFile = (file: OpenFileTab) => {
|
||||
@@ -62,7 +67,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
|
||||
const topFilesPanel = (
|
||||
<TaskFilesPanel
|
||||
onSelectDiffPath={handleSelectDiffPath}
|
||||
onSelectDiff={handleSelectDiff}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
);
|
||||
@@ -127,9 +132,9 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
>
|
||||
<Panel id="chat" minSize="400px" className="min-h-0 min-w-0">
|
||||
<TaskCenterPanel
|
||||
selectedDiffPath={selectedDiffPath}
|
||||
selectedDiff={selectedDiff}
|
||||
openFileRequest={openFileRequest}
|
||||
onDiffPathHandled={() => setSelectedDiffPath(null)}
|
||||
onDiffHandled={() => setSelectedDiff(null)}
|
||||
onFileOpenHandled={() => setOpenFileRequest(null)}
|
||||
/>
|
||||
</Panel>
|
||||
@@ -141,9 +146,9 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
) : (
|
||||
<Panel id="chat" minSize="400px" className="min-h-0 min-w-0">
|
||||
<TaskCenterPanel
|
||||
selectedDiffPath={selectedDiffPath}
|
||||
selectedDiff={selectedDiff}
|
||||
openFileRequest={openFileRequest}
|
||||
onDiffPathHandled={() => setSelectedDiffPath(null)}
|
||||
onDiffHandled={() => setSelectedDiff(null)}
|
||||
onFileOpenHandled={() => setOpenFileRequest(null)}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { CommitStatBadge, LineStat } from '@/components/diff-stat';
|
||||
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
|
||||
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import type { FileInfo } from '@/lib/state/slices';
|
||||
import { formatUserHomePath } from '@/lib/utils';
|
||||
@@ -88,21 +89,30 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
|
||||
const { toast } = useToast();
|
||||
const gitStatus = useSessionGitStatus(activeSessionId ?? null);
|
||||
const { commits } = useSessionCommits(activeSessionId ?? null);
|
||||
const { pull, push, rebase, merge, commit, createPR, isLoading: isGitLoading } = useGitOperations(activeSessionId ?? null);
|
||||
|
||||
// Use worktree branch if available, otherwise fall back to base branch
|
||||
const displayBranch = worktreeBranch || baseBranch;
|
||||
|
||||
// Calculate total additions and deletions from all files.
|
||||
let totalAdditions = 0;
|
||||
let totalDeletions = 0;
|
||||
// Calculate total additions and deletions from uncommitted files.
|
||||
let uncommittedAdditions = 0;
|
||||
let uncommittedDeletions = 0;
|
||||
if (gitStatus?.files && Object.keys(gitStatus.files).length > 0) {
|
||||
for (const file of Object.values(gitStatus.files) as FileInfo[]) {
|
||||
totalAdditions += file.additions || 0;
|
||||
totalDeletions += file.deletions || 0;
|
||||
uncommittedAdditions += file.additions || 0;
|
||||
uncommittedDeletions += file.deletions || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate cumulative additions and deletions from commits.
|
||||
const commitAdditions = commits.reduce((sum, c) => sum + c.insertions, 0);
|
||||
const commitDeletions = commits.reduce((sum, c) => sum + c.deletions, 0);
|
||||
|
||||
// Combined total (uncommitted + commits)
|
||||
const totalAdditions = uncommittedAdditions + commitAdditions;
|
||||
const totalDeletions = uncommittedDeletions + commitDeletions;
|
||||
|
||||
const handleGitOperation = useCallback(async (
|
||||
operation: () => Promise<{ success: boolean; output: string; error?: string; conflict_files?: string[] }>,
|
||||
operationName: string
|
||||
@@ -121,9 +131,7 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
description: result.error || 'An error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
if (result.conflict_files && result.conflict_files.length > 0) {
|
||||
console.log('Conflict files:', result.conflict_files);
|
||||
}
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -358,7 +366,7 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Git Status: Lines Changed */}
|
||||
{/* Git Status: Total Lines Changed (uncommitted + commits) */}
|
||||
{(totalAdditions > 0 || totalDeletions > 0) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -366,7 +374,9 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
<LineStat added={totalAdditions} removed={totalDeletions} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Lines changed</TooltipContent>
|
||||
<TooltipContent>
|
||||
Total changes{commits.length > 0 ? ` (${commits.length} commit${commits.length !== 1 ? 's' : ''}${uncommittedAdditions > 0 || uncommittedDeletions > 0 ? ' + uncommitted' : ''})` : ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -494,17 +504,18 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
<IconCopy className="h-4 w-4" />
|
||||
Copy workspace path
|
||||
</DropdownMenuItem>
|
||||
{/* TODO: Implement open workspace folder
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
// TODO: Implement open workspace folder
|
||||
console.log('Open workspace folder:', worktreePath);
|
||||
// Open workspace folder in system file manager
|
||||
}}
|
||||
disabled={!worktreePath}
|
||||
>
|
||||
<IconFolderOpen className="h-4 w-4" />
|
||||
Open workspace folder
|
||||
</DropdownMenuItem>
|
||||
*/}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -523,11 +534,11 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
{gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? (
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{Object.keys(gitStatus.files).length}</span> file{Object.keys(gitStatus.files).length !== 1 ? 's' : ''} changed
|
||||
{(totalAdditions > 0 || totalDeletions > 0) && (
|
||||
{(uncommittedAdditions > 0 || uncommittedDeletions > 0) && (
|
||||
<span className="ml-2">
|
||||
(<span className="text-green-600">+{totalAdditions}</span>
|
||||
(<span className="text-green-600">+{uncommittedAdditions}</span>
|
||||
{' / '}
|
||||
<span className="text-red-600">-{totalDeletions}</span>)
|
||||
<span className="text-red-600">-{uncommittedDeletions}</span>)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import type { CumulativeDiff } from '@/lib/state/slices/session-runtime/types';
|
||||
|
||||
// Local state cache for cumulative diffs (not stored in global store since it's on-demand)
|
||||
const cumulativeDiffCache: Record<string, CumulativeDiff | null> = {};
|
||||
const loadingState: Record<string, boolean> = {};
|
||||
|
||||
/**
|
||||
* Hook to fetch cumulative diff for a session.
|
||||
* Returns the total diff from base branch to current HEAD.
|
||||
*/
|
||||
export function useCumulativeDiff(sessionId: string | null) {
|
||||
const [diff, setDiff] = useState<CumulativeDiff | null>(
|
||||
sessionId ? cumulativeDiffCache[sessionId] ?? null : null
|
||||
);
|
||||
const [loading, setLoading] = useState(
|
||||
sessionId ? loadingState[sessionId] ?? false : false
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCumulativeDiff = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
setLoading(true);
|
||||
loadingState[sessionId] = true;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await client.request<{ cumulative_diff?: CumulativeDiff }>(
|
||||
'session.cumulative_diff',
|
||||
{ session_id: sessionId }
|
||||
);
|
||||
|
||||
if (response?.cumulative_diff) {
|
||||
cumulativeDiffCache[sessionId] = response.cumulative_diff;
|
||||
setDiff(response.cumulative_diff);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cumulative diff:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch cumulative diff');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingState[sessionId] = false;
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Fetch on mount if not cached
|
||||
useEffect(() => {
|
||||
if (sessionId && !diff && !loading) {
|
||||
fetchCumulativeDiff();
|
||||
}
|
||||
}, [sessionId, diff, loading, fetchCumulativeDiff]);
|
||||
|
||||
// Update local state when sessionId changes
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
setDiff(cumulativeDiffCache[sessionId] ?? null);
|
||||
setLoading(loadingState[sessionId] ?? false);
|
||||
} else {
|
||||
setDiff(null);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
return {
|
||||
diff,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchCumulativeDiff,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import type { SessionCommit } from '@/lib/state/slices/session-runtime/types';
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage commits for a session.
|
||||
* Session commits track all commits made during a task session.
|
||||
*/
|
||||
export function useSessionCommits(sessionId: string | null) {
|
||||
const commits = useAppStore((state) =>
|
||||
sessionId ? state.sessionCommits.bySessionId[sessionId] : undefined
|
||||
);
|
||||
const loading = useAppStore((state) =>
|
||||
sessionId ? state.sessionCommits.loading[sessionId] : false
|
||||
);
|
||||
const setSessionCommits = useAppStore((state) => state.setSessionCommits);
|
||||
const setSessionCommitsLoading = useAppStore((state) => state.setSessionCommitsLoading);
|
||||
|
||||
const fetchCommits = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
setSessionCommitsLoading(sessionId, true);
|
||||
try {
|
||||
const response = await client.request<{ commits?: SessionCommit[] }>(
|
||||
'session.git.commits',
|
||||
{ session_id: sessionId }
|
||||
);
|
||||
|
||||
if (response?.commits) {
|
||||
setSessionCommits(sessionId, response.commits);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch session commits:', error);
|
||||
} finally {
|
||||
setSessionCommitsLoading(sessionId, false);
|
||||
}
|
||||
}, [sessionId, setSessionCommits, setSessionCommitsLoading]);
|
||||
|
||||
// Fetch commits on mount
|
||||
useEffect(() => {
|
||||
if (sessionId && !commits) {
|
||||
fetchCommits();
|
||||
}
|
||||
}, [sessionId, commits, fetchCommits]);
|
||||
|
||||
return {
|
||||
commits: commits ?? [],
|
||||
loading,
|
||||
refetch: fetchCommits,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import type { GitSnapshot } from '@/lib/state/slices/session-runtime/types';
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage git snapshots for a session.
|
||||
* Git snapshots provide historical tracking of git status at key moments.
|
||||
*/
|
||||
export function useSessionGitSnapshots(sessionId: string | null, options?: { limit?: number }) {
|
||||
const snapshots = useAppStore((state) =>
|
||||
sessionId ? state.gitSnapshots.bySessionId[sessionId] : undefined
|
||||
);
|
||||
const latestSnapshot = useAppStore((state) =>
|
||||
sessionId ? state.gitSnapshots.latestBySessionId[sessionId] : undefined
|
||||
);
|
||||
const loading = useAppStore((state) =>
|
||||
sessionId ? state.gitSnapshots.loading[sessionId] : false
|
||||
);
|
||||
const setGitSnapshots = useAppStore((state) => state.setGitSnapshots);
|
||||
const setGitSnapshotsLoading = useAppStore((state) => state.setGitSnapshotsLoading);
|
||||
|
||||
const fetchSnapshots = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
setGitSnapshotsLoading(sessionId, true);
|
||||
try {
|
||||
const response = await client.request<{ snapshots?: GitSnapshot[] }>(
|
||||
'session.git.snapshots',
|
||||
{
|
||||
session_id: sessionId,
|
||||
limit: options?.limit ?? 0,
|
||||
}
|
||||
);
|
||||
|
||||
if (response?.snapshots) {
|
||||
setGitSnapshots(sessionId, response.snapshots);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch git snapshots:', error);
|
||||
} finally {
|
||||
setGitSnapshotsLoading(sessionId, false);
|
||||
}
|
||||
}, [sessionId, options?.limit, setGitSnapshots, setGitSnapshotsLoading]);
|
||||
|
||||
// Fetch snapshots on mount
|
||||
useEffect(() => {
|
||||
if (sessionId && !snapshots) {
|
||||
fetchSnapshots();
|
||||
}
|
||||
}, [sessionId, snapshots, fetchSnapshots]);
|
||||
|
||||
return {
|
||||
snapshots: snapshots ?? [],
|
||||
latestSnapshot: latestSnapshot ?? null,
|
||||
loading,
|
||||
refetch: fetchSnapshots,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,47 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import type { GitStatusEntry } from '@/lib/state/store';
|
||||
|
||||
/**
|
||||
* Hook to get the current git status for a session.
|
||||
* Git status is populated via WebSocket from git snapshot updates.
|
||||
* For historical snapshots, use useSessionGitSnapshots hook.
|
||||
*/
|
||||
export function useSessionGitStatus(sessionId: string | null) {
|
||||
const gitStatus = useAppStore((state) =>
|
||||
sessionId ? state.gitStatus.bySessionId[sessionId] : undefined
|
||||
);
|
||||
const session = useAppStore((state) =>
|
||||
sessionId ? state.taskSessions.items[sessionId] : undefined
|
||||
);
|
||||
const setGitStatus = useAppStore((state) => state.setGitStatus);
|
||||
|
||||
// Populate git status from session metadata if not already in store
|
||||
useEffect(() => {
|
||||
if (!sessionId || gitStatus) return;
|
||||
|
||||
// Try to extract git_status from session metadata
|
||||
const metadata = session?.metadata;
|
||||
if (!metadata || typeof metadata !== 'object') return;
|
||||
|
||||
const storedGitStatus = metadata.git_status;
|
||||
if (!storedGitStatus || typeof storedGitStatus !== 'object') return;
|
||||
|
||||
// Map stored git status to GitStatusEntry
|
||||
const gs = storedGitStatus as Record<string, unknown>;
|
||||
const entry: GitStatusEntry = {
|
||||
branch: (gs.branch as string) ?? null,
|
||||
remote_branch: (gs.remote_branch as string) ?? null,
|
||||
modified: (gs.modified as string[]) ?? [],
|
||||
added: (gs.added as string[]) ?? [],
|
||||
deleted: (gs.deleted as string[]) ?? [],
|
||||
untracked: (gs.untracked as string[]) ?? [],
|
||||
renamed: (gs.renamed as string[]) ?? [],
|
||||
ahead: (gs.ahead as number) ?? 0,
|
||||
behind: (gs.behind as number) ?? 0,
|
||||
files: (gs.files as Record<string, GitStatusEntry['files'][string]>) ?? {},
|
||||
timestamp: (gs.timestamp as string) ?? null,
|
||||
};
|
||||
|
||||
setGitStatus(sessionId, entry);
|
||||
}, [sessionId, gitStatus, session?.metadata, setGitStatus]);
|
||||
|
||||
// Subscribe to session updates to receive git status via WebSocket
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const client = getWebSocketClient();
|
||||
|
||||
@@ -63,6 +63,12 @@ export type {
|
||||
FileInfo,
|
||||
GitStatusEntry,
|
||||
GitStatusState,
|
||||
SnapshotType,
|
||||
GitSnapshot,
|
||||
SessionCommit,
|
||||
CumulativeDiff,
|
||||
GitSnapshotsState,
|
||||
SessionCommitsState,
|
||||
ContextWindowEntry,
|
||||
ContextWindowState,
|
||||
AgentState,
|
||||
|
||||
@@ -21,6 +21,8 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = {
|
||||
devProcessBySessionId: {},
|
||||
},
|
||||
gitStatus: { bySessionId: {} },
|
||||
gitSnapshots: { bySessionId: {}, latestBySessionId: {}, loading: {} },
|
||||
sessionCommits: { bySessionId: {}, loading: {} },
|
||||
contextWindow: { bySessionId: {} },
|
||||
agents: { agents: [] },
|
||||
};
|
||||
@@ -90,4 +92,36 @@ export const createSessionRuntimeSlice: StateCreator<
|
||||
set((draft) => {
|
||||
draft.contextWindow.bySessionId[sessionId] = contextWindow;
|
||||
}),
|
||||
// Git snapshot actions
|
||||
setGitSnapshots: (sessionId, snapshots) =>
|
||||
set((draft) => {
|
||||
draft.gitSnapshots.bySessionId[sessionId] = snapshots;
|
||||
draft.gitSnapshots.latestBySessionId[sessionId] = snapshots.length > 0 ? snapshots[0] : null;
|
||||
}),
|
||||
setGitSnapshotsLoading: (sessionId, loading) =>
|
||||
set((draft) => {
|
||||
draft.gitSnapshots.loading[sessionId] = loading;
|
||||
}),
|
||||
addGitSnapshot: (sessionId, snapshot) =>
|
||||
set((draft) => {
|
||||
const existing = draft.gitSnapshots.bySessionId[sessionId] || [];
|
||||
// Add to front (newest first)
|
||||
draft.gitSnapshots.bySessionId[sessionId] = [snapshot, ...existing];
|
||||
draft.gitSnapshots.latestBySessionId[sessionId] = snapshot;
|
||||
}),
|
||||
// Session commit actions
|
||||
setSessionCommits: (sessionId, commits) =>
|
||||
set((draft) => {
|
||||
draft.sessionCommits.bySessionId[sessionId] = commits;
|
||||
}),
|
||||
setSessionCommitsLoading: (sessionId, loading) =>
|
||||
set((draft) => {
|
||||
draft.sessionCommits.loading[sessionId] = loading;
|
||||
}),
|
||||
addSessionCommit: (sessionId, commit) =>
|
||||
set((draft) => {
|
||||
const existing = draft.sessionCommits.bySessionId[sessionId] || [];
|
||||
// Add to front (newest first)
|
||||
draft.sessionCommits.bySessionId[sessionId] = [commit, ...existing];
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -66,6 +66,61 @@ export type GitStatusState = {
|
||||
bySessionId: Record<string, GitStatusEntry>;
|
||||
};
|
||||
|
||||
// Git Snapshot types for historical tracking
|
||||
export type SnapshotType = 'status_update' | 'pre_commit' | 'post_commit' | 'pre_stage' | 'post_stage';
|
||||
|
||||
export type GitSnapshot = {
|
||||
id: string;
|
||||
session_id: string;
|
||||
snapshot_type: SnapshotType;
|
||||
branch: string;
|
||||
remote_branch: string;
|
||||
head_commit: string;
|
||||
base_commit: string;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: Record<string, FileInfo>;
|
||||
triggered_by: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SessionCommit = {
|
||||
id: string;
|
||||
session_id: string;
|
||||
commit_sha: string;
|
||||
parent_sha: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
commit_message: string;
|
||||
committed_at: string;
|
||||
pre_commit_snapshot_id?: string;
|
||||
post_commit_snapshot_id?: string;
|
||||
files_changed: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CumulativeDiff = {
|
||||
session_id: string;
|
||||
base_commit: string;
|
||||
head_commit: string;
|
||||
total_commits: number;
|
||||
files: Record<string, FileInfo>;
|
||||
};
|
||||
|
||||
export type GitSnapshotsState = {
|
||||
bySessionId: Record<string, GitSnapshot[]>;
|
||||
latestBySessionId: Record<string, GitSnapshot | null>;
|
||||
loading: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type SessionCommitsState = {
|
||||
bySessionId: Record<string, SessionCommit[]>;
|
||||
loading: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type ContextWindowEntry = {
|
||||
size: number;
|
||||
used: number;
|
||||
@@ -87,6 +142,8 @@ export type SessionRuntimeSliceState = {
|
||||
shell: ShellState;
|
||||
processes: ProcessState;
|
||||
gitStatus: GitStatusState;
|
||||
gitSnapshots: GitSnapshotsState;
|
||||
sessionCommits: SessionCommitsState;
|
||||
contextWindow: ContextWindowState;
|
||||
agents: AgentState;
|
||||
};
|
||||
@@ -106,6 +163,14 @@ export type SessionRuntimeSliceActions = {
|
||||
setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void;
|
||||
clearGitStatus: (sessionId: string) => void;
|
||||
setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void;
|
||||
// Git snapshot actions
|
||||
setGitSnapshots: (sessionId: string, snapshots: GitSnapshot[]) => void;
|
||||
setGitSnapshotsLoading: (sessionId: string, loading: boolean) => void;
|
||||
addGitSnapshot: (sessionId: string, snapshot: GitSnapshot) => void;
|
||||
// Session commit actions
|
||||
setSessionCommits: (sessionId: string, commits: SessionCommit[]) => void;
|
||||
setSessionCommitsLoading: (sessionId: string, loading: boolean) => void;
|
||||
addSessionCommit: (sessionId: string, commit: SessionCommit) => void;
|
||||
};
|
||||
|
||||
export type SessionRuntimeSlice = SessionRuntimeSliceState & SessionRuntimeSliceActions;
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
type ProcessStatusEntry,
|
||||
type Worktree,
|
||||
type GitStatusEntry,
|
||||
type GitSnapshot,
|
||||
type SessionCommit,
|
||||
type ContextWindowEntry,
|
||||
type SessionAgentctlStatus,
|
||||
type PreviewStage,
|
||||
@@ -130,6 +132,8 @@ export type AppState = {
|
||||
shell: typeof defaultSessionRuntimeState['shell'];
|
||||
processes: typeof defaultSessionRuntimeState['processes'];
|
||||
gitStatus: typeof defaultSessionRuntimeState['gitStatus'];
|
||||
gitSnapshots: typeof defaultSessionRuntimeState['gitSnapshots'];
|
||||
sessionCommits: typeof defaultSessionRuntimeState['sessionCommits'];
|
||||
contextWindow: typeof defaultSessionRuntimeState['contextWindow'];
|
||||
agents: typeof defaultSessionRuntimeState['agents'];
|
||||
|
||||
@@ -215,6 +219,12 @@ export type AppState = {
|
||||
setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void;
|
||||
setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void;
|
||||
clearGitStatus: (sessionId: string) => void;
|
||||
setGitSnapshots: (sessionId: string, snapshots: GitSnapshot[]) => void;
|
||||
setGitSnapshotsLoading: (sessionId: string, loading: boolean) => void;
|
||||
addGitSnapshot: (sessionId: string, snapshot: GitSnapshot) => void;
|
||||
setSessionCommits: (sessionId: string, commits: SessionCommit[]) => void;
|
||||
setSessionCommitsLoading: (sessionId: string, loading: boolean) => void;
|
||||
addSessionCommit: (sessionId: string, commit: SessionCommit) => void;
|
||||
setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void;
|
||||
bumpAgentProfilesVersion: () => void;
|
||||
setPendingModel: (sessionId: string, modelId: string) => void;
|
||||
@@ -255,6 +265,8 @@ const defaultState = {
|
||||
shell: defaultSessionRuntimeState.shell,
|
||||
processes: defaultSessionRuntimeState.processes,
|
||||
gitStatus: defaultSessionRuntimeState.gitStatus,
|
||||
gitSnapshots: defaultSessionRuntimeState.gitSnapshots,
|
||||
sessionCommits: defaultSessionRuntimeState.sessionCommits,
|
||||
contextWindow: defaultSessionRuntimeState.contextWindow,
|
||||
agents: defaultSessionRuntimeState.agents,
|
||||
previewPanel: defaultUIState.previewPanel,
|
||||
@@ -300,6 +312,8 @@ function mergeInitialState(initialState?: Partial<AppState>): typeof defaultStat
|
||||
shell: { ...defaultState.shell, ...initialState.shell },
|
||||
processes: { ...defaultState.processes, ...initialState.processes },
|
||||
gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus },
|
||||
gitSnapshots: { ...defaultState.gitSnapshots, ...initialState.gitSnapshots },
|
||||
sessionCommits: { ...defaultState.sessionCommits, ...initialState.sessionCommits },
|
||||
contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow },
|
||||
agents: { ...defaultState.agents, ...initialState.agents },
|
||||
previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel },
|
||||
|
||||
@@ -9,6 +9,8 @@ export type BackendMessageType =
|
||||
| 'terminal.output'
|
||||
| 'diff.update'
|
||||
| 'session.git.status'
|
||||
| 'session.git.snapshot'
|
||||
| 'session.git.commit'
|
||||
| 'system.error'
|
||||
| 'workspace.created'
|
||||
| 'workspace.updated'
|
||||
@@ -203,6 +205,47 @@ export type GitStatusPayload = {
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type GitSnapshotPayload = {
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
snapshot: {
|
||||
id: string;
|
||||
session_id: string;
|
||||
snapshot_type: string;
|
||||
branch: string;
|
||||
remote_branch: string;
|
||||
head_commit: string;
|
||||
base_commit: string;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: Record<string, FileInfo>;
|
||||
triggered_by: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type GitCommitPayload = {
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
commit: {
|
||||
id: string;
|
||||
session_id: string;
|
||||
commit_sha: string;
|
||||
parent_sha: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
commit_message: string;
|
||||
committed_at: string;
|
||||
pre_commit_snapshot_id?: string;
|
||||
post_commit_snapshot_id?: string;
|
||||
files_changed: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ProcessOutputPayload = {
|
||||
session_id: string;
|
||||
process_id: string;
|
||||
@@ -312,6 +355,8 @@ export type BackendMessageMap = {
|
||||
'terminal.output': BackendMessage<'terminal.output', TerminalOutputPayload>;
|
||||
'diff.update': BackendMessage<'diff.update', DiffUpdatePayload>;
|
||||
'session.git.status': BackendMessage<'session.git.status', GitStatusPayload>;
|
||||
'session.git.snapshot': BackendMessage<'session.git.snapshot', GitSnapshotPayload>;
|
||||
'session.git.commit': BackendMessage<'session.git.commit', GitCommitPayload>;
|
||||
'system.error': BackendMessage<'system.error', SystemErrorPayload>;
|
||||
'workspace.created': BackendMessage<'workspace.created', WorkspacePayload>;
|
||||
'workspace.updated': BackendMessage<'workspace.updated', WorkspacePayload>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { StoreApi } from 'zustand';
|
||||
import type { AppState } from '@/lib/state/store';
|
||||
import type { WsHandlers } from '@/lib/ws/handlers/types';
|
||||
import type { GitSnapshot, SessionCommit } from '@/lib/state/slices/session-runtime/types';
|
||||
|
||||
export function registerGitStatusHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
return {
|
||||
@@ -23,5 +24,19 @@ export function registerGitStatusHandlers(store: StoreApi<AppState>): WsHandlers
|
||||
timestamp: payload.timestamp,
|
||||
});
|
||||
},
|
||||
'session.git.snapshot': (message) => {
|
||||
const payload = message.payload;
|
||||
if (!payload.session_id || !payload.snapshot) {
|
||||
return;
|
||||
}
|
||||
store.getState().addGitSnapshot(payload.session_id, payload.snapshot as GitSnapshot);
|
||||
},
|
||||
'session.git.commit': (message) => {
|
||||
const payload = message.payload;
|
||||
if (!payload.session_id || !payload.commit) {
|
||||
return;
|
||||
}
|
||||
store.getState().addSessionCommit(payload.session_id, payload.commit as SessionCommit);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user