177 lines
4.7 KiB
Go
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
|
|
}
|