Files
kandev/apps/backend/internal/agentctl/server/api/workspace.go
T

177 lines
4.7 KiB
Go

package api
import (
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agentctl/types"
"go.uber.org/zap"
)
// handleWorkspaceStreamWS handles the unified workspace stream WebSocket endpoint.
// It streams git status, file changes, file lists, and shell I/O over a single connection.
func (s *Server) handleWorkspaceStreamWS(c *gin.Context) {
conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
s.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer func() {
if err := conn.Close(); err != nil {
s.logger.Debug("failed to close workspace stream websocket", zap.Error(err))
}
}()
s.logger.Info("Workspace stream WebSocket connected")
// Subscribe to unified workspace updates
sub := s.procMgr.GetWorkspaceTracker().SubscribeWorkspaceStream()
defer s.procMgr.GetWorkspaceTracker().UnsubscribeWorkspaceStream(sub)
// Get shell for input handling (may be nil)
shell := s.procMgr.Shell()
// Subscribe to shell output if shell is available
var shellOutputCh chan []byte
if shell != nil {
shellOutputCh = make(chan []byte, 256)
shell.Subscribe(shellOutputCh)
defer shell.Unsubscribe(shellOutputCh)
}
// Send connected message
connectedMsg := types.NewWorkspaceConnected()
if err := conn.WriteJSON(connectedMsg); err != nil {
s.logger.Debug("failed to send connected message", zap.Error(err))
return
}
// Done channel to signal goroutine shutdown
done := make(chan struct{})
defer close(done)
// Handle incoming messages (shell_input, shell_resize, ping) in a goroutine
go func() {
for {
select {
case <-done:
return
default:
var msg types.WorkspaceStreamMessage
if err := conn.ReadJSON(&msg); err != nil {
s.logger.Debug("workspace stream WebSocket read error", zap.Error(err))
return
}
switch msg.Type {
case types.WorkspaceMessageTypeShellInput:
if shell != nil {
if _, err := shell.Write([]byte(msg.Data)); err != nil {
s.logger.Debug("shell write error", zap.Error(err))
}
}
case types.WorkspaceMessageTypeShellResize:
// Resize is currently not implemented in the shell session
// TODO: Implement shell.Resize(cols, rows) when needed
s.logger.Debug("shell resize requested", zap.Int("cols", msg.Cols), zap.Int("rows", msg.Rows))
case types.WorkspaceMessageTypePing:
// Respond with pong
pongMsg := types.NewWorkspacePong()
if err := conn.WriteJSON(pongMsg); err != nil {
s.logger.Debug("workspace stream pong write error", zap.Error(err))
return
}
}
}
}
}()
// Forward all workspace events to WebSocket
for {
select {
case <-done:
return
case msg, ok := <-sub:
if !ok {
return
}
if err := conn.WriteJSON(msg); err != nil {
s.logger.Debug("workspace stream write error", zap.Error(err))
return
}
case data, ok := <-shellOutputCh:
if !ok {
// Shell output channel closed
shellOutputCh = nil
continue
}
// Forward shell output as workspace stream message
shellMsg := types.NewWorkspaceShellOutput(string(data))
if err := conn.WriteJSON(shellMsg); err != nil {
s.logger.Debug("workspace stream shell output write error", zap.Error(err))
return
}
}
}
}
// handleFileTree handles file tree requests via HTTP GET
func (s *Server) handleFileTree(c *gin.Context) {
path := c.Query("path")
depth := 1
if d := c.Query("depth"); d != "" {
if _, err := json.Number(d).Int64(); err == nil {
depth = int(mustParseInt(d))
}
}
tree, err := s.procMgr.GetWorkspaceTracker().GetFileTree(path, depth)
if err != nil {
c.JSON(400, types.FileTreeResponse{Error: err.Error()})
return
}
c.JSON(200, types.FileTreeResponse{Root: tree})
}
// handleFileContent handles file content requests via HTTP GET
func (s *Server) handleFileContent(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(400, types.FileContentResponse{Error: "path is required"})
return
}
content, size, err := s.procMgr.GetWorkspaceTracker().GetFileContent(path)
if err != nil {
c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size})
return
}
c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size})
}
// handleFileSearch handles file search requests via HTTP GET
func (s *Server) handleFileSearch(c *gin.Context) {
query := c.Query("q")
limit := 20
if l := c.Query("limit"); l != "" {
if parsed := mustParseInt(l); parsed > 0 {
limit = parsed
}
}
files := s.procMgr.GetWorkspaceTracker().SearchFiles(query, limit)
c.JSON(200, types.FileSearchResponse{Files: files})
}
// mustParseInt parses a string to int, returns 0 on error
func mustParseInt(s string) int {
var n int
if err := json.Unmarshal([]byte(s), &n); err != nil {
return 0
}
return n
}