@@ -29,6 +29,7 @@ func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||
d.RegisterFunc(ws.ActionWorkspaceFileTreeGet, h.wsGetFileTree)
|
||||
d.RegisterFunc(ws.ActionWorkspaceFileContentGet, h.wsGetFileContent)
|
||||
d.RegisterFunc(ws.ActionWorkspaceFileContentUpdate, h.wsUpdateFileContent)
|
||||
d.RegisterFunc(ws.ActionWorkspaceFileDelete, h.wsDeleteFile)
|
||||
d.RegisterFunc(ws.ActionWorkspaceFilesSearch, h.wsSearchFiles)
|
||||
}
|
||||
|
||||
@@ -154,6 +155,46 @@ func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws
|
||||
return ws.NewResponse(msg.ID, msg.Action, response)
|
||||
}
|
||||
|
||||
// wsDeleteFile handles workspace.file.delete action
|
||||
func (h *WorkspaceFileHandlers) wsDeleteFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
|
||||
if req.SessionID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
||||
}
|
||||
if req.Path == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "path is required", nil)
|
||||
}
|
||||
|
||||
// Get agent execution for this session
|
||||
execution, found := h.lifecycle.GetExecutionBySessionID(req.SessionID)
|
||||
if !found {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for session", nil)
|
||||
}
|
||||
|
||||
// Get agentctl client
|
||||
client := execution.GetAgentCtlClient()
|
||||
if client == nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent client not available", nil)
|
||||
}
|
||||
|
||||
// Delete file via agentctl
|
||||
response, err := client.DeleteFile(ctx, req.Path)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to delete file", zap.Error(err), zap.String("session_id", req.SessionID), zap.String("path", req.Path))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to delete file: %v", err), nil)
|
||||
}
|
||||
|
||||
return ws.NewResponse(msg.ID, msg.Action, response)
|
||||
}
|
||||
|
||||
// wsSearchFiles handles workspace.files.search action
|
||||
func (h *WorkspaceFileHandlers) wsSearchFiles(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req struct {
|
||||
|
||||
@@ -461,6 +461,48 @@ func (c *Client) ApplyFileDiff(ctx context.Context, path string, diff string, or
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// FileDeleteResponse represents a file delete response
|
||||
type FileDeleteResponse struct {
|
||||
Path string `json:"path"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteFile deletes a file via HTTP DELETE
|
||||
func (c *Client) DeleteFile(ctx context.Context, path string) (*FileDeleteResponse, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/v1/workspace/file?path=%s", c.baseURL, path)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, 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 delete file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
c.logger.Debug("failed to close file delete response body", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
var response FileDeleteResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if response.Error != "" {
|
||||
return nil, fmt.Errorf("file delete error: %s", response.Error)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, fmt.Errorf("file delete failed")
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Close closes all connections
|
||||
func (c *Client) Close() {
|
||||
c.CloseUpdatesStream()
|
||||
|
||||
@@ -90,6 +90,7 @@ func (s *Server) setupRoutes() {
|
||||
api.GET("/workspace/tree", s.handleFileTree)
|
||||
api.GET("/workspace/file/content", s.handleFileContent)
|
||||
api.POST("/workspace/file/content", s.handleFileUpdate)
|
||||
api.DELETE("/workspace/file", s.handleFileDelete)
|
||||
api.GET("/workspace/search", s.handleFileSearch)
|
||||
|
||||
// Shell access (HTTP endpoints only - streaming is via /workspace/stream)
|
||||
|
||||
@@ -203,6 +203,30 @@ func (s *Server) handleFileSearch(c *gin.Context) {
|
||||
c.JSON(200, types.FileSearchResponse{Files: files})
|
||||
}
|
||||
|
||||
// handleFileDelete handles file delete requests via HTTP DELETE
|
||||
func (s *Server) handleFileDelete(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(400, streams.FileDeleteResponse{Error: "path is required"})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.procMgr.GetWorkspaceTracker().DeleteFile(path)
|
||||
if err != nil {
|
||||
c.JSON(400, streams.FileDeleteResponse{
|
||||
Path: path,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, streams.FileDeleteResponse{
|
||||
Path: path,
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
|
||||
// mustParseInt parses a string to int, returns 0 on error
|
||||
func mustParseInt(s string) int {
|
||||
var n int
|
||||
|
||||
@@ -1310,6 +1310,57 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or
|
||||
return newHash, nil
|
||||
}
|
||||
|
||||
// DeleteFile deletes a file from the workspace
|
||||
func (wt *WorkspaceTracker) DeleteFile(reqPath string) error {
|
||||
cleanWorkDir := filepath.Clean(wt.workDir)
|
||||
cleanReqPath := filepath.Clean(reqPath)
|
||||
|
||||
// Resolve the full path - if already absolute and within workDir, use directly
|
||||
var fullPath string
|
||||
if filepath.IsAbs(cleanReqPath) && strings.HasPrefix(cleanReqPath, cleanWorkDir+string(os.PathSeparator)) {
|
||||
fullPath = cleanReqPath
|
||||
} else {
|
||||
fullPath = filepath.Join(wt.workDir, cleanReqPath)
|
||||
}
|
||||
|
||||
// Path traversal protection
|
||||
if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir {
|
||||
return fmt.Errorf("path traversal detected")
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("file does not exist: %s", reqPath)
|
||||
}
|
||||
return fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
|
||||
// Don't allow deleting directories
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("cannot delete directory: %s", reqPath)
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
return fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
|
||||
// Trigger filesystem change notification
|
||||
select {
|
||||
case wt.fsChangeTrigger <- struct{}{}:
|
||||
default:
|
||||
// Channel already has a pending trigger, skip
|
||||
}
|
||||
|
||||
wt.logger.Debug("deleted file",
|
||||
zap.String("path", reqPath),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateSHA256 calculates the SHA256 hash of a string
|
||||
func calculateSHA256(content string) string {
|
||||
hash := sha256.Sum256([]byte(content))
|
||||
|
||||
@@ -180,3 +180,26 @@ type FileUpdateResponse struct {
|
||||
// Error contains error message if the request failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// FileDeleteRequest represents a request to delete a file.
|
||||
//
|
||||
// HTTP endpoint: DELETE /api/v1/workspace/file
|
||||
type FileDeleteRequest struct {
|
||||
// Path is the file path (relative to workspace root).
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// FileDeleteResponse represents a response to a file delete request.
|
||||
type FileDeleteResponse struct {
|
||||
// RequestID identifies the request.
|
||||
RequestID string `json:"request_id"`
|
||||
|
||||
// Path is the file path.
|
||||
Path string `json:"path"`
|
||||
|
||||
// Success indicates if the deletion was successful.
|
||||
Success bool `json:"success"`
|
||||
|
||||
// Error contains error message if the request failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ const (
|
||||
ActionWorkspaceFileTreeGet = "workspace.tree.get"
|
||||
ActionWorkspaceFileContentGet = "workspace.file.get"
|
||||
ActionWorkspaceFileContentUpdate = "workspace.file.update"
|
||||
ActionWorkspaceFileDelete = "workspace.file.delete"
|
||||
ActionWorkspaceFilesSearch = "workspace.files.search"
|
||||
ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification
|
||||
|
||||
|
||||
Generated
+28
-12
@@ -269,6 +269,9 @@ importers:
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.18
|
||||
version: 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@uiw/codemirror-theme-github':
|
||||
specifier: ^4.25.4
|
||||
version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)
|
||||
'@uiw/codemirror-theme-vscode':
|
||||
specifier: ^4.25.4
|
||||
version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)
|
||||
@@ -319,7 +322,7 @@ importers:
|
||||
version: 11.12.2
|
||||
next:
|
||||
specifier: 16.1.1
|
||||
version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -2813,6 +2816,9 @@ packages:
|
||||
'@codemirror/state': 6.5.3
|
||||
'@codemirror/view': 6.39.10
|
||||
|
||||
'@uiw/codemirror-theme-github@4.25.4':
|
||||
resolution: {integrity: sha512-M5zRT2vIpNsuKN0Lz+DwLnmhHW8Eddp1M9zC0hm3V+bvffmaSn/pUDey1eqGIv5xNNmjhqvDAz0a90xLYCzvSw==}
|
||||
|
||||
'@uiw/codemirror-theme-vscode@4.25.4':
|
||||
resolution: {integrity: sha512-9ob5EtLqrXBFl8uf4eFRkXjyjfyfBRVsJdt7xbc33f+2/I29/2v2nEdU/xw40+dhloxF/h1Ry281f8wAs97MWQ==}
|
||||
|
||||
@@ -8937,6 +8943,14 @@ snapshots:
|
||||
'@codemirror/state': 6.5.3
|
||||
'@codemirror/view': 6.39.10
|
||||
|
||||
'@uiw/codemirror-theme-github@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)':
|
||||
dependencies:
|
||||
'@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/language'
|
||||
- '@codemirror/state'
|
||||
- '@codemirror/view'
|
||||
|
||||
'@uiw/codemirror-theme-vscode@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)':
|
||||
dependencies:
|
||||
'@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)
|
||||
@@ -10002,8 +10016,8 @@ snapshots:
|
||||
'@next/eslint-plugin-next': 16.1.1
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||
@@ -10025,7 +10039,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
@@ -10036,22 +10050,22 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -10062,7 +10076,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -11481,7 +11495,7 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next@16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@next/env': 16.1.1
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -11490,7 +11504,7 @@ snapshots:
|
||||
postcss: 8.4.31
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
styled-jsx: 5.1.6(react@19.2.3)
|
||||
styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.1
|
||||
'@next/swc-darwin-x64': 16.1.1
|
||||
@@ -12578,10 +12592,12 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.3):
|
||||
styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.3
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.6
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
|
||||
@@ -423,6 +423,7 @@ export const DiffViewer = memo(function DiffViewer({
|
||||
light: 'github-light',
|
||||
},
|
||||
enableLineSelection: enableComments,
|
||||
hunkSeparators: 'simple',
|
||||
enableHoverUtility: enableComments,
|
||||
diffIndicators: 'none',
|
||||
onLineSelectionEnd: handleLineSelectionEnd,
|
||||
|
||||
@@ -101,7 +101,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({ comment, worktree
|
||||
{!isSuccess && getStatusIcon()}
|
||||
</span>
|
||||
{filePath && (
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="min-w-0">
|
||||
<FilePathButton
|
||||
filePath={filePath}
|
||||
worktreePath={worktreePath}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
|
||||
type CommentViewPopoverProps = {
|
||||
comments: DiffComment[];
|
||||
position: { x: number; y: number };
|
||||
onDelete: (commentId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CommentViewPopover({
|
||||
comments,
|
||||
position,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: CommentViewPopoverProps) {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
// Delay adding listener to avoid immediate close
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}, 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
// Calculate position
|
||||
const popoverWidth = 350;
|
||||
const popoverHeight = 200;
|
||||
const gap = 8;
|
||||
|
||||
let left = position.x + gap;
|
||||
let top = position.y + gap;
|
||||
|
||||
// If would overflow right, shift left
|
||||
if (left + popoverWidth > window.innerWidth - 16) {
|
||||
left = Math.max(16, window.innerWidth - popoverWidth - 16);
|
||||
}
|
||||
|
||||
// If would overflow bottom, position above
|
||||
if (top + popoverHeight > window.innerHeight - 16) {
|
||||
top = Math.max(16, position.y - popoverHeight - gap);
|
||||
}
|
||||
|
||||
// Format line range
|
||||
const formatLineRange = (start: number, end: number) => {
|
||||
return start === end ? `L${start}` : `L${start}-${end}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={cn(
|
||||
'fixed z-50 w-[350px] max-h-[350px] overflow-auto rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm shadow-xl',
|
||||
'animate-in fade-in-0 zoom-in-95 duration-150'
|
||||
)}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
}}
|
||||
>
|
||||
{/* Comments list */}
|
||||
<div className="divide-y divide-border/30">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="p-3">
|
||||
{/* Line range and delete */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="px-2 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">
|
||||
{formatLineRange(comment.startLine, comment.endLine)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(comment.id)}
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Code snippet with scroll */}
|
||||
{comment.codeContent && (
|
||||
<pre className="mb-2 p-2 rounded-md bg-muted/50 text-[10px] text-muted-foreground font-mono max-h-[80px] overflow-auto whitespace-pre-wrap">
|
||||
{comment.codeContent}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Annotation */}
|
||||
<p className="text-xs text-foreground leading-relaxed">
|
||||
{comment.annotation}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type EditorCommentPopoverProps = {
|
||||
selectedText: string;
|
||||
lineRange: { start: number; end: number };
|
||||
position: { x: number; y: number };
|
||||
onSubmit: (comment: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function EditorCommentPopover({
|
||||
selectedText,
|
||||
lineRange,
|
||||
position,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: EditorCommentPopoverProps) {
|
||||
const [comment, setComment] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus textarea on mount
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
// Delay adding listener to avoid immediate close from selection click
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}, 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!comment.trim()) return;
|
||||
onSubmit(comment.trim());
|
||||
}, [comment, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
const isDisabled = !comment.trim();
|
||||
|
||||
// Truncate code preview
|
||||
const previewText = selectedText.length > 100
|
||||
? selectedText.slice(0, 100).trim() + '…'
|
||||
: selectedText;
|
||||
|
||||
// Format line range
|
||||
const lineRangeText = lineRange.start === lineRange.end
|
||||
? `Line ${lineRange.start}`
|
||||
: `Lines ${lineRange.start}-${lineRange.end}`;
|
||||
|
||||
// Calculate position - anchor at bottom-right of selection, adjust if would overflow
|
||||
const popoverWidth = 384; // w-96 = 24rem = 384px
|
||||
const popoverHeight = 220; // approximate height of popover
|
||||
const gap = 8;
|
||||
|
||||
// Start at bottom-right of selection
|
||||
let left = position.x + gap;
|
||||
let top = position.y + gap;
|
||||
|
||||
// If would overflow right, shift left
|
||||
if (left + popoverWidth > window.innerWidth - 16) {
|
||||
left = Math.max(16, window.innerWidth - popoverWidth - 16);
|
||||
}
|
||||
|
||||
// If would overflow bottom, position above the selection instead
|
||||
if (top + popoverHeight > window.innerHeight - 16) {
|
||||
top = Math.max(16, position.y - popoverHeight - gap);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={cn(
|
||||
'fixed z-50 w-96 rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm p-4 shadow-xl',
|
||||
'animate-in fade-in-0 zoom-in-95 duration-150'
|
||||
)}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
}}
|
||||
>
|
||||
{/* Line range badge */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-0.5 rounded-md bg-muted text-xs font-mono text-muted-foreground">
|
||||
{lineRangeText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Code preview */}
|
||||
<pre className="mb-3 p-2 rounded-md bg-muted/50 text-xs text-muted-foreground font-mono line-clamp-3 overflow-hidden whitespace-pre-wrap">
|
||||
{previewText}
|
||||
</pre>
|
||||
|
||||
{/* Comment input */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add your comment or instruction..."
|
||||
className="min-h-[72px] resize-none text-sm border-border/50 focus:border-primary/50"
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
⌘+Enter to add
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
className="gap-1.5 cursor-pointer"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Add comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { IconChevronRight, IconChevronDown, IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { IconChevronRight, IconChevronDown, IconFile, IconFolder, IconFolderOpen, IconSearch, IconX, IconLoader2, IconTrash } from '@tabler/icons-react';
|
||||
import { ScrollArea } from '@kandev/ui/scroll-area';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { requestFileTree, requestFileContent } from '@/lib/ws/workspace-files';
|
||||
import { requestFileTree, requestFileContent, searchWorkspaceFiles } from '@/lib/ws/workspace-files';
|
||||
import type { FileTreeNode, FileContentResponse, OpenFileTab } from '@/lib/types/backend';
|
||||
import {
|
||||
getFilesPanelExpandedPaths,
|
||||
setFilesPanelExpandedPaths,
|
||||
getFilesPanelScrollPosition,
|
||||
setFilesPanelScrollPosition,
|
||||
} from '@/lib/local-storage';
|
||||
|
||||
type FileBrowserProps = {
|
||||
sessionId: string;
|
||||
onOpenFile: (file: OpenFileTab) => void;
|
||||
onDeleteFile?: (path: string) => void;
|
||||
isSearchOpen?: boolean;
|
||||
onCloseSearch?: () => void;
|
||||
activeFilePath?: string | null;
|
||||
};
|
||||
|
||||
export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
|
||||
export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen = false, onCloseSearch, activeFilePath }: FileBrowserProps) {
|
||||
const [tree, setTree] = useState<FileTreeNode | null>(null);
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
|
||||
const [isLoadingTree, setIsLoadingTree] = useState(true);
|
||||
|
||||
// Search state
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<string[] | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Scroll persistence
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const scrollSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasRestoredScrollRef = useRef<string | null>(null);
|
||||
const hasInitializedExpandedRef = useRef<string | null>(null);
|
||||
|
||||
// Focus search input when search opens
|
||||
useEffect(() => {
|
||||
if (isSearchOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isSearchOpen]);
|
||||
|
||||
// Clear search when closing
|
||||
useEffect(() => {
|
||||
if (!isSearchOpen) {
|
||||
setLocalSearchQuery('');
|
||||
setSearchResults(null);
|
||||
setIsSearching(false);
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
}
|
||||
}, [isSearchOpen]);
|
||||
|
||||
// Load initial tree - always try to load, don't wait for agentctl ready status
|
||||
useEffect(() => {
|
||||
setTree(null);
|
||||
setExpandedPaths(new Set());
|
||||
setLoadingPaths(new Set());
|
||||
setIsLoadingTree(true);
|
||||
// Reset the auto-expand ref so it will run for the new session
|
||||
hasInitializedExpandedRef.current = null;
|
||||
|
||||
// Restore expanded paths from session storage or reset
|
||||
const savedPaths = getFilesPanelExpandedPaths(sessionId);
|
||||
if (savedPaths.length > 0) {
|
||||
setExpandedPaths(new Set(savedPaths));
|
||||
// Mark as initialized only if we have saved paths to restore
|
||||
hasInitializedExpandedRef.current = sessionId;
|
||||
} else {
|
||||
setExpandedPaths(new Set());
|
||||
// Leave hasInitializedExpandedRef.current as null so auto-expand will run
|
||||
}
|
||||
|
||||
const loadTree = async () => {
|
||||
try {
|
||||
@@ -42,6 +98,51 @@ export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
|
||||
loadTree();
|
||||
}, [sessionId]);
|
||||
|
||||
// Auto-expand first level folders and load their children
|
||||
// Note: We intentionally use tree?.children?.length instead of tree to avoid re-running when tree updates
|
||||
useEffect(() => {
|
||||
if (!tree || isLoadingTree) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only auto-expand if we haven't initialized from saved state
|
||||
if (hasInitializedExpandedRef.current === sessionId) {
|
||||
return;
|
||||
}
|
||||
hasInitializedExpandedRef.current = sessionId;
|
||||
|
||||
// Only expand the root folder to show its immediate children
|
||||
setExpandedPaths(new Set([tree.path]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tree?.children?.length, isLoadingTree, sessionId]);
|
||||
|
||||
// Save expanded paths when they change
|
||||
useEffect(() => {
|
||||
if (expandedPaths.size > 0 || hasInitializedExpandedRef.current === sessionId) {
|
||||
setFilesPanelExpandedPaths(sessionId, Array.from(expandedPaths));
|
||||
}
|
||||
}, [expandedPaths, sessionId]);
|
||||
|
||||
// Track if tree is loaded (used for scroll restore timing)
|
||||
const isTreeLoaded = !isLoadingTree && tree !== null;
|
||||
|
||||
// Restore scroll position after tree loads
|
||||
useEffect(() => {
|
||||
if (!isTreeLoaded || hasRestoredScrollRef.current === sessionId) return;
|
||||
|
||||
const savedScroll = getFilesPanelScrollPosition(sessionId);
|
||||
if (savedScroll > 0 && scrollAreaRef.current) {
|
||||
// Find the viewport element within ScrollArea
|
||||
const viewport = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
|
||||
if (viewport) {
|
||||
viewport.scrollTop = savedScroll;
|
||||
hasRestoredScrollRef.current = sessionId;
|
||||
}
|
||||
} else {
|
||||
hasRestoredScrollRef.current = sessionId;
|
||||
}
|
||||
}, [isTreeLoaded, sessionId]);
|
||||
|
||||
// Subscribe to file changes and refresh tree
|
||||
useEffect(() => {
|
||||
const client = getWebSocketClient();
|
||||
@@ -60,6 +161,89 @@ export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
|
||||
return unsubscribe;
|
||||
}, [sessionId]);
|
||||
|
||||
// Handle scroll events to save position
|
||||
const handleScroll = useCallback((event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const scrollTop = target.scrollTop;
|
||||
|
||||
// Debounce save
|
||||
if (scrollSaveTimeoutRef.current) {
|
||||
clearTimeout(scrollSaveTimeoutRef.current);
|
||||
}
|
||||
scrollSaveTimeoutRef.current = setTimeout(() => {
|
||||
setFilesPanelScrollPosition(sessionId, scrollTop);
|
||||
}, 150);
|
||||
}, [sessionId]);
|
||||
|
||||
// Attach scroll listener to ScrollArea viewport
|
||||
useEffect(() => {
|
||||
const scrollArea = scrollAreaRef.current;
|
||||
if (!scrollArea) return;
|
||||
|
||||
const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]');
|
||||
if (!viewport) return;
|
||||
|
||||
viewport.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
viewport.removeEventListener('scroll', handleScroll);
|
||||
if (scrollSaveTimeoutRef.current) {
|
||||
clearTimeout(scrollSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [handleScroll, tree]);
|
||||
|
||||
// Handle search input change with debounce
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setLocalSearchQuery(value);
|
||||
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (!value.trim()) {
|
||||
setSearchResults(null);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
const response = await searchWorkspaceFiles(client, sessionId, value, 50);
|
||||
setSearchResults(response.files || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to search files:', error);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
}, [sessionId]);
|
||||
|
||||
// Close search (clears and notifies parent)
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
setLocalSearchQuery('');
|
||||
setSearchResults(null);
|
||||
setIsSearching(false);
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
onCloseSearch?.();
|
||||
}, [onCloseSearch]);
|
||||
|
||||
// Cleanup search timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleExpand = useCallback(async (node: FileTreeNode) => {
|
||||
if (!node.is_dir) return;
|
||||
|
||||
@@ -134,42 +318,85 @@ export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
|
||||
}
|
||||
}, [sessionId, onOpenFile]);
|
||||
|
||||
// Open file from search result (path only)
|
||||
const openFileByPath = useCallback(async (path: string) => {
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
const response: FileContentResponse = await requestFileContent(client, sessionId, path);
|
||||
|
||||
// Calculate hash for the file content
|
||||
const { calculateHash } = await import('@/lib/utils/file-diff');
|
||||
const hash = await calculateHash(response.content);
|
||||
|
||||
// Extract filename from path
|
||||
const name = path.split('/').pop() || path;
|
||||
|
||||
onOpenFile({
|
||||
path,
|
||||
name,
|
||||
content: response.content,
|
||||
originalContent: response.content,
|
||||
originalHash: hash,
|
||||
isDirty: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load file content:', error);
|
||||
}
|
||||
}, [sessionId, onOpenFile]);
|
||||
|
||||
const renderTreeNode = (node: FileTreeNode, depth: number = 0): React.ReactNode => {
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isLoading = loadingPaths.has(node.path);
|
||||
const isActive = !node.is_dir && activeFilePath === node.path;
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className="flex items-center gap-1 py-1 px-2 hover:bg-accent cursor-pointer rounded text-sm"
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
className={`group flex items-center gap-1 py-1 px-2 hover:bg-muted/50 cursor-pointer rounded text-sm ${isActive ? 'bg-muted/50' : ''}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8 + (node.is_dir ? 0 : 20)}px` }}
|
||||
onClick={() => (node.is_dir ? toggleExpand(node) : openFile(node))}
|
||||
>
|
||||
{node.is_dir && (
|
||||
<span className="flex-shrink-0">
|
||||
{isLoading ? (
|
||||
<span className="animate-spin">⏳</span>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : isExpanded ? (
|
||||
<IconChevronDown className="h-4 w-4" />
|
||||
<IconChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
<IconChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{node.is_dir ? (
|
||||
isExpanded ? (
|
||||
<IconFolderOpen className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
||||
<IconFolderOpen className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<IconFolder className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
||||
<IconFolder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
)
|
||||
) : (
|
||||
<IconFile className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<IconFile className={`h-3 w-3 flex-shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`} />
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{!node.is_dir && node.size && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{(node.size / 1024).toFixed(1)}KB
|
||||
</span>
|
||||
<span className={`flex-1 truncate ${isActive ? 'text-foreground' : 'text-muted-foreground'} group-hover:text-foreground ${node.is_dir ? 'font-medium' : ''}`}>{node.name}</span>
|
||||
{!node.is_dir && onDeleteFile && (
|
||||
<div className="flex items-center gap-1 ml-auto opacity-0 group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteFile(node.path);
|
||||
}}
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete file</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{node.is_dir && isExpanded && node.children && (
|
||||
@@ -188,10 +415,74 @@ export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const renderSearchResults = () => {
|
||||
if (!searchResults) return null;
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground text-center">
|
||||
No files found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
{searchResults.map((path) => {
|
||||
const name = path.split('/').pop() || path;
|
||||
const folder = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '';
|
||||
return (
|
||||
<div
|
||||
key={path}
|
||||
className="group flex items-center gap-1 py-1 px-2 hover:bg-muted/50 cursor-pointer rounded text-sm"
|
||||
onClick={() => openFileByPath(path)}
|
||||
>
|
||||
<IconFile className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-muted-foreground group-hover:text-foreground">
|
||||
{folder && <span>{folder}/</span>}
|
||||
<span>{name}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ScrollArea className="flex-1">
|
||||
{isLoadingTree ? (
|
||||
{/* Search input - only shown when search is open */}
|
||||
{isSearchOpen && (
|
||||
<div className="px-2 pb-2">
|
||||
<div className="relative">
|
||||
{isSearching ? (
|
||||
<IconLoader2 className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none animate-spin" />
|
||||
) : (
|
||||
<IconSearch className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
)}
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={localSearchQuery}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search files..."
|
||||
className="pl-7 pr-7 h-7 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseSearch}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1" ref={scrollAreaRef}>
|
||||
{isSearchOpen && searchResults !== null ? (
|
||||
renderSearchResults()
|
||||
) : isLoadingTree ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading files...</div>
|
||||
) : tree ? (
|
||||
<div className="pb-2">{renderTreeNode(tree)}</div>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { vscodeDark } from '@uiw/codemirror-theme-vscode';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { EditorView, gutter, GutterMarker, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { Decoration, type DecorationSet } from '@codemirror/view';
|
||||
import { getCodeMirrorExtensionFromPath } from '@/lib/languages';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons-react';
|
||||
import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus } from '@tabler/icons-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { formatDiffStats } from '@/lib/utils/file-diff';
|
||||
import { vscodeDark } from '@uiw/codemirror-theme-vscode';
|
||||
import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { EditorCommentPopover } from './editor-comment-popover';
|
||||
import { CommentViewPopover } from './comment-view-popover';
|
||||
|
||||
type FileEditorContentProps = {
|
||||
path: string;
|
||||
@@ -16,60 +23,388 @@ type FileEditorContentProps = {
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
sessionId?: string;
|
||||
enableComments?: boolean;
|
||||
onChange: (newContent: string) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
type TextSelection = {
|
||||
text: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
position: { x: number; y: number };
|
||||
} | null;
|
||||
|
||||
type FloatingButtonPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
type CommentViewState = {
|
||||
comments: DiffComment[];
|
||||
position: { x: number; y: number };
|
||||
} | null;
|
||||
|
||||
// Create comment line decorations
|
||||
function createCommentDecorations(
|
||||
view: EditorView,
|
||||
comments: DiffComment[]
|
||||
): DecorationSet {
|
||||
if (comments.length === 0) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
const decorations: Array<{ from: number; decoration: Decoration }> = [];
|
||||
|
||||
// Get all lines that have comments
|
||||
const linesWithComments = new Set<number>();
|
||||
for (const comment of comments) {
|
||||
for (let line = comment.startLine; line <= comment.endLine; line++) {
|
||||
linesWithComments.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Create line decorations for commented lines
|
||||
for (const lineNum of linesWithComments) {
|
||||
if (lineNum > view.state.doc.lines) continue;
|
||||
const line = view.state.doc.line(lineNum);
|
||||
decorations.push({
|
||||
from: line.from,
|
||||
decoration: Decoration.line({
|
||||
class: 'cm-comment-line',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
decorations.sort((a, b) => a.from - b.from);
|
||||
return Decoration.set(decorations.map(d => d.decoration.range(d.from)));
|
||||
}
|
||||
|
||||
// Gutter marker for lines with comments - defined outside component to avoid re-creation
|
||||
class CommentGutterMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly lineComments: DiffComment[],
|
||||
readonly isFirstLine: boolean
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'cm-comment-gutter-marker';
|
||||
marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? 's' : ''} - click to view`;
|
||||
|
||||
// Add icon only on the first line of a comment
|
||||
if (this.isFirstLine) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '12');
|
||||
svg.setAttribute('height', '12');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'rgba(99, 102, 241, 0.9)');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z');
|
||||
svg.appendChild(path);
|
||||
marker.appendChild(svg);
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
eq(other: CommentGutterMarker) {
|
||||
return this.lineComments.length === other.lineComments.length && this.isFirstLine === other.isFirstLine;
|
||||
}
|
||||
}
|
||||
|
||||
export function FileEditorContent({
|
||||
path,
|
||||
content,
|
||||
originalContent,
|
||||
isDirty,
|
||||
isSaving,
|
||||
sessionId,
|
||||
enableComments = false,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: FileEditorContentProps) {
|
||||
const [wrapEnabled, setWrapEnabled] = useState(true);
|
||||
const [textSelection, setTextSelection] = useState<TextSelection>(null);
|
||||
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
|
||||
const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
|
||||
const [commentView, setCommentView] = useState<CommentViewState>(null);
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const { toast } = useToast();
|
||||
|
||||
// Comment store
|
||||
const addComment = useDiffCommentsStore((state) => state.addComment);
|
||||
const removeComment = useDiffCommentsStore((state) => state.removeComment);
|
||||
const comments = useFileComments(sessionId ?? '', path);
|
||||
|
||||
const langExt = getCodeMirrorExtensionFromPath(path);
|
||||
const extensions: Extension[] = [
|
||||
EditorView.lineWrapping,
|
||||
EditorView.editable.of(true),
|
||||
];
|
||||
if (langExt) {
|
||||
extensions.push(langExt);
|
||||
}
|
||||
|
||||
// Create the comment decoration plugin
|
||||
const commentPlugin = useMemo(() => {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = createCommentDecorations(view, comments);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = createCommentDecorations(update.view, comments);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
}, [comments]);
|
||||
|
||||
// Create comment gutter
|
||||
const commentGutter = useMemo(() => {
|
||||
// Build a map of line number to comments that include that line
|
||||
const commentsByLine = new Map<number, DiffComment[]>();
|
||||
// Track which lines are the first line of at least one comment
|
||||
const firstLines = new Set<number>();
|
||||
|
||||
for (const comment of comments) {
|
||||
firstLines.add(comment.startLine);
|
||||
// Add to all lines in the range
|
||||
for (let lineNum = comment.startLine; lineNum <= comment.endLine; lineNum++) {
|
||||
const existing = commentsByLine.get(lineNum) || [];
|
||||
existing.push(comment);
|
||||
commentsByLine.set(lineNum, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return gutter({
|
||||
class: 'cm-comment-gutter',
|
||||
lineMarker: (view, line) => {
|
||||
const lineNum = view.state.doc.lineAt(line.from).number;
|
||||
const lineComments = commentsByLine.get(lineNum);
|
||||
if (lineComments && lineComments.length > 0) {
|
||||
return new CommentGutterMarker(lineComments, firstLines.has(lineNum));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
domEventHandlers: {
|
||||
click: (view, line, event) => {
|
||||
const lineNum = view.state.doc.lineAt(line.from).number;
|
||||
const lineComments = commentsByLine.get(lineNum);
|
||||
if (lineComments && lineComments.length > 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCommentView({
|
||||
comments: lineComments,
|
||||
position: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [comments]);
|
||||
|
||||
// Track mouse position
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
mousePositionRef.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
return () => document.removeEventListener('mousemove', handleMouseMove);
|
||||
}, []);
|
||||
|
||||
// Show floating button after selection ends (on mouseup)
|
||||
const handleSelectionEnd = useCallback(() => {
|
||||
if (!enableComments || !sessionId) return;
|
||||
|
||||
const view = editorRef.current?.view;
|
||||
if (!view) return;
|
||||
|
||||
const selection = view.state.selection.main;
|
||||
if (selection.empty) {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = view.state.sliceDoc(selection.from, selection.to);
|
||||
if (!selectedText.trim()) {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get line numbers
|
||||
const startLine = view.state.doc.lineAt(selection.from).number;
|
||||
const endLine = view.state.doc.lineAt(selection.to).number;
|
||||
|
||||
// Use mouse position for the floating button (where cursor is)
|
||||
setCurrentSelection({ text: selectedText, startLine, endLine });
|
||||
setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
|
||||
}, [enableComments, sessionId]);
|
||||
|
||||
// Listen for mouseup to show floating button after selection ends
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper || !enableComments || !sessionId) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
// Don't show if clicking on the floating button itself
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
|
||||
return;
|
||||
}
|
||||
// Small delay to let selection finalize
|
||||
setTimeout(handleSelectionEnd, 10);
|
||||
};
|
||||
|
||||
// Clear selection state when clicking (new selection starts)
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Don't clear if clicking on the floating button
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
|
||||
return;
|
||||
}
|
||||
setFloatingButtonPos(null);
|
||||
};
|
||||
|
||||
wrapper.addEventListener('mouseup', handleMouseUp);
|
||||
wrapper.addEventListener('mousedown', handleMouseDown);
|
||||
return () => {
|
||||
wrapper.removeEventListener('mouseup', handleMouseUp);
|
||||
wrapper.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, [enableComments, sessionId, handleSelectionEnd]);
|
||||
|
||||
// Clear floating button when selection is cleared via keyboard
|
||||
const selectionUpdateExtension = useMemo(() => {
|
||||
return EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet) {
|
||||
const selection = update.state.selection.main;
|
||||
if (selection.empty) {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const extensions: Extension[] = useMemo(() => {
|
||||
const exts: Extension[] = [
|
||||
EditorView.editable.of(true),
|
||||
EditorView.theme({
|
||||
'&': { backgroundColor: 'hsl(var(--background)) !important' },
|
||||
'.cm-gutters': { backgroundColor: 'hsl(var(--background)) !important', borderRight: 'none' },
|
||||
// Comment gutter
|
||||
'.cm-comment-gutter': {
|
||||
width: '22px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-comment-gutter .cm-gutterElement': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0',
|
||||
},
|
||||
// Marker styles - background color on the marker itself (indigo)
|
||||
'.cm-comment-gutter-marker': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.2)',
|
||||
},
|
||||
'.cm-comment-gutter-marker:hover': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.35)',
|
||||
},
|
||||
// Comment line highlight with left border (indigo)
|
||||
'.cm-comment-line': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
|
||||
borderLeft: '3px solid rgba(99, 102, 241, 0.6)',
|
||||
marginLeft: '-3px',
|
||||
paddingLeft: '3px',
|
||||
},
|
||||
}),
|
||||
commentGutter,
|
||||
commentPlugin,
|
||||
selectionUpdateExtension,
|
||||
];
|
||||
if (wrapEnabled) {
|
||||
exts.push(EditorView.lineWrapping);
|
||||
}
|
||||
if (langExt) {
|
||||
exts.push(langExt);
|
||||
}
|
||||
return exts;
|
||||
}, [langExt, wrapEnabled, commentGutter, commentPlugin, selectionUpdateExtension]);
|
||||
|
||||
// Calculate diff stats when dirty
|
||||
const diffStats = useMemo(() => {
|
||||
if (!isDirty) return null;
|
||||
|
||||
// Simple line-based diff for stats
|
||||
|
||||
const originalLines = originalContent.split('\n');
|
||||
const currentLines = content.split('\n');
|
||||
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
// Simple comparison - count different lines
|
||||
|
||||
const maxLen = Math.max(originalLines.length, currentLines.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const origLine = originalLines[i];
|
||||
const currLine = currentLines[i];
|
||||
|
||||
|
||||
if (origLine === undefined && currLine !== undefined) {
|
||||
additions++;
|
||||
} else if (origLine !== undefined && currLine === undefined) {
|
||||
deletions++;
|
||||
} else if (origLine !== currLine) {
|
||||
// Line changed - count as both addition and deletion
|
||||
additions++;
|
||||
deletions++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { additions, deletions };
|
||||
}, [isDirty, content, originalContent]);
|
||||
|
||||
// Handle Cmd/Ctrl+S keyboard shortcut
|
||||
// Handle Cmd+I to open comment popover
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper || !enableComments || !sessionId) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
|
||||
if (!currentSelection || !floatingButtonPos) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setTextSelection({
|
||||
...currentSelection,
|
||||
position: floatingButtonPos,
|
||||
});
|
||||
setFloatingButtonPos(null);
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [enableComments, sessionId, currentSelection, floatingButtonPos]);
|
||||
|
||||
// Handle Cmd/Ctrl+S keyboard shortcut and Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
@@ -78,11 +413,16 @@ export function FileEditorContent({
|
||||
onSave();
|
||||
}
|
||||
}
|
||||
// Escape to close popovers
|
||||
if (e.key === 'Escape') {
|
||||
if (textSelection) setTextSelection(null);
|
||||
if (commentView) setCommentView(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isDirty, isSaving, onSave]);
|
||||
}, [isDirty, isSaving, onSave, textSelection, commentView]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
@@ -91,11 +431,91 @@ export function FileEditorContent({
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// Open comment popover from floating button
|
||||
const handleFloatingButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!currentSelection || !floatingButtonPos) return;
|
||||
|
||||
setTextSelection({
|
||||
...currentSelection,
|
||||
position: floatingButtonPos,
|
||||
});
|
||||
setFloatingButtonPos(null);
|
||||
}, [currentSelection, floatingButtonPos]);
|
||||
|
||||
// Submit comment
|
||||
const handleCommentSubmit = useCallback((annotation: string) => {
|
||||
if (!textSelection || !sessionId) return;
|
||||
|
||||
const { text, startLine, endLine } = textSelection;
|
||||
|
||||
const comment: DiffComment = {
|
||||
id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sessionId,
|
||||
filePath: path,
|
||||
startLine,
|
||||
endLine,
|
||||
side: 'additions',
|
||||
codeContent: text,
|
||||
annotation,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
addComment(comment);
|
||||
setTextSelection(null);
|
||||
|
||||
// Clear selection in editor
|
||||
const view = editorRef.current?.view;
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
selection: { anchor: view.state.selection.main.head },
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Comment added',
|
||||
description: 'Your comment will be sent with your next message.',
|
||||
});
|
||||
}, [textSelection, sessionId, path, addComment, toast]);
|
||||
|
||||
// Close popover
|
||||
const handlePopoverClose = useCallback(() => {
|
||||
setTextSelection(null);
|
||||
}, []);
|
||||
|
||||
// Delete comment
|
||||
const handleDeleteComment = useCallback((commentId: string) => {
|
||||
if (!sessionId) return;
|
||||
removeComment(sessionId, path, commentId);
|
||||
|
||||
// Close the view if no more comments
|
||||
if (commentView && commentView.comments.length <= 1) {
|
||||
setCommentView(null);
|
||||
} else if (commentView) {
|
||||
setCommentView({
|
||||
...commentView,
|
||||
comments: commentView.comments.filter(c => c.id !== commentId),
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Comment deleted',
|
||||
});
|
||||
}, [sessionId, path, removeComment, commentView, toast]);
|
||||
|
||||
// Close comment view
|
||||
const handleCommentViewClose = useCallback(() => {
|
||||
setCommentView(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div ref={wrapperRef} className="flex h-full flex-col bg-background rounded-lg">
|
||||
{/* Editor header with save button */}
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-4 py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-2 border-foreground/10 border-b">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{path}</span>
|
||||
{isDirty && diffStats && (
|
||||
<span className="text-xs text-yellow-500">
|
||||
@@ -103,33 +523,70 @@ export function FileEditorContent({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={onSave}
|
||||
disabled={!isDirty || isSaving}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDeviceFloppy className="h-4 w-4" />
|
||||
Save
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+S)
|
||||
</span>
|
||||
</>
|
||||
<div className="flex items-center gap-1">
|
||||
{enableComments && sessionId && comments.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWrapEnabled(!wrapEnabled)}
|
||||
className={`h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{wrapEnabled ? <IconTextWrap className="h-4 w-4" /> : <IconTextWrapDisabled className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{onDelete && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete file</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={onSave}
|
||||
disabled={!isDirty || isSaving}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDeviceFloppy className="h-4 w-4" />
|
||||
Save
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+S)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CodeMirror editor */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
height="100%"
|
||||
theme={vscodeDark}
|
||||
@@ -140,11 +597,50 @@ export function FileEditorContent({
|
||||
foldGutter: true,
|
||||
highlightActiveLine: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
className="h-full overflow-auto text-sm"
|
||||
className="h-full overflow-auto text-xs"
|
||||
/>
|
||||
|
||||
{/* Floating "Add comment" button when text is selected */}
|
||||
{floatingButtonPos && !textSelection && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
|
||||
style={{
|
||||
left: floatingButtonPos.x + 8,
|
||||
top: floatingButtonPos.y + 8,
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={handleFloatingButtonClick}
|
||||
>
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
Comment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Comment popover for adding new comment */}
|
||||
{textSelection && (
|
||||
<EditorCommentPopover
|
||||
selectedText={textSelection.text}
|
||||
lineRange={{ start: textSelection.startLine, end: textSelection.endLine }}
|
||||
position={textSelection.position}
|
||||
onSubmit={handleCommentSubmit}
|
||||
onClose={handlePopoverClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comment view popover for viewing/deleting existing comments */}
|
||||
{commentView && (
|
||||
<CommentViewPopover
|
||||
comments={commentView.comments}
|
||||
position={commentView.position}
|
||||
onDelete={handleDeleteComment}
|
||||
onClose={handleCommentViewClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export function FileViewerContent({ path, content }: FileViewerContentProps) {
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: true,
|
||||
}}
|
||||
className="h-full overflow-auto text-sm"
|
||||
className="h-full overflow-auto text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ type MilkdownEditorProps = {
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
onSelectionChange?: (selection: TextSelection | null) => void;
|
||||
/** Called when selection ends (mouseup) with selected text - for showing floating button */
|
||||
onSelectionEnd?: (selection: TextSelection | null) => void;
|
||||
comments?: CommentHighlight[];
|
||||
onCommentClick?: (id: string, position: { x: number; y: number }) => void;
|
||||
};
|
||||
@@ -349,6 +351,7 @@ function MilkdownEditorInner({
|
||||
onChange,
|
||||
placeholder = 'Start typing...',
|
||||
onSelectionChange,
|
||||
onSelectionEnd,
|
||||
comments = [],
|
||||
onCommentClick,
|
||||
}: MilkdownEditorProps) {
|
||||
@@ -359,6 +362,7 @@ function MilkdownEditorInner({
|
||||
const placeholderRef = useRef(placeholder);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
const onSelectionEndRef = useRef(onSelectionEnd);
|
||||
const commentsRef = useRef(comments);
|
||||
const onCommentClickRef = useRef(onCommentClick);
|
||||
|
||||
@@ -371,6 +375,10 @@ function MilkdownEditorInner({
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
}, [onSelectionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionEndRef.current = onSelectionEnd;
|
||||
}, [onSelectionEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
commentsRef.current = comments;
|
||||
}, [comments]);
|
||||
@@ -399,6 +407,57 @@ function MilkdownEditorInner({
|
||||
return () => wrapper.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
// Handle mouseup to show floating button when selection ends
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!onSelectionEndRef.current) return;
|
||||
|
||||
// Capture mouse position immediately
|
||||
const mouseX = e.clientX;
|
||||
const mouseY = e.clientY;
|
||||
|
||||
// Small delay to let selection finalize
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||
onSelectionEndRef.current?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = selection.toString().trim();
|
||||
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
|
||||
onSelectionEndRef.current?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use mouse position (where cursor is) instead of selection end
|
||||
onSelectionEndRef.current?.({
|
||||
text: selectedText,
|
||||
position: { x: mouseX, y: mouseY },
|
||||
});
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Don't clear if clicking on the floating button
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
|
||||
return;
|
||||
}
|
||||
// Clear selection state when starting new selection
|
||||
onSelectionEndRef.current?.(null);
|
||||
};
|
||||
|
||||
wrapper.addEventListener('mouseup', handleMouseUp);
|
||||
wrapper.addEventListener('mousedown', handleMouseDown);
|
||||
return () => {
|
||||
wrapper.removeEventListener('mouseup', handleMouseUp);
|
||||
wrapper.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle Cmd+I to trigger selection popover (overrides italic formatting)
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
|
||||
@@ -107,7 +107,6 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
|
||||
<TaskChatPanel
|
||||
sessionId={sessionId}
|
||||
onOpenFile={handleOpenFileFromChat}
|
||||
onSelectDiff={handleSelectDiffAndSwitchPanel}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -17,6 +17,7 @@ type PlanSelectionPopoverProps = {
|
||||
onAdd: (comment: string, selectedText: string) => void;
|
||||
onClose: () => void;
|
||||
editingComment?: string; // Pre-fill if editing existing comment
|
||||
onDelete?: () => void; // Delete callback when editing existing comment
|
||||
};
|
||||
|
||||
export function PlanSelectionPopover({
|
||||
@@ -25,6 +26,7 @@ export function PlanSelectionPopover({
|
||||
onAdd,
|
||||
onClose,
|
||||
editingComment,
|
||||
onDelete,
|
||||
}: PlanSelectionPopoverProps) {
|
||||
const [comment, setComment] = useState(editingComment || '');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -134,14 +136,29 @@ export function PlanSelectionPopover({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
⌘+Enter to add
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
⌘+Enter to {editingComment ? 'update' : 'add'}
|
||||
</span>
|
||||
{editingComment && onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
onClose();
|
||||
}}
|
||||
className="h-7 px-2 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
className="gap-1.5"
|
||||
className="gap-1.5 cursor-pointer"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
{editingComment ? 'Update' : 'Add comment'}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import { memo, useCallback, useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { IconSparkles, IconCheck, IconChevronDown, IconX } from '@tabler/icons-react';
|
||||
import { TabsContent } from '@kandev/ui/tabs';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
@@ -18,13 +18,12 @@ import { TaskPlanPanel } from './task-plan-panel';
|
||||
import { FileEditorContent } from './file-editor-content';
|
||||
import { PassthroughTerminal } from './passthrough-terminal';
|
||||
import type { OpenFileTab, FileContentResponse } from '@/lib/types/backend';
|
||||
import { FILE_EXTENSION_COLORS } from '@/lib/types/backend';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
|
||||
import { approveSessionAction } from '@/app/actions/workspaces';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { requestFileContent, updateFileContent } from '@/lib/ws/workspace-files';
|
||||
import { getPlanLastSeen, setPlanLastSeen, getCenterPanelTab, setCenterPanelTab } from '@/lib/local-storage';
|
||||
import { requestFileContent, updateFileContent, deleteFile } from '@/lib/ws/workspace-files';
|
||||
import { getPlanLastSeen, setPlanLastSeen, getOpenFileTabs, setOpenFileTabs as saveOpenFileTabs, getActiveTabForSession, setActiveTabForSession } from '@/lib/local-storage';
|
||||
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
|
||||
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
|
||||
import { generateUnifiedDiff, calculateHash } from '@/lib/utils/file-diff';
|
||||
@@ -37,6 +36,7 @@ type TaskCenterPanelProps = {
|
||||
openFileRequest: OpenFileTab | null;
|
||||
onDiffHandled: () => void;
|
||||
onFileOpenHandled: () => void;
|
||||
onActiveFileChange?: (filePath: string | null) => void;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
openFileRequest,
|
||||
onDiffHandled,
|
||||
onFileOpenHandled,
|
||||
onActiveFileChange,
|
||||
sessionId = null,
|
||||
}: TaskCenterPanelProps) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
@@ -114,11 +115,17 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
return hasUncommittedChanges || hasCommits;
|
||||
}, [gitStatus, commits]);
|
||||
|
||||
// Initialize tab from localStorage, defaulting to 'chat'
|
||||
// If saved tab is 'changes' but there are no changes, fall back to 'chat'
|
||||
// Initialize tab - restored from sessionStorage or default to 'chat'
|
||||
const [leftTab, setLeftTab] = useState(() => {
|
||||
const savedTab = getCenterPanelTab('chat');
|
||||
return savedTab;
|
||||
// Try to restore from session-specific storage on initial render
|
||||
if (typeof window !== 'undefined' && activeSessionId) {
|
||||
const savedTab = getActiveTabForSession(activeSessionId, 'chat');
|
||||
// Only return non-file tabs synchronously, file tabs need async content loading
|
||||
if (savedTab === 'chat' || savedTab === 'plan' || savedTab === 'changes') {
|
||||
return savedTab;
|
||||
}
|
||||
}
|
||||
return 'chat';
|
||||
});
|
||||
|
||||
// If current tab is 'changes' but there are no changes, switch to 'chat'
|
||||
@@ -143,8 +150,171 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(null);
|
||||
const [openFileTabs, setOpenFileTabs] = useState<OpenFileTab[]>([]);
|
||||
const [savingFiles, setSavingFiles] = useState<Set<string>>(new Set());
|
||||
const restoredTabsRef = useRef<string | null>(null);
|
||||
const restorationInProgressRef = useRef<boolean>(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Save current active tab for the current session before switching
|
||||
const prevSessionRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
// On unmount or before session change, save the current tab for the previous session
|
||||
return () => {
|
||||
if (prevSessionRef.current && leftTab) {
|
||||
setActiveTabForSession(prevSessionRef.current, leftTab);
|
||||
}
|
||||
};
|
||||
}, [leftTab]);
|
||||
|
||||
// Restore file tabs and active tab from sessionStorage on mount/session change
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
|
||||
// When session changes, always clear existing tabs first and reset state
|
||||
if (restoredTabsRef.current !== activeSessionId) {
|
||||
// Save the active tab for the OLD session before switching
|
||||
if (prevSessionRef.current && prevSessionRef.current !== activeSessionId) {
|
||||
setActiveTabForSession(prevSessionRef.current, leftTab);
|
||||
}
|
||||
|
||||
restoredTabsRef.current = activeSessionId;
|
||||
prevSessionRef.current = activeSessionId;
|
||||
restorationInProgressRef.current = false;
|
||||
|
||||
// Clear existing file tabs immediately when session changes
|
||||
setOpenFileTabs([]);
|
||||
} else if (restorationInProgressRef.current) {
|
||||
// Already restoring for this session
|
||||
return;
|
||||
} else {
|
||||
// Already restored for this session
|
||||
return;
|
||||
}
|
||||
|
||||
const savedTabs = getOpenFileTabs(activeSessionId);
|
||||
const savedActiveTab = getActiveTabForSession(activeSessionId, 'chat');
|
||||
|
||||
// If no file tabs to load, just restore the active tab
|
||||
if (savedTabs.length === 0) {
|
||||
// Only set if it's a valid non-file tab
|
||||
if (savedActiveTab === 'chat' || savedActiveTab === 'plan' || savedActiveTab === 'changes') {
|
||||
setLeftTab(savedActiveTab);
|
||||
} else {
|
||||
setLeftTab('chat');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark restoration as in progress
|
||||
restorationInProgressRef.current = true;
|
||||
|
||||
// Load content for each saved tab with retry for WebSocket client
|
||||
const loadTabs = async (retryCount = 0): Promise<void> => {
|
||||
const maxRetries = 5;
|
||||
const retryDelay = 200;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) {
|
||||
if (retryCount < maxRetries) {
|
||||
setTimeout(() => loadTabs(retryCount + 1), retryDelay);
|
||||
return;
|
||||
}
|
||||
restorationInProgressRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify we're still restoring for the same session
|
||||
if (restoredTabsRef.current !== activeSessionId) {
|
||||
restorationInProgressRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedTabs: OpenFileTab[] = [];
|
||||
for (const savedTab of savedTabs) {
|
||||
try {
|
||||
const response = await requestFileContent(client, activeSessionId, savedTab.path);
|
||||
const hash = await calculateHash(response.content);
|
||||
loadedTabs.push({
|
||||
path: savedTab.path,
|
||||
name: savedTab.name,
|
||||
content: response.content,
|
||||
originalContent: response.content,
|
||||
originalHash: hash,
|
||||
isDirty: false,
|
||||
});
|
||||
} catch {
|
||||
// Failed to restore tab, skip it
|
||||
}
|
||||
}
|
||||
|
||||
// Verify session hasn't changed
|
||||
if (restoredTabsRef.current !== activeSessionId) {
|
||||
restorationInProgressRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedTabs.length > 0) {
|
||||
setOpenFileTabs(loadedTabs);
|
||||
|
||||
// Restore active tab - check if it's a file tab that was loaded
|
||||
if (savedActiveTab.startsWith('file:')) {
|
||||
const filePath = savedActiveTab.replace('file:', '');
|
||||
const tabExists = loadedTabs.some(t => t.path === filePath);
|
||||
if (tabExists) {
|
||||
// Use setTimeout to ensure React has processed setOpenFileTabs
|
||||
setTimeout(() => {
|
||||
setLeftTab(savedActiveTab);
|
||||
restorationInProgressRef.current = false;
|
||||
}, 0);
|
||||
} else {
|
||||
setLeftTab('chat');
|
||||
restorationInProgressRef.current = false;
|
||||
}
|
||||
} else {
|
||||
setLeftTab(savedActiveTab);
|
||||
restorationInProgressRef.current = false;
|
||||
}
|
||||
} else {
|
||||
// No tabs could be loaded, fall back to non-file tab
|
||||
if (savedActiveTab === 'chat' || savedActiveTab === 'plan' || savedActiveTab === 'changes') {
|
||||
setLeftTab(savedActiveTab);
|
||||
} else {
|
||||
setLeftTab('chat');
|
||||
}
|
||||
restorationInProgressRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void loadTabs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- leftTab is intentionally excluded to prevent re-running on tab changes
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Persist open file tabs to sessionStorage when they change
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
const tabsToSave = openFileTabs.map(({ path, name }) => ({ path, name }));
|
||||
saveOpenFileTabs(activeSessionId, tabsToSave);
|
||||
}, [activeSessionId, openFileTabs]);
|
||||
|
||||
// Persist active tab whenever it changes (not just via handleTabChange)
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
// Skip during restoration to avoid overwriting with intermediate values
|
||||
if (restorationInProgressRef.current) {
|
||||
return;
|
||||
}
|
||||
setActiveTabForSession(activeSessionId, leftTab);
|
||||
}, [activeSessionId, leftTab]);
|
||||
|
||||
// Notify parent when the active file changes
|
||||
useEffect(() => {
|
||||
if (leftTab.startsWith('file:')) {
|
||||
const filePath = leftTab.replace('file:', '');
|
||||
onActiveFileChange?.(filePath);
|
||||
} else {
|
||||
onActiveFileChange?.(null);
|
||||
}
|
||||
}, [leftTab, onActiveFileChange]);
|
||||
|
||||
// Track plan updates for notification dot
|
||||
const plan = useAppStore((state) =>
|
||||
activeTaskId ? state.taskPlans.byTaskId[activeTaskId] : null
|
||||
@@ -165,11 +335,11 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
setPlanLastSeen(activeTaskId, plan.updated_at);
|
||||
}
|
||||
setLeftTab(tab);
|
||||
// Persist tab selection (only for main tabs, not file tabs)
|
||||
if (tab === 'plan' || tab === 'changes' || tab === 'chat') {
|
||||
setCenterPanelTab(tab);
|
||||
// Persist tab selection per-session (for all tabs including file tabs)
|
||||
if (activeSessionId) {
|
||||
setActiveTabForSession(activeSessionId, tab);
|
||||
}
|
||||
}, [activeTaskId, plan]);
|
||||
}, [activeTaskId, activeSessionId, plan]);
|
||||
|
||||
// Handle external diff selection
|
||||
useEffect(() => {
|
||||
@@ -314,6 +484,33 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
}
|
||||
}, [openFileTabs, activeSessionId, toast]);
|
||||
|
||||
// Handler for deleting file from editor
|
||||
const handleFileDelete = useCallback(async (path: string) => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client || !activeSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await deleteFile(client, activeSessionId, path);
|
||||
if (response.success) {
|
||||
// Close the tab
|
||||
handleCloseFileTab(path);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
description: response.error || 'Failed to delete file',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
description: error instanceof Error ? error.message : 'An error occurred while deleting the file',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}, [activeSessionId, handleCloseFileTab, toast]);
|
||||
|
||||
const tabs: SessionTab[] = useMemo(() => {
|
||||
const staticTabs: SessionTab[] = [
|
||||
{
|
||||
@@ -332,12 +529,10 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
];
|
||||
|
||||
const fileTabs: SessionTab[] = openFileTabs.map((tab) => {
|
||||
const ext = tab.name.split('.').pop()?.toLowerCase() || '';
|
||||
const dotColor = tab.isDirty ? 'bg-yellow-500' : (FILE_EXTENSION_COLORS[ext] || 'bg-muted-foreground');
|
||||
return {
|
||||
id: `file:${tab.path}`,
|
||||
label: tab.isDirty ? `${tab.name} *` : tab.name,
|
||||
icon: <span className={`h-2 w-2 rounded-full ${dotColor}`} />,
|
||||
icon: tab.isDirty ? <span className="h-2 w-2 rounded-full bg-yellow-500" /> : undefined,
|
||||
closable: true,
|
||||
onClose: (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -427,9 +622,9 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
onOpenFile={handleOpenFileFromChat}
|
||||
showRequestChangesTooltip={showRequestChangesTooltip}
|
||||
onRequestChangesTooltipDismiss={() => setShowRequestChangesTooltip(false)}
|
||||
onSelectDiff={(path) => {
|
||||
setSelectedDiff({ path });
|
||||
setLeftTab('changes');
|
||||
onOpenFileAtLine={(filePath) => {
|
||||
// Open the file in editor tab (or switch to it)
|
||||
handleOpenFileFromChat(filePath);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -441,15 +636,18 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
</TabsContent>
|
||||
|
||||
{openFileTabs.map((tab) => (
|
||||
<TabsContent key={tab.path} value={`file:${tab.path}`} className="mt-3 flex-1 min-h-0">
|
||||
<TabsContent key={tab.path} value={`file:${tab.path}`} className="flex-1 min-h-0">
|
||||
<FileEditorContent
|
||||
path={tab.path}
|
||||
content={tab.content}
|
||||
originalContent={tab.originalContent}
|
||||
isDirty={tab.isDirty}
|
||||
isSaving={savingFiles.has(tab.path)}
|
||||
sessionId={activeSessionId || undefined}
|
||||
enableComments={!!activeSessionId}
|
||||
onChange={(newContent) => handleFileChange(tab.path, newContent)}
|
||||
onSave={() => handleFileSave(tab.path)}
|
||||
onDelete={() => handleFileDelete(tab.path)}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
@@ -24,8 +24,8 @@ type TaskChatPanelProps = {
|
||||
onOpenFile?: (path: string) => void;
|
||||
showRequestChangesTooltip?: boolean;
|
||||
onRequestChangesTooltipDismiss?: () => void;
|
||||
/** Callback to select a file in the changes panel (for jump-to-line) */
|
||||
onSelectDiff?: (path: string) => void;
|
||||
/** Callback to open a file at a specific line (for comment clicks) */
|
||||
onOpenFileAtLine?: (filePath: string) => void;
|
||||
};
|
||||
|
||||
export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
@@ -34,7 +34,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
onOpenFile,
|
||||
showRequestChangesTooltip = false,
|
||||
onRequestChangesTooltipDismiss,
|
||||
onSelectDiff,
|
||||
onOpenFileAtLine,
|
||||
}: TaskChatPanelProps) {
|
||||
const [planModeEnabled, setPlanModeEnabled] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
@@ -211,7 +211,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
pendingCommentsByFile={pendingCommentsByFile}
|
||||
onRemoveCommentFile={handleRemoveCommentFile}
|
||||
onRemoveComment={handleRemoveComment}
|
||||
onCommentClick={onSelectDiff ? (comment) => onSelectDiff(comment.filePath) : undefined}
|
||||
onCommentClick={onOpenFileAtLine ? (comment) => onOpenFileAtLine(comment.filePath) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo, useState, useCallback } from 'react';
|
||||
import { memo, useMemo, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
IconArrowBackUp,
|
||||
IconExternalLink,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconChevronRight,
|
||||
IconGitCommit,
|
||||
IconLoader2,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { TabsContent } from '@kandev/ui/tabs';
|
||||
@@ -34,15 +35,23 @@ import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-sta
|
||||
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { deleteFile } from '@/lib/ws/workspace-files';
|
||||
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';
|
||||
import type { OpenFileTab } from '@/lib/types/backend';
|
||||
import {
|
||||
getFilesPanelTab,
|
||||
setFilesPanelTab,
|
||||
hasUserSelectedFilesPanelTab,
|
||||
setUserSelectedFilesPanelTab,
|
||||
} from '@/lib/local-storage';
|
||||
|
||||
type TaskFilesPanelProps = {
|
||||
onSelectDiff: (path: string, content?: string) => void;
|
||||
onOpenFile: (file: OpenFileTab) => void;
|
||||
activeFilePath?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -92,10 +101,11 @@ const splitPath = (path: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }: TaskFilesPanelProps) {
|
||||
const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, activeFilePath }: TaskFilesPanelProps) {
|
||||
const [topTab, setTopTab] = useState<'diff' | 'files'>('diff');
|
||||
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
|
||||
const [fileToDiscard, setFileToDiscard] = useState<string | null>(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const gitStatus = useSessionGitStatus(activeSessionId);
|
||||
const { commits } = useSessionCommits(activeSessionId ?? null);
|
||||
@@ -103,6 +113,14 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
|
||||
const gitOps = useGitOperations(activeSessionId ?? null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Track if we've initialized the tab for this session
|
||||
const hasInitializedTabRef = useRef<string | null>(null);
|
||||
|
||||
// Close search when switching tabs
|
||||
useEffect(() => {
|
||||
setIsSearchOpen(false);
|
||||
}, [topTab]);
|
||||
|
||||
// State for commit diffs
|
||||
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
|
||||
const [commitDiffs, setCommitDiffs] = useState<Record<string, Record<string, FileInfo>>>({});
|
||||
@@ -156,13 +174,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
|
||||
|
||||
try {
|
||||
const result = await gitOps.discard([fileToDiscard]);
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Changes discarded',
|
||||
description: `Successfully discarded changes to ${fileToDiscard}`,
|
||||
variant: 'success',
|
||||
});
|
||||
} else {
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: 'Failed to discard changes',
|
||||
description: result.error || 'An unknown error occurred',
|
||||
@@ -181,6 +193,29 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
|
||||
}
|
||||
}, [fileToDiscard, gitOps, toast]);
|
||||
|
||||
// Handle delete file
|
||||
const handleDeleteFile = useCallback(async (path: string) => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client || !activeSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await deleteFile(client, activeSessionId, path);
|
||||
if (!response.success) {
|
||||
toast({
|
||||
title: 'Failed to delete file',
|
||||
description: response.error || 'An unknown error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete file',
|
||||
description: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}, [activeSessionId, toast]);
|
||||
|
||||
// Convert git status files to array for display
|
||||
const changedFiles = useMemo(() => {
|
||||
if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) {
|
||||
@@ -196,6 +231,36 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
|
||||
}));
|
||||
}, [gitStatus]);
|
||||
|
||||
// Smart tab selection: restore user preference or auto-select based on changes
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
|
||||
// Only initialize once per session
|
||||
if (hasInitializedTabRef.current === activeSessionId) return;
|
||||
hasInitializedTabRef.current = activeSessionId;
|
||||
|
||||
// If user has previously selected a tab for this session, restore it
|
||||
if (hasUserSelectedFilesPanelTab(activeSessionId)) {
|
||||
const savedTab = getFilesPanelTab(activeSessionId, 'diff');
|
||||
setTopTab(savedTab);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-select based on whether there are changes
|
||||
const hasChanges = changedFiles.length > 0 || commits.length > 0;
|
||||
const defaultTab = hasChanges ? 'diff' : 'files';
|
||||
setTopTab(defaultTab);
|
||||
}, [activeSessionId, changedFiles.length, commits.length]);
|
||||
|
||||
// Handle tab change - save preference and mark as user-selected
|
||||
const handleTabChange = useCallback((tab: 'diff' | 'files') => {
|
||||
setTopTab(tab);
|
||||
if (activeSessionId) {
|
||||
setFilesPanelTab(activeSessionId, tab);
|
||||
setUserSelectedFilesPanelTab(activeSessionId);
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
const tabs: SessionTab[] = [
|
||||
{
|
||||
id: 'diff',
|
||||
@@ -207,13 +272,36 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
|
||||
},
|
||||
];
|
||||
|
||||
// Contextual buttons based on active tab
|
||||
const tabRightContent = useMemo(() => {
|
||||
if (topTab === 'files') {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => setIsSearchOpen(true)}
|
||||
>
|
||||
<IconSearch className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Search files</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
// No contextual buttons for diff tab (for now)
|
||||
return null;
|
||||
}, [topTab]);
|
||||
|
||||
return (
|
||||
<SessionPanel borderSide="left">
|
||||
<SessionTabs
|
||||
tabs={tabs}
|
||||
activeTab={topTab}
|
||||
onTabChange={(value) => setTopTab(value as 'diff' | 'files')}
|
||||
onTabChange={(value) => handleTabChange(value as 'diff' | 'files')}
|
||||
className="flex-1 min-h-0"
|
||||
rightContent={tabRightContent}
|
||||
>
|
||||
<TabsContent value="diff" className="flex-1 min-h-0">
|
||||
<SessionPanelContent>
|
||||
@@ -401,10 +489,17 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
|
||||
</div>
|
||||
</SessionPanelContent>
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="mt-3 flex-1 min-h-0">
|
||||
<TabsContent value="files" className="flex-1 min-h-0">
|
||||
<SessionPanelContent>
|
||||
{activeSessionId ? (
|
||||
<FileBrowser sessionId={activeSessionId} onOpenFile={onOpenFile} />
|
||||
<FileBrowser
|
||||
sessionId={activeSessionId}
|
||||
onOpenFile={onOpenFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
isSearchOpen={isSearchOpen}
|
||||
onCloseSearch={() => setIsSearchOpen(false)}
|
||||
activeFilePath={activeFilePath}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
||||
No task selected
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useMemo, useState, useCallback } from 'react';
|
||||
import { Group, Panel, type Layout } from 'react-resizable-panels';
|
||||
import { useDefaultLayout } from '@/lib/layout/use-default-layout';
|
||||
import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint';
|
||||
@@ -67,6 +67,13 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
handleFileOpenHandled,
|
||||
} = useSessionLayoutState({ sessionId });
|
||||
|
||||
// Track active file path for highlighting in file tree
|
||||
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
|
||||
|
||||
const handleActiveFileChange = useCallback((filePath: string | null) => {
|
||||
setActiveFilePath(filePath);
|
||||
}, []);
|
||||
|
||||
const layoutBySession = useLayoutStore((state) => state.columnsBySessionId);
|
||||
const layoutState = useMemo(
|
||||
() => layoutBySession[sessionKey] ?? DEFAULT_LAYOUT_STATE,
|
||||
@@ -134,6 +141,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
<TaskFilesPanel
|
||||
onSelectDiff={handleSelectDiff}
|
||||
onOpenFile={handleOpenFile}
|
||||
activeFilePath={activeFilePath}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -172,6 +180,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
openFileRequest={openFileRequest}
|
||||
onDiffHandled={handleClearSelectedDiff}
|
||||
onFileOpenHandled={handleFileOpenHandled}
|
||||
onActiveFileChange={handleActiveFileChange}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel id="preview" minSize="470px" className="min-h-0 min-w-0">
|
||||
@@ -186,6 +195,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
openFileRequest={openFileRequest}
|
||||
onDiffHandled={handleClearSelectedDiff}
|
||||
onFileOpenHandled={handleFileOpenHandled}
|
||||
onActiveFileChange={handleActiveFileChange}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { memo, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick } from '@tabler/icons-react';
|
||||
import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick, IconMessagePlus } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { useTaskPlan } from '@/hooks/domains/session/use-task-plan';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
@@ -42,6 +43,9 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
|
||||
const lastPlanContentRef = useRef<string | undefined>(undefined);
|
||||
// Text selection state for the selection popover
|
||||
const [textSelection, setTextSelection] = useState<TextSelection | null>(null);
|
||||
// Floating button position (shown after selection ends)
|
||||
const [floatingButtonPos, setFloatingButtonPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [currentSelectionText, setCurrentSelectionText] = useState<string | null>(null);
|
||||
// Plan comments state
|
||||
const [comments, setComments] = useState<PlanComment[]>([]);
|
||||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||
@@ -172,10 +176,35 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
|
||||
const handleSelectionClose = useCallback(() => {
|
||||
setTextSelection(null);
|
||||
setEditingCommentId(null);
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelectionText(null);
|
||||
// Clear the browser selection
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, []);
|
||||
|
||||
// Handle selection end (mouseup) - show floating button
|
||||
const handleSelectionEnd = useCallback((selection: TextSelection | null) => {
|
||||
if (!activeSessionId) return;
|
||||
if (selection) {
|
||||
setFloatingButtonPos(selection.position);
|
||||
setCurrentSelectionText(selection.text);
|
||||
} else {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelectionText(null);
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Handle floating button click - open the popover
|
||||
const handleFloatingButtonClick = useCallback(() => {
|
||||
if (!floatingButtonPos || !currentSelectionText) return;
|
||||
setTextSelection({
|
||||
text: currentSelectionText,
|
||||
position: floatingButtonPos,
|
||||
});
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelectionText(null);
|
||||
}, [floatingButtonPos, currentSelectionText]);
|
||||
|
||||
// Handle clicking on a highlighted comment in the editor (opens edit popover)
|
||||
const handleCommentHighlightClick = useCallback(
|
||||
(id: string, position: { x: number; y: number }) => {
|
||||
@@ -196,6 +225,11 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
|
||||
setComments([]);
|
||||
}, []);
|
||||
|
||||
// Handle deleting a single comment
|
||||
const handleDeleteComment = useCallback((commentId: string) => {
|
||||
setComments(prev => prev.filter(c => c.id !== commentId));
|
||||
}, []);
|
||||
|
||||
// Handle submitting all comments to the agent
|
||||
const handleSubmitComments = useCallback(async () => {
|
||||
if (!taskId || !activeSessionId || comments.length === 0) return;
|
||||
@@ -292,9 +326,28 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
|
||||
onChange={setDraftContent}
|
||||
placeholder="Start typing your plan..."
|
||||
onSelectionChange={activeSessionId ? setTextSelection : undefined}
|
||||
onSelectionEnd={activeSessionId ? handleSelectionEnd : undefined}
|
||||
comments={commentHighlights}
|
||||
onCommentClick={handleCommentHighlightClick}
|
||||
/>
|
||||
|
||||
{/* Floating "Comment" button when text is selected */}
|
||||
{floatingButtonPos && !textSelection && activeSessionId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
|
||||
style={{
|
||||
left: floatingButtonPos.x + 8,
|
||||
top: floatingButtonPos.y + 8,
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={handleFloatingButtonClick}
|
||||
>
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
Comment
|
||||
</Button>
|
||||
)}
|
||||
{/* Rich empty state - shows when no content and editor not focused */}
|
||||
{!isLoading && draftContent.trim() === '' && !isEditorFocused && (
|
||||
<div
|
||||
@@ -379,6 +432,7 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
|
||||
onAdd={handleAddComment}
|
||||
onClose={handleSelectionClose}
|
||||
editingComment={editingCommentId ? comments.find(c => c.id === editingCommentId)?.comment : undefined}
|
||||
onDelete={editingCommentId ? () => handleDeleteComment(editingCommentId) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -171,3 +171,215 @@ export function setCenterPanelTab(tab: string): void {
|
||||
// Ignore write failures
|
||||
}
|
||||
}
|
||||
|
||||
// Internal storage keys for files panel (uses sessionStorage for per-tab persistence)
|
||||
const FILES_PANEL_KEYS = {
|
||||
TAB: 'kandev.filesPanel.tab',
|
||||
USER_SELECTED: 'kandev.filesPanel.userSelected',
|
||||
EXPANDED: 'kandev.filesPanel.expanded',
|
||||
SCROLL: 'kandev.filesPanel.scroll',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get the saved files panel tab for a session
|
||||
* @param sessionId - The session ID
|
||||
* @param fallback - Default tab if not found
|
||||
* @returns The saved tab ('diff' or 'files')
|
||||
*/
|
||||
export function getFilesPanelTab(sessionId: string, fallback: 'diff' | 'files'): 'diff' | 'files' {
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
try {
|
||||
const key = `${FILES_PANEL_KEYS.TAB}.${sessionId}`;
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
const value = JSON.parse(raw) as string;
|
||||
return value === 'diff' || value === 'files' ? value : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the files panel tab for a session
|
||||
* @param sessionId - The session ID
|
||||
* @param tab - The tab to save ('diff' or 'files')
|
||||
*/
|
||||
export function setFilesPanelTab(sessionId: string, tab: 'diff' | 'files'): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const key = `${FILES_PANEL_KEYS.TAB}.${sessionId}`;
|
||||
window.sessionStorage.setItem(key, JSON.stringify(tab));
|
||||
} catch {
|
||||
// Ignore write failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has explicitly selected a tab for this session
|
||||
* @param sessionId - The session ID
|
||||
* @returns true if user has made a selection
|
||||
*/
|
||||
export function hasUserSelectedFilesPanelTab(sessionId: string): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const key = `${FILES_PANEL_KEYS.USER_SELECTED}.${sessionId}`;
|
||||
return window.sessionStorage.getItem(key) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that user has explicitly selected a tab for this session
|
||||
* @param sessionId - The session ID
|
||||
*/
|
||||
export function setUserSelectedFilesPanelTab(sessionId: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const key = `${FILES_PANEL_KEYS.USER_SELECTED}.${sessionId}`;
|
||||
window.sessionStorage.setItem(key, 'true');
|
||||
} catch {
|
||||
// Ignore write failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the saved expanded paths for file browser
|
||||
* @param sessionId - The session ID
|
||||
* @returns Array of expanded folder paths
|
||||
*/
|
||||
export function getFilesPanelExpandedPaths(sessionId: string): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const key = `${FILES_PANEL_KEYS.EXPANDED}.${sessionId}`;
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the expanded paths for file browser
|
||||
* @param sessionId - The session ID
|
||||
* @param paths - Array of expanded folder paths
|
||||
*/
|
||||
export function setFilesPanelExpandedPaths(sessionId: string, paths: string[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const key = `${FILES_PANEL_KEYS.EXPANDED}.${sessionId}`;
|
||||
window.sessionStorage.setItem(key, JSON.stringify(paths));
|
||||
} catch {
|
||||
// Ignore write failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the saved scroll position for file browser
|
||||
* @param sessionId - The session ID
|
||||
* @returns The scroll position in pixels
|
||||
*/
|
||||
export function getFilesPanelScrollPosition(sessionId: string): number {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
try {
|
||||
const key = `${FILES_PANEL_KEYS.SCROLL}.${sessionId}`;
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return 0;
|
||||
return JSON.parse(raw) as number;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the scroll position for file browser
|
||||
* @param sessionId - The session ID
|
||||
* @param position - The scroll position in pixels
|
||||
*/
|
||||
export function setFilesPanelScrollPosition(sessionId: string, position: number): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const key = `${FILES_PANEL_KEYS.SCROLL}.${sessionId}`;
|
||||
window.sessionStorage.setItem(key, JSON.stringify(position));
|
||||
} catch {
|
||||
// Ignore write failures
|
||||
}
|
||||
}
|
||||
|
||||
// Internal storage keys for open file tabs
|
||||
const OPEN_FILES_KEY = 'kandev.openFiles';
|
||||
const ACTIVE_TAB_KEY = 'kandev.activeTab';
|
||||
|
||||
/**
|
||||
* Minimal tab info stored in sessionStorage (no content - reloaded on restore)
|
||||
*/
|
||||
export interface StoredFileTab {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the saved open file tabs for a session
|
||||
* @param sessionId - The session ID
|
||||
* @returns Array of stored file tabs (path and name only)
|
||||
*/
|
||||
export function getOpenFileTabs(sessionId: string): StoredFileTab[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const key = `${OPEN_FILES_KEY}.${sessionId}`;
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as StoredFileTab[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the open file tabs for a session
|
||||
* @param sessionId - The session ID
|
||||
* @param tabs - Array of tabs (only path and name are stored)
|
||||
*/
|
||||
export function setOpenFileTabs(sessionId: string, tabs: StoredFileTab[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const key = `${OPEN_FILES_KEY}.${sessionId}`;
|
||||
window.sessionStorage.setItem(key, JSON.stringify(tabs));
|
||||
} catch {
|
||||
// Ignore write failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the saved active tab for a session
|
||||
* @param sessionId - The session ID
|
||||
* @param fallback - Default tab if not found
|
||||
* @returns The saved active tab id (e.g., 'chat', 'plan', 'file:/path/to/file')
|
||||
*/
|
||||
export function getActiveTabForSession(sessionId: string, fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
try {
|
||||
const key = `${ACTIVE_TAB_KEY}.${sessionId}`;
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
return JSON.parse(raw) as string;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the active tab for a session
|
||||
* @param sessionId - The session ID
|
||||
* @param tabId - The tab id to save
|
||||
*/
|
||||
export function setActiveTabForSession(sessionId: string, tabId: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const key = `${ACTIVE_TAB_KEY}.${sessionId}`;
|
||||
window.sessionStorage.setItem(key, JSON.stringify(tabId));
|
||||
} catch {
|
||||
// Ignore write failures
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,3 +74,27 @@ export async function updateFileContent(
|
||||
original_hash: originalHash,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* File delete response from backend
|
||||
*/
|
||||
export type FileDeleteResponse = {
|
||||
path: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a file from the workspace
|
||||
*/
|
||||
export async function deleteFile(
|
||||
client: WebSocketClient,
|
||||
sessionId: string,
|
||||
path: string
|
||||
): Promise<FileDeleteResponse> {
|
||||
return client.request<FileDeleteResponse>('workspace.file.delete', {
|
||||
session_id: sessionId,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@uiw/codemirror-theme-github": "^4.25.4",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
|
||||
Reference in New Issue
Block a user