diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index a6d2dda214..87916edf9e 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "strings" + "unicode/utf8" agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -18,6 +20,12 @@ type WorkspaceFileHandlers struct { logger *logger.Logger } +type workspaceContentSearchRequest struct { + SessionID string `json:"session_id"` + Query string `json:"query"` + LimitPerRepo int `json:"limit_per_repo"` +} + // NewWorkspaceFileHandlers creates new workspace file handlers func NewWorkspaceFileHandlers(lm *lifecycle.Manager, log *logger.Logger) *WorkspaceFileHandlers { return &WorkspaceFileHandlers{ @@ -36,6 +44,7 @@ func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorkspaceFileDelete, h.wsDeleteFile) d.RegisterFunc(ws.ActionWorkspaceFileRename, h.wsRenameFile) d.RegisterFunc(ws.ActionWorkspaceFilesSearch, h.wsSearchFiles) + d.RegisterFunc(ws.ActionWorkspaceContentSearch, h.wsSearchContent) } // wsGetFileTree handles workspace.tree.get action @@ -327,6 +336,68 @@ func (h *WorkspaceFileHandlers) wsSearchFiles(ctx context.Context, msg *ws.Messa return ws.NewResponse(msg.ID, msg.Action, response) } +// wsSearchContent handles workspace.content.search. +func (h *WorkspaceFileHandlers) wsSearchContent( + ctx context.Context, + msg *ws.Message, +) (*ws.Message, error) { + req, err := decodeWorkspaceContentSearchRequest(msg) + if 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 utf8.RuneCountInString(req.Query) > streams.WorkspaceContentSearchMaxQueryRunes { + return ws.NewError( + msg.ID, msg.Action, ws.ErrorCodeValidation, + "query exceeds 200 characters", nil, + ) + } + + execution, err := h.lifecycle.GetOrEnsureExecution(ctx, req.SessionID) + if err != nil { + return ws.NewError( + msg.ID, msg.Action, ws.ErrorCodeNotFound, + "No agent found for session: "+err.Error(), nil, + ) + } + client := execution.GetAgentCtlClient() + if client == nil { + return ws.NewError( + msg.ID, msg.Action, ws.ErrorCodeInternalError, + "Agent client not available", nil, + ) + } + response, err := client.SearchWorkspaceContent(ctx, req.Query, req.LimitPerRepo) + if err != nil { + h.logger.Error( + "failed to search workspace content", + zap.Error(err), + zap.String("session_id", req.SessionID), + ) + return ws.NewError( + msg.ID, msg.Action, ws.ErrorCodeInternalError, + fmt.Sprintf("Failed to search workspace content: %v", err), nil, + ) + } + return ws.NewResponse(msg.ID, msg.Action, response) +} + +func decodeWorkspaceContentSearchRequest( + msg *ws.Message, +) (workspaceContentSearchRequest, error) { + var request workspaceContentSearchRequest + err := msg.ParsePayload(&request) + return request, err +} + // wsRenameFile handles workspace.file.rename action func (h *WorkspaceFileHandlers) wsRenameFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go b/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go new file mode 100644 index 0000000000..b8b4a21600 --- /dev/null +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go @@ -0,0 +1,86 @@ +package handlers + +import ( + "context" + "strings" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestWorkspaceFileHandlersRegisterContentSearch(t *testing.T) { + handler := NewWorkspaceFileHandlers(nil, newTestLogger()) + dispatcher := ws.NewDispatcher() + handler.RegisterHandlers(dispatcher) + + if !dispatcher.HasHandler(ws.ActionWorkspaceContentSearch) { + t.Fatal("workspace.content.search handler was not registered") + } +} + +func TestWorkspaceContentSearchRequiresSessionID(t *testing.T) { + handler := NewWorkspaceFileHandlers(nil, newTestLogger()) + msg, err := ws.NewRequest("request-1", ws.ActionWorkspaceContentSearch, map[string]any{ + "query": "needle", + }) + if err != nil { + t.Fatal(err) + } + + response, err := handler.wsSearchContent(context.Background(), msg) + if err != nil { + t.Fatalf("wsSearchContent returned error: %v", err) + } + assertWorkspaceContentSearchErrorCode(t, response, ws.ErrorCodeValidation) +} + +func TestWorkspaceContentSearchRejectsLongQueryBeforeLifecycleLookup(t *testing.T) { + handler := NewWorkspaceFileHandlers(nil, newTestLogger()) + msg, err := ws.NewRequest("request-1", ws.ActionWorkspaceContentSearch, map[string]any{ + "session_id": "session-1", + "query": strings.Repeat("界", 201), + }) + if err != nil { + t.Fatal(err) + } + + response, err := handler.wsSearchContent(context.Background(), msg) + if err != nil { + t.Fatalf("wsSearchContent returned error: %v", err) + } + assertWorkspaceContentSearchErrorCode(t, response, ws.ErrorCodeValidation) +} + +func TestDecodeWorkspaceContentSearchRequestUsesPerRepositoryLimit(t *testing.T) { + msg, err := ws.NewRequest("request-1", ws.ActionWorkspaceContentSearch, map[string]any{ + "session_id": "session-1", + "query": "needle", + "limit_per_repo": 37, + "limit": 9, + }) + if err != nil { + t.Fatal(err) + } + + request, err := decodeWorkspaceContentSearchRequest(msg) + if err != nil { + t.Fatalf("decodeWorkspaceContentSearchRequest returned error: %v", err) + } + if request.LimitPerRepo != 37 { + t.Fatalf("limit_per_repo = %d, want 37", request.LimitPerRepo) + } +} + +func assertWorkspaceContentSearchErrorCode(t *testing.T, response *ws.Message, want string) { + t.Helper() + if response == nil || response.Type != ws.MessageTypeError { + t.Fatalf("response = %#v, want error message", response) + } + var payload ws.ErrorPayload + if err := response.ParsePayload(&payload); err != nil { + t.Fatalf("parse error payload: %v", err) + } + if payload.Code != want { + t.Fatalf("error code = %q, want %q", payload.Code, want) + } +} diff --git a/apps/backend/internal/agent/runtime/agentctl/client_content_search_test.go b/apps/backend/internal/agent/runtime/agentctl/client_content_search_test.go new file mode 100644 index 0000000000..586a57a8f5 --- /dev/null +++ b/apps/backend/internal/agent/runtime/agentctl/client_content_search_test.go @@ -0,0 +1,58 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSearchWorkspaceContentUsesDedicatedEndpointAndDecodesMatches(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/workspace/content-search" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("q"); got != "hello world" { + t.Fatalf("query = %q, want %q", got, "hello world") + } + if got := r.URL.Query().Get("limit_per_repo"); got != "12" { + t.Fatalf("limit_per_repo = %q, want 12", got) + } + if legacy := r.URL.Query().Get("limit"); legacy != "" { + t.Fatalf("legacy limit query unexpectedly set to %q", legacy) + } + _ = json.NewEncoder(w).Encode(WorkspaceContentSearchResponse{ + Results: []WorkspaceContentSearchResult{{ + RepositoryName: "backend", + Path: "main.go", + Line: 9, + Column: 4, + Preview: "say hello world", + MatchRanges: []WorkspaceContentMatchRange{{Start: 4, End: 15}}, + }}, + }) + })) + defer server.Close() + + client := &Client{baseURL: server.URL, httpClient: server.Client(), logger: newTestLogger()} + response, err := client.SearchWorkspaceContent(context.Background(), "hello world", 12) + if err != nil { + t.Fatalf("SearchWorkspaceContent returned error: %v", err) + } + if len(response.Results) != 1 || response.Results[0].RepositoryName != "backend" { + t.Fatalf("response = %#v", response) + } +} + +func TestSearchWorkspaceContentRejectsNonOKResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "bad query", http.StatusBadRequest) + })) + defer server.Close() + + client := &Client{baseURL: server.URL, httpClient: server.Client(), logger: newTestLogger()} + if _, err := client.SearchWorkspaceContent(context.Background(), "needle", 10); err == nil { + t.Fatal("SearchWorkspaceContent returned nil error for non-OK response") + } +} diff --git a/apps/backend/internal/agent/runtime/agentctl/client_files.go b/apps/backend/internal/agent/runtime/agentctl/client_files.go index d59f77f095..f86f89dda2 100644 --- a/apps/backend/internal/agent/runtime/agentctl/client_files.go +++ b/apps/backend/internal/agent/runtime/agentctl/client_files.go @@ -80,6 +80,45 @@ func (c *Client) SearchFiles(ctx context.Context, query string, limit int) (*Fil return &result, nil } +// SearchWorkspaceContent searches matching lines in every task repository. +func (c *Client) SearchWorkspaceContent( + ctx context.Context, + query string, + limit int, +) (*WorkspaceContentSearchResponse, error) { + reqURL := fmt.Sprintf( + "%s/api/v1/workspace/content-search?q=%s&limit_per_repo=%d", + c.baseURL, + url.QueryEscape(query), + limit, + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create content search request: %w", err) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to search workspace content: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close content search response body", zap.Error(err)) + } + }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("workspace content search failed with status %d", resp.StatusCode) + } + + var result WorkspaceContentSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode content search response: %w", err) + } + if result.Error != "" { + return nil, fmt.Errorf("workspace content search error: %s", result.Error) + } + return &result, nil +} + // RequestFileContent requests file content via HTTP GET. // repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. func (c *Client) RequestFileContent(ctx context.Context, path, repo string) (*FileContentResponse, error) { @@ -324,11 +363,13 @@ func (c *Client) RenameFile(ctx context.Context, oldPath, newPath, repo string) return &response, nil } -// FileSearchResponse represents a response with matching files -type FileSearchResponse struct { - Files []string `json:"files"` - Error string `json:"error,omitempty"` -} +type ( + FileSearchResult = streams.FileSearchResult + FileSearchResponse = streams.FileSearchResponse + WorkspaceContentMatchRange = streams.WorkspaceContentMatchRange + WorkspaceContentSearchResult = streams.WorkspaceContentSearchResult + WorkspaceContentSearchResponse = streams.WorkspaceContentSearchResponse +) // CopyFilesRequest is the body for POST /workspace/copy-files. Mirrors the // agentctl-side api.CopyFilesRequest shape — kept local rather than imported diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 3113add732..1713917f01 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -132,6 +132,7 @@ func (s *Server) setupRoutes() { api.POST("/workspace/file/rename", s.handleFileRename) api.DELETE("/workspace/file", s.handleFileDelete) api.GET("/workspace/search", s.handleFileSearch) + api.GET("/workspace/content-search", s.handleWorkspaceContentSearch) // Batched copy of files from the host (used by remote executors — // Docker, Sprites — to seed the workspace with gitignored config diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index 44b369ea7f..de766736bf 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -2,10 +2,13 @@ package api import ( "encoding/json" + "errors" "io" + "net/http" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "go.uber.org/zap" @@ -232,9 +235,35 @@ func (s *Server) handleFileSearch(c *gin.Context) { } } - files := s.procMgr.GetWorkspaceTracker().SearchFiles(query, limit) + results := s.procMgr.SearchWorkspaceFileResults(query, limit) + files := make([]string, 0, len(results)) + for _, result := range results { + files = append(files, result.Path) + } - c.JSON(200, types.FileSearchResponse{Files: files}) + c.JSON(200, types.FileSearchResponse{Files: files, Results: results}) +} + +// handleWorkspaceContentSearch searches matching lines across every task repository. +func (s *Server) handleWorkspaceContentSearch(c *gin.Context) { + query := c.Query("q") + limit := 20 + if rawLimit := c.Query("limit_per_repo"); rawLimit != "" { + if parsed := mustParseInt(rawLimit); parsed > 0 { + limit = parsed + } + } + + response, err := s.procMgr.SearchWorkspaceContent(c.Request.Context(), query, limit) + if err == nil { + c.JSON(http.StatusOK, response) + return + } + if errors.Is(err, process.ErrContentSearchQueryTooLong) { + c.JSON(http.StatusBadRequest, types.WorkspaceContentSearchResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, types.WorkspaceContentSearchResponse{Error: err.Error()}) } // handleFileCreate handles file create requests via HTTP POST. diff --git a/apps/backend/internal/agentctl/server/api/workspace_content_search_test.go b/apps/backend/internal/agentctl/server/api/workspace_content_search_test.go new file mode 100644 index 0000000000..95dd080c28 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/workspace_content_search_test.go @@ -0,0 +1,69 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func TestHandleWorkspaceContentSearchReturnsTypedMatches(t *testing.T) { + repoDir := t.TempDir() + initWorkspaceGitRepo(t, repoDir) + if err := os.WriteFile( + filepath.Join(repoDir, "hello.txt"), + []byte("zero\nhello Needle world\nanother needle\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + log := newTestLogger() + cfg := &config.InstanceConfig{WorkDir: repoDir} + server := NewServer(cfg, process.NewManager(cfg, log), nil, nil, log) + + req := httptest.NewRequest( + http.MethodGet, + "/api/v1/workspace/content-search?q=needle&limit_per_repo=1", + nil, + ) + resp := httptest.NewRecorder() + server.router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", resp.Code, resp.Body.String()) + } + var body streams.WorkspaceContentSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(body.Results) != 1 { + t.Fatalf("results = %#v, want one", body.Results) + } + match := body.Results[0] + if match.Path != "hello.txt" || match.Line != 2 || match.Column != 7 { + t.Fatalf("match = %#v", match) + } +} + +func TestHandleWorkspaceContentSearchRejectsQueryOverMaximum(t *testing.T) { + server := newTestServer(t) + query := strings.Repeat("a", process.WorkspaceContentSearchMaxQueryRunes+1) + req := httptest.NewRequest( + http.MethodGet, + "/api/v1/workspace/content-search?q="+query, + nil, + ) + resp := httptest.NewRecorder() + server.router.ServeHTTP(resp, req) + + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", resp.Code, resp.Body.String()) + } +} diff --git a/apps/backend/internal/agentctl/server/api/workspace_file_search_test.go b/apps/backend/internal/agentctl/server/api/workspace_file_search_test.go new file mode 100644 index 0000000000..3629e30eac --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/workspace_file_search_test.go @@ -0,0 +1,94 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" +) + +func TestHandleFileSearchIncludesEveryTaskRepository(t *testing.T) { + type result struct { + RepositoryName string `json:"repository_name"` + Path string `json:"path"` + } + type response struct { + Files []string `json:"files"` + Results []result `json:"results"` + } + + taskRoot := t.TempDir() + for _, fixture := range []struct { + repository string + path string + }{ + {repository: "backend", path: "src/shared-search.go"}, + {repository: "frontend", path: "src/shared-search.ts"}, + } { + repoDir := filepath.Join(taskRoot, fixture.repository) + initWorkspaceGitRepo(t, repoDir) + fullPath := filepath.Join(repoDir, fixture.path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte("fixture"), 0o644); err != nil { + t.Fatal(err) + } + } + + log := newTestLogger() + cfg := &config.InstanceConfig{WorkDir: taskRoot} + manager := process.NewManager(cfg, log) + manager.StartAllWorkspaceTrackers(context.Background()) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := manager.Stop(ctx); err != nil { + t.Errorf("stop manager: %v", err) + } + }) + server := NewServer(cfg, manager, nil, nil, log) + + deadline := time.Now().Add(2 * time.Second) + var files []string + var results []result + for time.Now().Before(deadline) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/workspace/search?q=shared", nil) + resp := httptest.NewRecorder() + server.router.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", resp.Code, resp.Body.String()) + } + var body response + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + files = body.Files + results = body.Results + if len(files) == 2 { + break + } + time.Sleep(20 * time.Millisecond) + } + + want := []string{"backend/src/shared-search.go", "frontend/src/shared-search.ts"} + if !reflect.DeepEqual(files, want) { + t.Fatalf("files = %v, want %v", files, want) + } + + wantResults := []result{ + {RepositoryName: "backend", Path: "backend/src/shared-search.go"}, + {RepositoryName: "frontend", Path: "frontend/src/shared-search.ts"}, + } + if !reflect.DeepEqual(results, wantResults) { + t.Fatalf("results = %+v, want %+v", results, wantResults) + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_content_search.go b/apps/backend/internal/agentctl/server/process/workspace_content_search.go new file mode 100644 index 0000000000..eee8f8fbad --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_content_search.go @@ -0,0 +1,604 @@ +package process + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "sort" + "unicode" + "unicode/utf16" + "unicode/utf8" + + "github.com/kandev/kandev/internal/agentctl/types" +) + +const ( + // WorkspaceContentSearchMaxQueryRunes is the largest accepted content query. + WorkspaceContentSearchMaxQueryRunes = types.WorkspaceContentSearchMaxQueryRunes + workspaceContentSearchMaxLimit = 50 + workspaceContentSearchDefaultLimit = 20 + workspaceContentSearchPreviewRunes = 300 + workspaceContentSearchReadBuffer = 32 * 1024 + workspaceContentContiguousScoreBase = 1_000_000_000 + workspaceContentSearchCancelRunes = 1_024 + workspaceContentSearchFuzzyMaxSteps = 250_000 +) + +// ErrContentSearchQueryTooLong indicates that the query exceeds the public limit. +var ErrContentSearchQueryTooLong = errors.New("content search query exceeds 200 characters") + +var errContentSearchFuzzyWorkLimit = errors.New("content search fuzzy work limit reached") + +type contentLineMatch struct { + score int + positions []int +} + +type contentSearchFuzzyWork struct { + steps int +} + +type scoredContentSearchResult struct { + result types.WorkspaceContentSearchResult + score int +} + +// SearchWorkspaceContent searches every repository represented by the manager. +func (m *Manager) SearchWorkspaceContent( + ctx context.Context, + query string, + limit int, +) (*types.WorkspaceContentSearchResponse, error) { + limit, err := validateContentSearch(query, limit) + if err != nil { + return nil, err + } + response := &types.WorkspaceContentSearchResponse{ + Results: []types.WorkspaceContentSearchResult{}, + } + if query == "" { + return response, nil + } + + for _, tracker := range m.contentSearchTrackers() { + if err := ctx.Err(); err != nil { + return nil, err + } + results, err := tracker.SearchContent(ctx, query, limit) + if err != nil { + return nil, err + } + response.Results = append(response.Results, results...) + } + return response, nil +} + +func (m *Manager) contentSearchTrackers() []*WorkspaceTracker { + root, repositories := m.snapshotTrackers() + trackers := make([]*WorkspaceTracker, 0, len(repositories)+1) + if len(repositories) == 0 { + if root != nil { + trackers = append(trackers, root) + } + return trackers + } + if root != nil && root.RepositoryName() != "" { + trackers = append(trackers, root) + } + trackers = append(trackers, repositories...) + sort.Slice(trackers, func(i, j int) bool { + if trackers[i].RepositoryName() != trackers[j].RepositoryName() { + return trackers[i].RepositoryName() < trackers[j].RepositoryName() + } + return trackers[i].workDir < trackers[j].workDir + }) + return trackers +} + +// SearchContent searches the tracked and untracked nonignored text files for +// matching lines. Results are bounded and sorted by match quality. +func (wt *WorkspaceTracker) SearchContent( + ctx context.Context, + query string, + limit int, +) ([]types.WorkspaceContentSearchResult, error) { + limit, err := validateContentSearch(query, limit) + if err != nil { + return nil, err + } + if query == "" { + return []types.WorkspaceContentSearchResult{}, nil + } + if err := ctx.Err(); err != nil { + return nil, err + } + + files, err := wt.getFileList(ctx) + if err != nil { + return nil, err + } + sort.Slice(files.Files, func(i, j int) bool { + return files.Files[i].Path < files.Files[j].Path + }) + queryRunes := foldRunes([]rune(query)) + ranked := make([]scoredContentSearchResult, 0, limit) + for _, file := range files.Files { + if err := ctx.Err(); err != nil { + return nil, err + } + data, searchable, readErr := wt.readContentSearchFile(ctx, file.Path) + if readErr != nil { + if err := ctx.Err(); err != nil { + return nil, err + } + continue + } + if !searchable { + continue + } + ranked, err = wt.searchContentFile(ctx, ranked, file.Path, data, queryRunes, limit) + if err != nil { + return nil, err + } + } + return contentSearchResults(ranked), nil +} + +func validateContentSearch(query string, limit int) (int, error) { + if utf8.RuneCountInString(query) > WorkspaceContentSearchMaxQueryRunes { + return 0, ErrContentSearchQueryTooLong + } + if limit <= 0 { + return workspaceContentSearchDefaultLimit, nil + } + if limit > workspaceContentSearchMaxLimit { + return workspaceContentSearchMaxLimit, nil + } + return limit, nil +} + +func (wt *WorkspaceTracker) readContentSearchFile( + ctx context.Context, + path string, +) ([]byte, bool, error) { + safePath, err := wt.resolveSafePath(path) + if err != nil { + return nil, false, err + } + file, err := os.Open(safePath) + if err != nil { + return nil, false, err + } + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil { + return nil, false, err + } + if !info.Mode().IsRegular() || info.Size() > maxFileSize { + return nil, false, nil + } + + data, err := readContentSearchBytes(ctx, file, info.Size()) + if err != nil { + return nil, false, err + } + if len(data) > maxFileSize { + return nil, false, nil + } + if !utf8.Valid(data) || bytes.IndexByte(data, 0) >= 0 { + return nil, false, nil + } + return data, true, nil +} + +func readContentSearchBytes(ctx context.Context, reader io.Reader, size int64) ([]byte, error) { + buffer := bytes.NewBuffer(make([]byte, 0, int(size))) + chunk := make([]byte, workspaceContentSearchReadBuffer) + limited := io.LimitReader(reader, maxFileSize+1) + for { + if err := ctx.Err(); err != nil { + return nil, err + } + count, err := limited.Read(chunk) + if count > 0 { + _, _ = buffer.Write(chunk[:count]) + } + if errors.Is(err, io.EOF) { + return buffer.Bytes(), nil + } + if err != nil { + return nil, err + } + } +} + +func (wt *WorkspaceTracker) searchContentFile( + ctx context.Context, + ranked []scoredContentSearchResult, + path string, + data []byte, + query []rune, + limit int, +) ([]scoredContentSearchResult, error) { + lines := bytes.Split(data, []byte{'\n'}) + for index, lineBytes := range lines { + if err := ctx.Err(); err != nil { + return nil, err + } + lineBytes = bytes.TrimSuffix(lineBytes, []byte{'\r'}) + lineRunes := []rune(string(lineBytes)) + match, ok, err := bestContentLineMatch(ctx, lineRunes, query) + if err != nil { + return nil, err + } + if !ok { + continue + } + result := buildContentSearchResult(wt.repositoryName, path, index+1, lineRunes, match) + ranked = insertContentSearchResult(ranked, scoredContentSearchResult{ + result: result, + score: match.score, + }, limit) + } + return ranked, nil +} + +func bestContentLineMatch( + ctx context.Context, + line, query []rune, +) (contentLineMatch, bool, error) { + if len(query) == 0 || len(line) < len(query) { + return contentLineMatch{}, false, nil + } + if err := ctx.Err(); err != nil { + return contentLineMatch{}, false, err + } + foldedLine := foldRunes(line) + match, ok, err := bestContiguousContentMatch(ctx, line, foldedLine, query) + if err != nil { + return contentLineMatch{}, false, err + } + if ok { + return match, true, nil + } + return bestSubsequenceContentMatch(ctx, line, foldedLine, query) +} + +func bestContiguousContentMatch( + ctx context.Context, + line, foldedLine, query []rune, +) (contentLineMatch, bool, error) { + bestStart, bestScore := 0, 0 + found := false + queryIndex := 0 + prefixes := contentSearchPrefixTable(query) + for lineIndex, item := range foldedLine { + if lineIndex%workspaceContentSearchCancelRunes == 0 { + if err := ctx.Err(); err != nil { + return contentLineMatch{}, false, err + } + } + for queryIndex > 0 && item != query[queryIndex] { + queryIndex = prefixes[queryIndex-1] + } + if item == query[queryIndex] { + queryIndex++ + } + if queryIndex != len(query) { + continue + } + start := lineIndex - len(query) + 1 + score := workspaceContentContiguousScoreBase - start + if isContentWordBoundary(line, start) { + score += 100 + } + if !found || score > bestScore { + bestStart, bestScore, found = start, score, true + } + queryIndex = prefixes[queryIndex-1] + } + if !found { + return contentLineMatch{}, false, nil + } + return contentLineMatch{ + score: bestScore, + positions: contiguousPositions(bestStart, len(query)), + }, true, nil +} + +func bestSubsequenceContentMatch( + ctx context.Context, + line, foldedLine, query []rune, +) (contentLineMatch, bool, error) { + if err := ctx.Err(); err != nil { + return contentLineMatch{}, false, err + } + best := contentLineMatch{} + found := false + work := contentSearchFuzzyWork{} + for cursor := 0; cursor < len(foldedLine); { + positions, ok, err := nextSubsequenceContentPositions( + ctx, + foldedLine, + query, + cursor, + &work, + ) + if errors.Is(err, errContentSearchFuzzyWorkLimit) { + break + } + if err != nil { + return contentLineMatch{}, false, err + } + if !ok { + break + } + score := scoreSubsequenceContentMatch(line, positions) + if !found || score > best.score { + best = contentLineMatch{score: score, positions: positions} + found = true + } + cursor = positions[0] + 1 + } + return best, found, nil +} + +func nextSubsequenceContentPositions( + ctx context.Context, + line, query []rune, + start int, + work *contentSearchFuzzyWork, +) ([]int, bool, error) { + positions := make([]int, 0, len(query)) + queryIndex := 0 + end := -1 + for position := start; position < len(line); position++ { + if err := work.consume(ctx); err != nil { + return nil, false, err + } + if line[position] != query[queryIndex] { + continue + } + queryIndex++ + if queryIndex == len(query) { + end = position + break + } + } + if end < 0 { + return nil, false, nil + } + positions = positions[:len(query)] + queryIndex = len(query) - 1 + for position := end; position >= start; position-- { + if err := work.consume(ctx); err != nil { + return nil, false, err + } + if line[position] != query[queryIndex] { + continue + } + positions[queryIndex] = position + queryIndex-- + if queryIndex < 0 { + break + } + } + return positions, true, nil +} + +func (work *contentSearchFuzzyWork) consume(ctx context.Context) error { + if work.steps >= workspaceContentSearchFuzzyMaxSteps { + if err := ctx.Err(); err != nil { + return err + } + return errContentSearchFuzzyWorkLimit + } + if work.steps%workspaceContentSearchCancelRunes == 0 { + if err := ctx.Err(); err != nil { + return err + } + } + work.steps++ + return nil +} + +func scoreSubsequenceContentMatch(line []rune, positions []int) int { + score := len(positions)*20 - positions[0] + for index, position := range positions { + if isContentWordBoundary(line, position) { + score += 8 + } + if index == 0 { + continue + } + gap := position - positions[index-1] - 1 + if gap == 0 { + score += 15 + } else { + score -= gap * 2 + } + } + return score +} + +func isContentWordBoundary(line []rune, position int) bool { + if position == 0 { + return true + } + previous, current := line[position-1], line[position] + if !unicode.IsLetter(previous) && !unicode.IsNumber(previous) && previous != '_' { + return true + } + return unicode.IsLower(previous) && unicode.IsUpper(current) +} + +func buildContentSearchResult( + repositoryName, path string, + lineNumber int, + line []rune, + match contentLineMatch, +) types.WorkspaceContentSearchResult { + preview, ranges := buildContentSearchPreview(line, match.positions) + return types.WorkspaceContentSearchResult{ + RepositoryName: repositoryName, + Path: path, + Line: lineNumber, + Column: utf16Length(line[:match.positions[0]]) + 1, + Preview: preview, + MatchRanges: ranges, + } +} + +func buildContentSearchPreview( + line []rune, + positions []int, +) (string, []types.WorkspaceContentMatchRange) { + start, end := contentSearchPreviewBounds(len(line), positions[0]) + previewRunes := append([]rune(nil), line[start:end]...) + prefixUnits := 0 + if start > 0 { + previewRunes = append([]rune{'…'}, previewRunes...) + prefixUnits = 1 + } + if end < len(line) { + previewRunes = append(previewRunes, '…') + } + + ranges := contentSearchMatchRanges(line, positions, start, end, prefixUnits) + return string(previewRunes), ranges +} + +func contentSearchPreviewBounds(lineLength, firstMatch int) (int, int) { + if lineLength <= workspaceContentSearchPreviewRunes { + return 0, lineLength + } + start := firstMatch - workspaceContentSearchPreviewRunes/4 + if start < 0 { + start = 0 + } + end := start + workspaceContentSearchPreviewRunes + if end > lineLength { + end = lineLength + start = end - workspaceContentSearchPreviewRunes + } + return start, end +} + +func contentSearchMatchRanges( + line []rune, + positions []int, + previewStart, previewEnd, prefixUnits int, +) []types.WorkspaceContentMatchRange { + ranges := make([]types.WorkspaceContentMatchRange, 0, len(positions)) + for _, position := range positions { + if position < previewStart || position >= previewEnd { + continue + } + start := prefixUnits + utf16Length(line[previewStart:position]) + end := start + utf16RuneLength(line[position]) + if len(ranges) > 0 && ranges[len(ranges)-1].End == start { + ranges[len(ranges)-1].End = end + continue + } + ranges = append(ranges, types.WorkspaceContentMatchRange{Start: start, End: end}) + } + return ranges +} + +func insertContentSearchResult( + ranked []scoredContentSearchResult, + candidate scoredContentSearchResult, + limit int, +) []scoredContentSearchResult { + index := sort.Search(len(ranked), func(index int) bool { + return betterContentSearchResult(candidate, ranked[index]) + }) + if len(ranked) < limit { + ranked = append(ranked, scoredContentSearchResult{}) + copy(ranked[index+1:], ranked[index:]) + ranked[index] = candidate + return ranked + } + if index == len(ranked) { + return ranked + } + copy(ranked[index+1:], ranked[index:len(ranked)-1]) + ranked[index] = candidate + return ranked +} + +func betterContentSearchResult(left, right scoredContentSearchResult) bool { + if left.score != right.score { + return left.score > right.score + } + if left.result.Path != right.result.Path { + return left.result.Path < right.result.Path + } + if left.result.Line != right.result.Line { + return left.result.Line < right.result.Line + } + return left.result.Column < right.result.Column +} + +func contentSearchResults( + ranked []scoredContentSearchResult, +) []types.WorkspaceContentSearchResult { + results := make([]types.WorkspaceContentSearchResult, len(ranked)) + for index := range ranked { + results[index] = ranked[index].result + } + return results +} + +func foldRunes(value []rune) []rune { + folded := make([]rune, len(value)) + for index, item := range value { + folded[index] = unicode.ToLower(item) + } + return folded +} + +func contentSearchPrefixTable(query []rune) []int { + prefixes := make([]int, len(query)) + prefixLength := 0 + for index := 1; index < len(query); { + if query[index] == query[prefixLength] { + prefixLength++ + prefixes[index] = prefixLength + index++ + continue + } + if prefixLength > 0 { + prefixLength = prefixes[prefixLength-1] + continue + } + index++ + } + return prefixes +} + +func contiguousPositions(start, length int) []int { + positions := make([]int, length) + for index := range positions { + positions[index] = start + index + } + return positions +} + +func utf16Length(value []rune) int { + length := 0 + for _, item := range value { + length += utf16RuneLength(item) + } + return length +} + +func utf16RuneLength(value rune) int { + length := utf16.RuneLen(value) + if length < 1 { + return 1 + } + return length +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_content_search_test.go b/apps/backend/internal/agentctl/server/process/workspace_content_search_test.go new file mode 100644 index 0000000000..77aa4202cc --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_content_search_test.go @@ -0,0 +1,300 @@ +package process + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "github.com/kandev/kandev/internal/agentctl/server/config" +) + +func TestWorkspaceTrackerSearchContentRanksContiguousMatchAndUsesUTF16Ranges(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + writeFile(t, repoDir, "search.txt", "n_e_e_d_l_e\n🙂 Needle value\n") + + tracker := NewWorkspaceTrackerForRepo(repoDir, "backend", newTestLogger(t)) + results, err := tracker.SearchContent(context.Background(), "NeEdLe", 10) + if err != nil { + t.Fatalf("SearchContent returned error: %v", err) + } + if len(results) != 2 { + t.Fatalf("SearchContent result count = %d, want 2: %#v", len(results), results) + } + + exact := results[0] + if exact.RepositoryName != "backend" || exact.Path != "search.txt" { + t.Fatalf("exact result identity = %#v", exact) + } + if exact.Line != 2 || exact.Column != 4 || exact.Preview != "🙂 Needle value" { + t.Fatalf("exact location = line %d, column %d, preview %q", exact.Line, exact.Column, exact.Preview) + } + if len(exact.MatchRanges) != 1 || + exact.MatchRanges[0].Start != 3 || exact.MatchRanges[0].End != 9 { + t.Fatalf("exact UTF-16 ranges = %#v, want [{3 9}]", exact.MatchRanges) + } + + fuzzy := results[1] + if fuzzy.Line != 1 || len(fuzzy.MatchRanges) != 6 { + t.Fatalf("fuzzy result = %#v, want six subsequence ranges on line 1", fuzzy) + } +} + +func TestContentSearchAlwaysRanksContiguousAboveSubsequence(t *testing.T) { + query := foldRunes([]rune("needle")) + exactLine := []rune(strings.Repeat("x", 1_100_000) + "needle") + fuzzyLine := []rune("n_e_e_d_l_e") + + exact, ok, err := bestContentLineMatch(context.Background(), exactLine, query) + if err != nil { + t.Fatalf("bestContentLineMatch returned error: %v", err) + } + if !ok { + t.Fatal("long-line contiguous match was not found") + } + fuzzy, ok, err := bestContentLineMatch(context.Background(), fuzzyLine, query) + if err != nil { + t.Fatalf("bestContentLineMatch returned error: %v", err) + } + if !ok { + t.Fatal("subsequence match was not found") + } + if exact.score <= fuzzy.score { + t.Fatalf("contiguous score %d must exceed subsequence score %d", exact.score, fuzzy.score) + } +} + +func TestBestSubsequenceContentMatchChoosesTightestOverlappingCandidate(t *testing.T) { + line := []rune("a___aa") + match, ok, err := bestSubsequenceContentMatch( + context.Background(), + line, + foldRunes(line), + foldRunes([]rune("aa")), + ) + if err != nil { + t.Fatalf("bestSubsequenceContentMatch returned error: %v", err) + } + if !ok { + t.Fatal("subsequence match was not found") + } + if got := match.positions; len(got) != 2 || got[0] != 4 || got[1] != 5 { + t.Fatalf("positions = %v, want tightest overlapping match [4 5]", got) + } +} + +func TestBestSubsequenceContentMatchChecksCancellationDuringMatching(t *testing.T) { + base, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ctx := &cancelOnErrCheckContext{ + Context: base, + cancel: cancel, + checksRemaining: 2, + } + line := []rune(strings.Repeat("a_", 2_000)) + + _, _, err := bestSubsequenceContentMatch( + ctx, + line, + foldRunes(line), + foldRunes([]rune("aa")), + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("bestSubsequenceContentMatch error = %v, want context.Canceled", err) + } +} + +func TestBestSubsequenceContentMatchBoundsPathologicalLongLineWork(t *testing.T) { + const expectedFuzzyWorkLimit = 250_000 + line := []rune(strings.Repeat("x", expectedFuzzyWorkLimit+1) + "a_b") + + _, ok, err := bestSubsequenceContentMatch( + context.Background(), + line, + foldRunes(line), + foldRunes([]rune("ab")), + ) + if err != nil { + t.Fatalf("bestSubsequenceContentMatch returned error: %v", err) + } + if ok { + t.Fatal("fuzzy match beyond the bounded work limit should be skipped") + } +} + +type cancelOnErrCheckContext struct { + context.Context + cancel context.CancelFunc + checksRemaining int +} + +func (ctx *cancelOnErrCheckContext) Err() error { + ctx.checksRemaining-- + if ctx.checksRemaining <= 0 { + ctx.cancel() + } + return ctx.Context.Err() +} + +func TestWorkspaceTrackerSearchContentUsesGitFileSetAndSkipsUnsafeOrBinaryFiles(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + writeFile(t, repoDir, ".gitignore", "ignored.txt\n") + writeFile(t, repoDir, "tracked.txt", "tracked secret\n") + runGit(t, repoDir, "add", ".gitignore", "tracked.txt") + runGit(t, repoDir, "commit", "-m", "add searchable files") + writeFile(t, repoDir, "untracked.txt", "untracked secret\n") + writeFile(t, repoDir, "ignored.txt", "ignored secret\n") + if err := os.WriteFile(filepath.Join(repoDir, "binary.bin"), []byte("binary\x00secret"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repoDir, "invalid.txt"), []byte("invalid secret\xff"), 0o644); err != nil { + t.Fatal(err) + } + largePath := filepath.Join(repoDir, "large.txt") + if err := os.WriteFile(largePath, []byte("large secret"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Truncate(largePath, maxFileSize+1); err != nil { + t.Fatal(err) + } + + outsideDir := t.TempDir() + if err := os.WriteFile(filepath.Join(outsideDir, "outside.txt"), []byte("outside secret"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outsideDir, "outside.txt"), filepath.Join(repoDir, "unsafe-link.txt")); err == nil { + runGit(t, repoDir, "add", "unsafe-link.txt") + runGit(t, repoDir, "commit", "-m", "add unsafe link") + } + + tracker := NewWorkspaceTracker(repoDir, newTestLogger(t)) + results, err := tracker.SearchContent(context.Background(), "secret", 50) + if err != nil { + t.Fatalf("SearchContent returned error: %v", err) + } + gotPaths := make([]string, 0, len(results)) + for _, result := range results { + gotPaths = append(gotPaths, result.Path) + } + want := []string{"tracked.txt", "untracked.txt"} + if strings.Join(gotPaths, ",") != strings.Join(want, ",") { + t.Fatalf("searchable paths = %v, want %v", gotPaths, want) + } +} + +func TestWorkspaceTrackerSearchContentBoundsPreviewAroundUTF16Match(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + line := strings.Repeat("x", 400) + "🙂 Needle" + strings.Repeat("y", 400) + writeFile(t, repoDir, "long.txt", line+"\n") + + tracker := NewWorkspaceTracker(repoDir, newTestLogger(t)) + results, err := tracker.SearchContent(context.Background(), "needle", 10) + if err != nil { + t.Fatalf("SearchContent returned error: %v", err) + } + if len(results) != 1 { + t.Fatalf("results = %#v, want one", results) + } + result := results[0] + if utf8.RuneCountInString(result.Preview) > workspaceContentSearchPreviewRunes+2 { + t.Fatalf("preview has %d runes, want at most %d", + utf8.RuneCountInString(result.Preview), workspaceContentSearchPreviewRunes+2) + } + if result.Column != 404 { + t.Fatalf("column = %d, want UTF-16 column 404", result.Column) + } + if len(result.MatchRanges) != 1 || + result.MatchRanges[0].Start != 77 || result.MatchRanges[0].End != 83 { + t.Fatalf("preview match ranges = %#v, want [{77 83}]", result.MatchRanges) + } +} + +func TestManagerSearchWorkspaceContentGroupsResultsByRepository(t *testing.T) { + taskRoot := t.TempDir() + for _, name := range []string{"zeta", "alpha"} { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + writeFile(t, repoDir, "match.txt", "first token\nsecond token\n") + if err := os.Rename(repoDir, filepath.Join(taskRoot, name)); err != nil { + t.Fatalf("place %s: %v", name, err) + } + } + + manager := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t)) + response, err := manager.SearchWorkspaceContent(context.Background(), "token", 1) + if err != nil { + t.Fatalf("SearchWorkspaceContent returned error: %v", err) + } + if len(response.Results) != 2 { + t.Fatalf("result count = %d, want one per repo: %#v", len(response.Results), response.Results) + } + if response.Results[0].RepositoryName != "alpha" || + response.Results[1].RepositoryName != "zeta" { + t.Fatalf("repository order = %q, %q; want alpha, zeta", + response.Results[0].RepositoryName, response.Results[1].RepositoryName) + } +} + +func TestManagerSearchWorkspaceContentClampsLimitPerRepository(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + writeFile(t, repoDir, "many.txt", strings.Repeat("needle\n", 60)) + + manager := NewManager(&config.InstanceConfig{WorkDir: repoDir}, newTestLogger(t)) + response, err := manager.SearchWorkspaceContent(context.Background(), "needle", 500) + if err != nil { + t.Fatalf("SearchWorkspaceContent returned error: %v", err) + } + if len(response.Results) != workspaceContentSearchMaxLimit { + t.Fatalf("result count = %d, want clamp %d", len(response.Results), workspaceContentSearchMaxLimit) + } +} + +func TestManagerSearchWorkspaceContentRejectsLongQuery(t *testing.T) { + manager := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t)) + _, err := manager.SearchWorkspaceContent( + context.Background(), + strings.Repeat("界", WorkspaceContentSearchMaxQueryRunes+1), + 10, + ) + if !errors.Is(err, ErrContentSearchQueryTooLong) { + t.Fatalf("SearchWorkspaceContent error = %v, want ErrContentSearchQueryTooLong", err) + } +} + +func TestWorkspaceTrackerSearchContentHonorsCancellation(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + writeFile(t, repoDir, "match.txt", "needle\n") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + tracker := NewWorkspaceTracker(repoDir, newTestLogger(t)) + _, err := tracker.SearchContent(ctx, "needle", 10) + if !errors.Is(err, context.Canceled) { + t.Fatalf("SearchContent error = %v, want context.Canceled", err) + } +} + +func TestReadContentSearchFilePropagatesCancellation(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + writeFile(t, repoDir, "match.txt", "needle\n") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + tracker := NewWorkspaceTracker(repoDir, newTestLogger(t)) + _, searchable, err := tracker.readContentSearchFile(ctx, "match.txt") + if searchable { + t.Fatal("canceled read unexpectedly reported searchable content") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("readContentSearchFile error = %v, want context.Canceled", err) + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index 26fb72441a..fde4edb96a 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -862,10 +862,18 @@ func calculateSHA256(content string) string { return hex.EncodeToString(hash[:]) } -// scoredMatch holds a file path and its match score for sorting +// scoredMatch holds a file path, repository identity, and match score for sorting. type scoredMatch struct { - path string - score int + path string + matchPath string + repositoryName string + score int +} + +type fileSearchCandidate struct { + path string + matchPath string + repositoryName string } // GetFileContentAtRef returns the content of a file at a specific git ref (branch, commit, HEAD, etc). @@ -928,8 +936,75 @@ func (wt *WorkspaceTracker) GetFileContentAtRef(ctx context.Context, reqPath str // SearchFiles searches for files matching the query string. // It uses fuzzy matching with scoring based on how well the query matches. func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { + wt.mu.RLock() + candidates := make([]fileSearchCandidate, 0, len(wt.currentFiles.Files)) + for _, file := range wt.currentFiles.Files { + candidates = append(candidates, fileSearchCandidate{ + path: file.Path, + matchPath: file.Path, + }) + } + wt.mu.RUnlock() + + results := searchFileCandidates(candidates, query, limit) + paths := make([]string, 0, len(results)) + for _, result := range results { + paths = append(paths, result.Path) + } + return paths +} + +// SearchWorkspaceFileResults searches every repository represented by the manager. +// Multi-repo results retain their task-root-relative repository prefix so +// existing file consumers can open the returned path without extra metadata. +func (m *Manager) SearchWorkspaceFileResults(query string, limit int) []types.FileSearchResult { + root, repositories := m.snapshotTrackers() + if len(repositories) == 0 { + if root == nil { + return []types.FileSearchResult{} + } + candidates := appendTrackerFileSearchCandidates(nil, root) + return searchFileCandidates(candidates, query, limit) + } + + candidates := make([]fileSearchCandidate, 0) + if root != nil && root.RepositoryName() != "" { + candidates = appendTrackerFileSearchCandidates(candidates, root) + } + for _, tracker := range repositories { + candidates = appendTrackerFileSearchCandidates(candidates, tracker) + } + return searchFileCandidates(candidates, query, limit) +} + +func appendTrackerFileSearchCandidates( + candidates []fileSearchCandidate, + tracker *WorkspaceTracker, +) []fileSearchCandidate { + tracker.mu.RLock() + defer tracker.mu.RUnlock() + repository := tracker.RepositoryName() + for _, file := range tracker.currentFiles.Files { + path := file.Path + if repository != "" { + path = filepath.ToSlash(filepath.Join(repository, path)) + } + candidates = append(candidates, fileSearchCandidate{ + path: path, + matchPath: file.Path, + repositoryName: repository, + }) + } + return candidates +} + +func searchFileCandidates( + candidates []fileSearchCandidate, + query string, + limit int, +) []types.FileSearchResult { if query == "" { - return []string{} + return []types.FileSearchResult{} } if limit <= 0 { limit = 20 @@ -938,16 +1013,15 @@ func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { query = strings.ToLower(query) var matches []scoredMatch - wt.mu.RLock() - files := wt.currentFiles.Files - wt.mu.RUnlock() - - for _, file := range files { - path := file.Path - if isRootOwnershipMarkerPath(path) { + for _, candidate := range candidates { + if isRootOwnershipMarkerPath(candidate.path) { continue } - lowerPath := strings.ToLower(path) + matchPath := candidate.matchPath + if matchPath == "" { + matchPath = candidate.path + } + lowerPath := strings.ToLower(matchPath) name := filepath.Base(lowerPath) score := 0 @@ -963,7 +1037,12 @@ func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { } if score > 0 { - matches = append(matches, scoredMatch{path: path, score: score}) + matches = append(matches, scoredMatch{ + path: candidate.path, + matchPath: matchPath, + repositoryName: candidate.repositoryName, + score: score, + }) } } @@ -973,13 +1052,16 @@ func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { return matches[i].score > matches[j].score } // Secondary sort by path length (shorter paths first) - return len(matches[i].path) < len(matches[j].path) + return len(matches[i].matchPath) < len(matches[j].matchPath) }) // Return top limit results - result := make([]string, 0, limit) + result := make([]types.FileSearchResult, 0, limit) for i := 0; i < len(matches) && i < limit; i++ { - result = append(result, matches[i].path) + result = append(result, types.FileSearchResult{ + RepositoryName: matches[i].repositoryName, + Path: matches[i].path, + }) } return result diff --git a/apps/backend/internal/agentctl/server/process/workspace_files_test.go b/apps/backend/internal/agentctl/server/process/workspace_files_test.go index 993ef6df01..9111b12ff0 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files_test.go @@ -479,6 +479,44 @@ func TestSearchFiles_HidesOnlyRootOwnershipMarker(t *testing.T) { } } +func TestSearchFileCandidatesDoesNotMatchRepositoryName(t *testing.T) { + results := searchFileCandidates([]fileSearchCandidate{ + { + path: "web/docs/unrelated.txt", + repositoryName: "web", + matchPath: "docs/unrelated.txt", + }, + { + path: "backend/src/web-client.ts", + repositoryName: "backend", + matchPath: "src/web-client.ts", + }, + }, "web", 20) + + if len(results) != 1 || results[0].Path != "backend/src/web-client.ts" { + t.Fatalf("search results = %#v, want only the repo-relative filename match", results) + } +} + +func TestSearchFileCandidatesBreaksTiesByRepositoryRelativePathLength(t *testing.T) { + results := searchFileCandidates([]fileSearchCandidate{ + { + path: "long-repository-name/a/query.go", + repositoryName: "long-repository-name", + matchPath: "a/query.go", + }, + { + path: "x/much-longer/query.go", + repositoryName: "x", + matchPath: "much-longer/query.go", + }, + }, "query.go", 20) + + if len(results) != 2 || results[0].Path != "long-repository-name/a/query.go" { + t.Fatalf("search results = %#v, want shortest repo-relative path first", results) + } +} + func TestGetFileTree_Symlinks(t *testing.T) { tmpDir := t.TempDir() wt := &WorkspaceTracker{workDir: tmpDir} diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go index 078dc63435..b296e49b86 100644 --- a/apps/backend/internal/agentctl/types/streams/file.go +++ b/apps/backend/internal/agentctl/types/streams/file.go @@ -159,15 +159,65 @@ type FileSearchRequest struct { Limit int `json:"limit"` } +// FileSearchResult identifies a matching file and its repository. +type FileSearchResult struct { + // RepositoryName is empty for single-repository workspaces. + RepositoryName string `json:"repository_name,omitempty"` + + // Path is task-root-relative so existing file APIs can open it directly. + Path string `json:"path"` +} + // FileSearchResponse represents a response with matching files. type FileSearchResponse struct { - // Files is the list of matching file paths. + // Files is the legacy list of matching task-root-relative paths. Files []string `json:"files"` + // Results carries repository identity for grouping-aware consumers. + Results []FileSearchResult `json:"results"` + // Error contains error message if the request failed. Error string `json:"error,omitempty"` } +// WorkspaceContentSearchMaxQueryRunes is the largest accepted content query. +const WorkspaceContentSearchMaxQueryRunes = 200 + +// WorkspaceContentSearchRequest searches file contents in every repository in +// the active task workspace. +// +// HTTP endpoint: GET /api/v1/workspace/content-search +type WorkspaceContentSearchRequest struct { + // Query is matched case-insensitively against individual text lines. + Query string `json:"query"` + + // LimitPerRepo is the maximum number of results returned per repository. + LimitPerRepo int `json:"limit_per_repo"` +} + +// WorkspaceContentMatchRange is a half-open UTF-16 range in a result preview. +type WorkspaceContentMatchRange struct { + Start int `json:"start"` + End int `json:"end"` +} + +// WorkspaceContentSearchResult identifies one matching line in a repository. +type WorkspaceContentSearchResult struct { + RepositoryName string `json:"repository_name"` + Path string `json:"path"` + Line int `json:"line"` + Column int `json:"column"` + Preview string `json:"preview"` + MatchRanges []WorkspaceContentMatchRange `json:"match_ranges"` +} + +// WorkspaceContentSearchResponse contains content matches grouped in +// deterministic repository order. +type WorkspaceContentSearchResponse struct { + Results []WorkspaceContentSearchResult `json:"results"` + Error string `json:"error,omitempty"` +} + // FileUpdateRequest represents a request to update file content using a diff. // // HTTP endpoint: POST /api/v1/workspace/file/content diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go index 6cc34de850..88a91d3658 100644 --- a/apps/backend/internal/agentctl/types/types.go +++ b/apps/backend/internal/agentctl/types/types.go @@ -34,16 +34,21 @@ type ( FileInfo = streams.FileInfo // File stream types - FileChangeNotification = streams.FileChangeNotification - FileListUpdate = streams.FileListUpdate - FileEntry = streams.FileEntry - FileTreeNode = streams.FileTreeNode - FileTreeRequest = streams.FileTreeRequest - FileTreeResponse = streams.FileTreeResponse - FileContentRequest = streams.FileContentRequest - FileContentResponse = streams.FileContentResponse - FileSearchRequest = streams.FileSearchRequest - FileSearchResponse = streams.FileSearchResponse + FileChangeNotification = streams.FileChangeNotification + FileListUpdate = streams.FileListUpdate + FileEntry = streams.FileEntry + FileTreeNode = streams.FileTreeNode + FileTreeRequest = streams.FileTreeRequest + FileTreeResponse = streams.FileTreeResponse + FileContentRequest = streams.FileContentRequest + FileContentResponse = streams.FileContentResponse + FileSearchRequest = streams.FileSearchRequest + FileSearchResult = streams.FileSearchResult + FileSearchResponse = streams.FileSearchResponse + WorkspaceContentSearchRequest = streams.WorkspaceContentSearchRequest + WorkspaceContentMatchRange = streams.WorkspaceContentMatchRange + WorkspaceContentSearchResult = streams.WorkspaceContentSearchResult + WorkspaceContentSearchResponse = streams.WorkspaceContentSearchResponse // Shell stream types ShellMessage = streams.ShellMessage @@ -84,6 +89,8 @@ const ( FileOpChmod = streams.FileOpChmod FileOpRefresh = streams.FileOpRefresh + WorkspaceContentSearchMaxQueryRunes = streams.WorkspaceContentSearchMaxQueryRunes + // Shell message types ShellMsgTypeInput = streams.ShellMsgTypeInput ShellMsgTypeOutput = streams.ShellMsgTypeOutput diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 099f2cb299..8ad3eb4fe0 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -259,6 +259,7 @@ const ( ActionWorkspaceFileDelete = "workspace.file.delete" ActionWorkspaceFileRename = "workspace.file.rename" ActionWorkspaceFilesSearch = "workspace.files.search" + ActionWorkspaceContentSearch = "workspace.content.search" ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification // Shell actions diff --git a/apps/web/components/command-panel-content-search.test.tsx b/apps/web/components/command-panel-content-search.test.tsx new file mode 100644 index 0000000000..bbcce49eb3 --- /dev/null +++ b/apps/web/components/command-panel-content-search.test.tsx @@ -0,0 +1,328 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WorkspaceContentSearchResult } from "@/lib/types/backend"; + +vi.mock("@kandev/ui/command", () => ({ + Command: ({ children, shouldFilter }: { children: ReactNode; shouldFilter?: boolean }) => ( +
+ {children} +
+ ), + CommandDialog: ({ children, open }: { children: ReactNode; open: boolean }) => + open ?
{children}
: null, + CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, + CommandGroup: ({ + children, + heading, + "data-testid": testId, + "data-repository": repository, + }: { + children: ReactNode; + heading?: ReactNode; + "data-testid"?: string; + "data-repository"?: string; + }) => ( +
+ {heading &&

{heading}

} + {children} +
+ ), + CommandInput: ({ + placeholder, + value, + onKeyDown, + }: { + placeholder: string; + value: string; + onKeyDown: (event: React.KeyboardEvent) => void; + }) => ( +
+ +
+ ), + CommandItem: ({ children }: { children: ReactNode }) =>
{children}
, + CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + CommandShortcut: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock("@kandev/ui/kbd", () => ({ + Kbd: ({ children }: { children: ReactNode }) => {children}, + KbdGroup: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock("@kandev/ui/badge", () => ({ + Badge: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + userSettings: { keyboardShortcuts: {} }, + taskSessions: { items: {} }, + kanban: { tasks: [] }, + repositories: { itemsByWorkspaceId: {} }, + }), +})); + +type MockContentSearchProps = { + onSelect: (result: WorkspaceContentSearchResult) => void; + results: WorkspaceContentSearchResult[]; +}; + +const mockContentSearch = vi.fn(({ onSelect, results }: MockContentSearchProps) => ( + +)); + +vi.mock("./workspace-content-search", () => ({ + WorkspaceContentSearch: (props: MockContentSearchProps) => mockContentSearch(props), +})); + +import { + CommandPanelView, + MODE_SEARCH_CONTENT, + type CommandPanelViewProps, +} from "./command-panel-footer"; + +const result: WorkspaceContentSearchResult = { + repository_name: "web", + path: "src/app.tsx", + line: 5, + column: 3, + preview: "needle", + match_ranges: [{ start: 0, end: 6 }], +}; + +const ARIA_SELECTED_ATTRIBUTE = "aria-selected"; + +function viewProps(overrides: Partial = {}): CommandPanelViewProps { + return { + open: true, + setOpen: vi.fn(), + mode: MODE_SEARCH_CONTENT, + inputCommand: null, + selectedValue: "", + setSelectedValue: vi.fn(), + search: "needle", + setSearch: vi.fn(), + handleKeyDown: vi.fn(), + onScopeChange: vi.fn(), + goBack: vi.fn(), + fileResults: [], + isSearchingFiles: false, + handleFileSelect: vi.fn(), + contentResults: [result], + isSearchingContent: false, + contentSearchError: null, + activeSessionId: "session-1", + workspaceSearchAvailable: true, + handleContentSelect: vi.fn(), + commands: [], + grouped: [], + handleSelect: vi.fn(), + isSearching: false, + taskResults: [], + stepMap: new Map(), + repoMap: new Map(), + handleTaskSelect: vi.fn(), + ...overrides, + }; +} + +afterEach(() => { + cleanup(); + mockContentSearch.mockClear(); +}); + +describe("CommandPanelView task content search mode", () => { + it("renders a dedicated input and forwards repository results", () => { + const props = viewProps(); + render(); + + expect(screen.getByPlaceholderText("Search task contents…")).toBeTruthy(); + expect(screen.getByText("Contents")).toBeTruthy(); + expect(mockContentSearch).toHaveBeenCalledWith( + expect.objectContaining({ + results: [result], + isSearching: false, + error: null, + search: "needle", + sessionId: "session-1", + }), + ); + + fireEvent.click(screen.getByTestId("mock-content-search")); + expect(props.handleContentSelect).toHaveBeenCalledWith(result); + }); + + it("makes all palette scopes visible and switches without clearing the query", () => { + const onScopeChange = vi.fn(); + const props = viewProps({ onScopeChange }); + render(); + + const tabs = screen.getAllByRole("tab"); + expect(tabs).toHaveLength(3); + expect( + screen.getByRole("tab", { name: "Commands" }).getAttribute(ARIA_SELECTED_ATTRIBUTE), + ).toBe("false"); + expect(screen.getByRole("tab", { name: "Files" }).getAttribute(ARIA_SELECTED_ATTRIBUTE)).toBe( + "false", + ); + expect( + screen.getByRole("tab", { name: "Contents" }).getAttribute(ARIA_SELECTED_ATTRIBUTE), + ).toBe("true"); + + fireEvent.click(screen.getByRole("tab", { name: "Files" })); + + expect(onScopeChange).toHaveBeenCalledWith("search-files"); + expect(props.setSearch).not.toHaveBeenCalled(); + expect((screen.getByRole("combobox") as HTMLInputElement).value).toBe("needle"); + }); + + it("keeps the mode selector inline with the search input", () => { + render(); + + const inputWrapper = screen.getByRole("combobox").closest("[data-slot=command-input-wrapper]"); + const switcher = screen.getByRole("tablist", { name: "Command palette mode" }); + + expect(switcher.parentElement).toBe(inputWrapper?.parentElement); + }); + + it("keeps balanced breathing room between the input and header divider", () => { + render(); + + const inputWrapper = screen.getByRole("combobox").closest("[data-slot=command-input-wrapper]"); + + expect(inputWrapper?.parentElement?.className).toContain( + "[&>[data-slot=command-input-wrapper]]:pb-1", + ); + }); + + it("uses a low-chrome text selector with an active underline", () => { + render(); + + const switcher = screen.getByRole("tablist", { name: "Command palette mode" }); + expect(switcher.querySelector("kbd")).toBeNull(); + expect(switcher.querySelectorAll("svg")).toHaveLength(0); + expect(screen.queryByTestId("command-panel-scope-indicator")).toBeNull(); + for (const tab of screen.getAllByRole("tab")) { + expect(tab.getAttribute("tabindex")).toBe("-1"); + expect(tab.className).toContain( + tab.getAttribute(ARIA_SELECTED_ATTRIBUTE) === "true" + ? "after:opacity-100" + : "after:opacity-0", + ); + } + }); + + it("cycles palette scopes with Tab and Shift+Tab", () => { + const onScopeChange = vi.fn(); + const props = viewProps({ onScopeChange }); + render(); + const input = screen.getByRole("combobox"); + input.focus(); + + expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false); + expect(onScopeChange).toHaveBeenLastCalledWith("commands"); + expect(document.activeElement).toBe(input); + + expect(fireEvent.keyDown(input, { key: "Tab", shiftKey: true })).toBe(false); + expect(onScopeChange).toHaveBeenLastCalledWith("search-files"); + expect(document.activeElement).toBe(input); + }); + + it("hides workspace modes and leaves Tab alone outside a task workbench", () => { + const onScopeChange = vi.fn(); + render( + , + ); + const input = screen.getByRole("combobox"); + + expect(screen.queryByRole("tablist", { name: "Command palette mode" })).toBeNull(); + expect(screen.queryByText("Switch mode")).toBeNull(); + expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(true); + expect(onScopeChange).not.toHaveBeenCalled(); + }); +}); + +describe("CommandPanelView mode result safety", () => { + it("does not delegate filtering to cmdk while palette mode groups swap", () => { + render(); + + expect(screen.getByTestId("command-root").getAttribute("data-should-filter")).toBe("false"); + }); + + it("filters command results before rendering them", () => { + render( + , + ); + + expect(screen.getByText("Needle command")).toBeTruthy(); + expect(screen.queryByText("Unrelated action")).toBeNull(); + }); + + it("returns a stale workspace-search mode to commands when context disappears", () => { + const onScopeChange = vi.fn(); + + render( + , + ); + + expect(onScopeChange).toHaveBeenCalledWith("commands"); + }); + + it("groups file matches by repository", () => { + render( + , + ); + + const groups = screen.getAllByTestId("file-search-repo-group"); + expect(groups).toHaveLength(2); + expect(groups[0].getAttribute("data-repository")).toBe("backend"); + expect(groups[1].getAttribute("data-repository")).toBe("frontend"); + }); +}); diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx index 22db973663..0e64f6e1c5 100644 --- a/apps/web/components/command-panel-footer.tsx +++ b/apps/web/components/command-panel-footer.tsx @@ -1,6 +1,6 @@ "use client"; -import type { Dispatch, SetStateAction } from "react"; +import { useEffect, type Dispatch, type SetStateAction } from "react"; import { formatDistanceToNow } from "date-fns"; import { IconArchive, IconArrowRight, IconHammer, IconLoader2 } from "@tabler/icons-react"; import { @@ -22,14 +22,25 @@ import { sortCommandsForSearch, } from "@/lib/commands/search"; import { formatShortcut } from "@/lib/keyboard/utils"; -import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; -import { useAppStore } from "@/components/state-provider"; import type { Task } from "@/lib/types/http"; +import type { FileSearchResult } from "@/lib/types/backend"; import { FileIcon } from "@/components/ui/file-icon"; +import { WorkspaceContentSearch } from "@/components/workspace-content-search"; +import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; +import { groupByRepositoryName, isSingleRepoGroup } from "@/lib/group-by-repo"; +import { + CommandPanelScopeSwitcher, + getAdjacentCommandPanelScope, + isCommandPanelScopeMode, + type CommandPanelScopeMode, +} from "@/components/command-panel-scope-switcher"; +import type { WorkspaceContentSearchError } from "@/hooks/domains/session/use-workspace-content-search"; +import type { WorkspaceContentSearchResult } from "@/lib/types/backend"; const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); export const MODE_COMMANDS: CommandPanelMode = "commands"; export const MODE_SEARCH_FILES: CommandPanelMode = "search-files"; +export const MODE_SEARCH_CONTENT: CommandPanelMode = "search-content"; const STEP_COLOR_MAP: Record = { "bg-slate-500": "#64748b", @@ -185,10 +196,11 @@ function CommandsListContent({ )} {search.trim() ? ( - {/* cmdk preserves this priority pre-sort when filter scores tie. */} - {sortCommandsForSearch(commands, search).map((cmd) => ( - - ))} + {sortCommandsForSearch(commands, search) + .filter((cmd) => scoreCommandSearch(cmd.id, search, getCommandSearchTerms(cmd)) > 0) + .map((cmd) => ( + + ))} ) : ( grouped.map(([group, items]) => ( @@ -204,13 +216,21 @@ function CommandsListContent({ } type FileSearchContentProps = { - files: string[]; + files: FileSearchResult[]; isSearching: boolean; search: string; + sessionId: string | null; onSelect: (path: string) => void; }; -function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchContentProps) { +function FileSearchContent({ + files, + isSearching, + search, + sessionId, + onSelect, +}: FileSearchContentProps) { + const getRepoDisplayName = useRepoDisplayName(sessionId); if (isSearching && files.length === 0) { return (
@@ -220,39 +240,59 @@ function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchC } if (search.trim() && files.length === 0) return No files found.; if (!search.trim()) return Type to search files...; - return ( - - {files.map((filePath) => { - const fileName = getFileName(filePath); - const lastSlash = filePath.lastIndexOf("/"); - const dir = lastSlash > 0 ? filePath.slice(0, lastSlash) : ""; + + const groups = groupByRepositoryName(files, (file) => file.repository_name); + const singleRepo = isSingleRepoGroup(groups); + return groups.map((group) => ( + + {group.items.map((file) => { + const repositoryPrefix = file.repository_name ? `${file.repository_name}/` : ""; + const displayPath = file.path.startsWith(repositoryPrefix) + ? file.path.slice(repositoryPrefix.length) + : file.path; + const fileName = getFileName(displayPath); + const lastSlash = displayPath.lastIndexOf("/"); + const dir = lastSlash > 0 ? displayPath.slice(0, lastSlash) : ""; return ( onSelect(filePath)} + key={file.path} + value={getFileResultValue(file.path)} + onSelect={() => onSelect(file.path)} forceMount > - {fileName} - {dir && {dir}} + {fileName} + {dir && {dir}} ); })} - ); + )); } function getInputPlaceholder(mode: CommandPanelMode, inputCommand: CommandItemType | null) { if (mode === "input") return inputCommand?.inputPlaceholder ?? "Enter value..."; if (mode === "search-tasks") return "Search for tasks..."; if (mode === MODE_SEARCH_FILES) return "Search for files..."; + if (mode === MODE_SEARCH_CONTENT) return "Search task contents…"; return "Type a command..."; } function getEnterLabel(mode: CommandPanelMode) { if (mode === "input") return "Confirm"; - if (mode === "search-tasks" || mode === MODE_SEARCH_FILES) return "Open"; + if (mode === "search-tasks" || mode === MODE_SEARCH_FILES || mode === MODE_SEARCH_CONTENT) { + return "Open"; + } return "Select"; } @@ -260,31 +300,41 @@ function getModeLabel(mode: CommandPanelMode, inputCommand: CommandItemType | nu if (mode === "input") return inputCommand?.label; if (mode === "search-tasks") return "Tasks"; if (mode === MODE_SEARCH_FILES) return "Files"; + if (mode === MODE_SEARCH_CONTENT) return "Contents"; return null; } -function CommandPanelFooter({ mode }: { mode: CommandPanelMode }) { - const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); +function CommandPanelFooter({ + mode, + workspaceSearchAvailable, +}: { + mode: CommandPanelMode; + workspaceSearchAvailable: boolean; +}) { + const isScopeMode = isCommandPanelScopeMode(mode); + const canSwitchScope = workspaceSearchAvailable && isScopeMode; return (
- {mode === MODE_COMMANDS && ( + {isScopeMode && ( <> ↑ ↓ Navigate - - {formatShortcut(getShortcut("FILE_SEARCH", keyboardShortcuts))} - File Search - + {canSwitchScope && ( + + Tab + Switch mode + + )} )} ↵ {getEnterLabel(mode)} - {mode !== MODE_COMMANDS && ( + {!isScopeMode && ( ⌫ Back @@ -308,10 +358,17 @@ export type CommandPanelViewProps = { search: string; setSearch: (value: string) => void; handleKeyDown: (e: React.KeyboardEvent) => void; + onScopeChange: (mode: CommandPanelScopeMode) => void; goBack: () => void; - fileResults: string[]; + fileResults: FileSearchResult[]; isSearchingFiles: boolean; handleFileSelect: (filePath: string) => void; + contentResults: WorkspaceContentSearchResult[]; + isSearchingContent: boolean; + contentSearchError: WorkspaceContentSearchError | null; + activeSessionId: string | null; + workspaceSearchAvailable: boolean; + handleContentSelect: (result: WorkspaceContentSearchResult) => void; commands: CommandItemType[]; grouped: Array<[string, CommandItemType[]]>; handleSelect: (cmd: CommandItemType) => void; @@ -322,30 +379,141 @@ export type CommandPanelViewProps = { handleTaskSelect: (task: Task) => void; }; -export function CommandPanelView({ - open, - setOpen, +function CommandPanelInputHeader({ mode, inputCommand, - selectedValue, - setSelectedValue, search, setSearch, handleKeyDown, + onScopeChange, goBack, - fileResults, - isSearchingFiles, - handleFileSelect, - commands, - grouped, - handleSelect, - isSearching, - taskResults, - stepMap, - repoMap, - handleTaskSelect, + workspaceSearchAvailable, }: CommandPanelViewProps) { + const isTopLevelMode = isCommandPanelScopeMode(mode); + const canSwitchScope = workspaceSearchAvailable && isTopLevelMode; const modeLabel = getModeLabel(mode, inputCommand); + const onInputKeyDown = (event: React.KeyboardEvent) => { + if ( + canSwitchScope && + event.key === "Tab" && + !event.altKey && + !event.ctrlKey && + !event.metaKey + ) { + event.preventDefault(); + onScopeChange(getAdjacentCommandPanelScope(mode, event.shiftKey)); + return; + } + handleKeyDown(event); + }; + return ( +
+ {!isTopLevelMode && ( + + )} + + {canSwitchScope && } +
+ ); +} + +function CommandPanelResultList(props: CommandPanelViewProps) { + const { + mode, + inputCommand, + search, + fileResults, + isSearchingFiles, + handleFileSelect, + contentResults, + isSearchingContent, + contentSearchError, + activeSessionId, + handleContentSelect, + commands, + grouped, + handleSelect, + isSearching, + taskResults, + stepMap, + repoMap, + handleTaskSelect, + } = props; + return ( + + {mode === MODE_COMMANDS && ( + + )} + {mode === MODE_SEARCH_FILES && ( + + )} + {mode === MODE_SEARCH_CONTENT && ( + + )} + {mode === "input" && + (!search.trim() ? ( + {inputCommand?.inputPlaceholder ?? "Enter a value..."} + ) : ( + Press Enter to confirm + ))} + + ); +} + +export function CommandPanelView(props: CommandPanelViewProps) { + const { + open, + setOpen, + mode, + selectedValue, + setSelectedValue, + workspaceSearchAvailable, + onScopeChange, + } = props; + const workspaceModeUnavailable = + !workspaceSearchAvailable && (mode === MODE_SEARCH_FILES || mode === MODE_SEARCH_CONTENT); + const renderedProps = workspaceModeUnavailable ? { ...props, mode: MODE_COMMANDS } : props; + + useEffect(() => { + if (workspaceModeUnavailable) onScopeChange("commands"); + }, [onScopeChange, workspaceModeUnavailable]); + return ( -
- {mode !== MODE_COMMANDS && ( - - )} - -
- - {mode === MODE_COMMANDS && ( - - )} - {mode === MODE_SEARCH_FILES && ( - - )} - {mode === "input" && - (!search.trim() ? ( - {inputCommand?.inputPlaceholder ?? "Enter a value..."} - ) : ( - Press Enter to confirm - ))} - - + + +
); diff --git a/apps/web/components/command-panel-scope-switcher.tsx b/apps/web/components/command-panel-scope-switcher.tsx new file mode 100644 index 0000000000..6f1586ee06 --- /dev/null +++ b/apps/web/components/command-panel-scope-switcher.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useAppStore } from "@/components/state-provider"; +import type { CommandPanelMode } from "@/lib/commands/types"; +import type { ConfigurableShortcutId } from "@/lib/keyboard/shortcut-overrides"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; +import { formatShortcut } from "@/lib/keyboard/utils"; +import { cn } from "@/lib/utils"; + +export type CommandPanelScopeMode = "commands" | "search-files" | "search-content"; + +type ScopeOption = { + mode: CommandPanelScopeMode; + label: string; + shortcutId: ConfigurableShortcutId; +}; + +const SCOPE_OPTIONS: ScopeOption[] = [ + { mode: "commands", label: "Commands", shortcutId: "SEARCH" }, + { mode: "search-files", label: "Files", shortcutId: "FILE_SEARCH" }, + { + mode: "search-content", + label: "Contents", + shortcutId: "CONTENT_SEARCH", + }, +]; + +export function isCommandPanelScopeMode(mode: CommandPanelMode): mode is CommandPanelScopeMode { + return SCOPE_OPTIONS.some((scope) => scope.mode === mode); +} + +export function getAdjacentCommandPanelScope( + mode: CommandPanelScopeMode, + reverse = false, +): CommandPanelScopeMode { + const currentIndex = SCOPE_OPTIONS.findIndex((scope) => scope.mode === mode); + const offset = reverse ? -1 : 1; + const nextIndex = (currentIndex + offset + SCOPE_OPTIONS.length) % SCOPE_OPTIONS.length; + return SCOPE_OPTIONS[nextIndex].mode; +} + +export function CommandPanelScopeSwitcher({ + mode, + onScopeChange, +}: { + mode: CommandPanelScopeMode; + onScopeChange: (mode: CommandPanelScopeMode) => void; +}) { + const keyboardShortcuts = useAppStore((state) => state.userSettings.keyboardShortcuts); + + return ( +
+ {SCOPE_OPTIONS.map((scope) => { + const active = mode === scope.mode; + const shortcut = formatShortcut(getShortcut(scope.shortcutId, keyboardShortcuts)); + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx index 994da9bef7..9cd1b4c21f 100644 --- a/apps/web/components/command-panel.tsx +++ b/apps/web/components/command-panel.tsx @@ -2,39 +2,44 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "@/lib/routing/client-router"; +import { usePathname } from "@/lib/routing/client-router"; import { useCommands, useCommandPanelOpen } from "@/lib/commands/command-registry"; import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types"; import { findFirstMatchingCommand, selectCommandSearchResult } from "@/lib/commands/search"; -import { SHORTCUTS } from "@/lib/keyboard/constants"; -import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; -import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { useCommandPanelShortcuts } from "@/hooks/use-command-panel-shortcuts"; +import { useContentSearchResultOpener } from "@/hooks/use-content-search-result-opener"; +import { useWorkspaceContentSearch } from "@/hooks/domains/session/use-workspace-content-search"; import { useAppStore } from "@/components/state-provider"; +import { + isCommandPanelScopeMode, + type CommandPanelScopeMode, +} from "@/components/command-panel-scope-switcher"; import { listTasksByWorkspace } from "@/lib/api"; import { linkToTask } from "@/lib/links"; import type { Task } from "@/lib/types/http"; +import type { FileSearchResult } from "@/lib/types/backend"; import { getWebSocketClient } from "@/lib/ws/connection"; import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import { useDockviewStore } from "@/lib/state/dockview-store"; +import { getContentSearchResultValue } from "@/components/workspace-content-search"; +import { getFileName } from "@/lib/utils/file-path"; +import { isTaskWorkspaceSearchAvailable } from "@/lib/commands/task-workspace-search"; import { CommandPanelView, MODE_COMMANDS, + MODE_SEARCH_CONTENT, MODE_SEARCH_FILES, getFileResultValue, getTaskResultValue, } from "@/components/command-panel-footer"; -function getFileName(filePath: string) { - return filePath.split("/").pop() ?? filePath; -} - -function useCommandPanelState() { - const [mode, setMode] = useState(MODE_COMMANDS); +function useCommandPanelState(mode: CommandPanelMode, setMode: (mode: CommandPanelMode) => void) { const [search, setSearch] = useState(""); const [inputCommand, setInputCommand] = useState(null); const [taskResults, setTaskResults] = useState([]); const [isSearching, setIsSearching] = useState(false); - const [fileResults, setFileResults] = useState([]); + const [fileResults, setFileResults] = useState([]); const [isSearchingFiles, setIsSearchingFiles] = useState(false); const [selectedValue, setSelectedValue] = useState(""); return { @@ -57,27 +62,33 @@ function useCommandPanelState() { }; } -function useCommandPanelEffectRefs() { - const debounceRef = useRef | null>(null); - const abortRef = useRef(null); - const fileDebounceRef = useRef | null>(null); - return { debounceRef, abortRef, fileDebounceRef }; -} - type FileSearchEffectOptions = { mode: CommandPanelMode; search: string; + workspaceSearchAvailable: boolean; activeSessionId: string | null; - setFileResults: (files: string[]) => void; + setFileResults: (files: FileSearchResult[]) => void; setIsSearchingFiles: (searching: boolean) => void; fileDebounceRef: React.RefObject | null>; }; function useFileSearchEffect(opts: FileSearchEffectOptions) { - const { mode, search, activeSessionId, setFileResults, setIsSearchingFiles, fileDebounceRef } = - opts; + const { + mode, + search, + workspaceSearchAvailable, + activeSessionId, + setFileResults, + setIsSearchingFiles, + fileDebounceRef, + } = opts; useEffect(() => { - if (mode !== MODE_SEARCH_FILES || !search.trim() || !activeSessionId) { + if ( + !workspaceSearchAvailable || + mode !== MODE_SEARCH_FILES || + !search.trim() || + !activeSessionId + ) { setFileResults([]); setIsSearchingFiles(false); return; @@ -94,8 +105,8 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) { try { const res = await searchWorkspaceFiles(client, activeSessionId, search.trim(), 10); if (!cancelled) { - const files = res.files ?? []; - setFileResults(files); + const results = res.results ?? (res.files ?? []).map((path) => ({ path })); + setFileResults(results); } } catch { if (!cancelled) setFileResults([]); @@ -107,7 +118,15 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) { cancelled = true; if (fileDebounceRef.current) clearTimeout(fileDebounceRef.current); }; - }, [activeSessionId, fileDebounceRef, mode, search, setFileResults, setIsSearchingFiles]); + }, [ + activeSessionId, + fileDebounceRef, + mode, + search, + setFileResults, + setIsSearchingFiles, + workspaceSearchAvailable, + ]); } const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); @@ -242,13 +261,26 @@ function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) { ]); } -function useCommandPanelEffects( - open: boolean, - state: ReturnType, - workspaceId: string | null, - activeSessionId: string | null, - steps: { id: string; position: number; show_in_command_panel?: boolean }[], -) { +type CommandPanelEffectsOptions = { + open: boolean; + state: ReturnType; + workspaceId: string | null; + activeSessionId: string | null; + workspaceSearchAvailable: boolean; + steps: { id: string; position: number; show_in_command_panel?: boolean }[]; + modeRequestVersion: number; +}; + +function useCommandPanelEffects(options: CommandPanelEffectsOptions) { + const { + open, + state, + workspaceId, + activeSessionId, + workspaceSearchAvailable, + steps, + modeRequestVersion, + } = options; const { mode, search, @@ -261,7 +293,24 @@ function useCommandPanelEffects( setIsSearchingFiles, setSelectedValue, } = state; - const { fileDebounceRef } = useCommandPanelEffectRefs(); + const fileDebounceRef = useRef | null>(null); + const previousRequestVersion = useRef(modeRequestVersion); + useEffect(() => { + if (previousRequestVersion.current === modeRequestVersion) return; + previousRequestVersion.current = modeRequestVersion; + setSearch(""); + setInputCommand(null); + setTaskResults([]); + setFileResults([]); + setSelectedValue(""); + }, [ + modeRequestVersion, + setFileResults, + setInputCommand, + setSearch, + setSelectedValue, + setTaskResults, + ]); useEffect(() => { if (!open) { const t = setTimeout(() => { @@ -289,6 +338,7 @@ function useCommandPanelEffects( useFileSearchEffect({ mode, search, + workspaceSearchAvailable, activeSessionId, setFileResults, setIsSearchingFiles, @@ -300,6 +350,7 @@ function useFirstResultSelection( open: boolean, state: ReturnType, commands: CommandItemType[], + contentResults: ReturnType["results"], ) { const { mode, search, taskResults, fileResults, setSelectedValue } = state; const previousCommandsRef = useRef(commands); @@ -333,12 +384,18 @@ function useFirstResultSelection( if (mode === MODE_SEARCH_FILES) { const firstFile = fileResults[0]; - setSelectedValue(firstFile ? getFileResultValue(firstFile) : ""); + setSelectedValue(firstFile ? getFileResultValue(firstFile.path) : ""); + return; + } + + if (mode === MODE_SEARCH_CONTENT) { + const firstResult = contentResults[0]; + setSelectedValue(firstResult ? getContentSearchResultValue(firstResult) : ""); return; } setSelectedValue(""); - }, [commands, fileResults, mode, open, search, setSelectedValue, taskResults]); + }, [commands, contentResults, fileResults, mode, open, search, setSelectedValue, taskResults]); } function useCommandPanelHandlers( @@ -416,7 +473,7 @@ function useCommandPanelHandlers( inputCommand.onInputSubmit(search.trim()); return; } - if (mode !== MODE_COMMANDS && e.key === "Backspace" && !search) { + if (!isCommandPanelScopeMode(mode) && e.key === "Backspace" && !search) { e.preventDefault(); setMode(MODE_COMMANDS); setSearch(""); @@ -426,6 +483,11 @@ function useCommandPanelHandlers( [mode, search, inputCommand, setOpen, setMode, setSearch, setInputCommand], ); + const onScopeChange = (nextMode: CommandPanelScopeMode) => { + setMode(nextMode); + setInputCommand(null); + }; + const goBack = useCallback(() => { setMode(MODE_COMMANDS); setSearch(""); @@ -440,20 +502,28 @@ function useCommandPanelHandlers( handleTaskSelect, handleFileSelect, handleKeyDown, + onScopeChange, goBack, }; } export function CommandPanel() { - const { open, setOpen } = useCommandPanelOpen(); + const { open, setOpen, mode: panelMode, setMode, modeRequestVersion } = useCommandPanelOpen(); const commands = useCommands(); + const pathname = usePathname(); const kanbanSteps = useAppStore((state) => state.kanban.steps); const workspaceId = useAppStore((state) => state.workspaces.activeId); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const workspaceSearchAvailable = useAppStore((state) => + isTaskWorkspaceSearchAvailable(state, pathname), + ); + const worktreePath = useAppStore((s) => + activeSessionId ? (s.taskSessions.items[activeSessionId]?.worktree_path ?? null) : null, + ); const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); const repositories = workspaceId ? (reposByWorkspace[workspaceId] ?? []) : []; - const state = useCommandPanelState(); + const state = useCommandPanelState(panelMode, setMode); const { mode, search, @@ -467,35 +537,34 @@ export function CommandPanel() { setSearch, } = state; - useCommandPanelEffects(open, state, workspaceId, activeSessionId, kanbanSteps); - useFirstResultSelection(open, state, commands); + useCommandPanelEffects({ + open, + state, + workspaceId, + activeSessionId, + workspaceSearchAvailable, + steps: kanbanSteps, + modeRequestVersion, + }); + const { + results: contentResults, + isSearching: isSearchingContent, + error: contentSearchError, + } = useWorkspaceContentSearch({ + enabled: open && workspaceSearchAvailable && mode === MODE_SEARCH_CONTENT, + query: search, + sessionId: activeSessionId, + }); + useFirstResultSelection(open, state, commands, contentResults); - const openRef = useRef(open); - useEffect(() => { - openRef.current = open; - }, [open]); - - const toggleCommands = useCallback(() => setOpen(!openRef.current), [setOpen]); - - const openFileSearch = useCallback(() => { - if (openRef.current && state.mode === MODE_SEARCH_FILES) { - setOpen(false); - } else { - state.setMode(MODE_SEARCH_FILES); - state.setSearch(""); - setOpen(true); - } - }, [setOpen, state]); - - const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); - const searchShortcut = getShortcut("SEARCH", keyboardShortcuts); - const fileSearchShortcut = getShortcut("FILE_SEARCH", keyboardShortcuts); - - const commandPanelShortcut = getShortcut("COMMAND_PANEL", keyboardShortcuts); - useKeyboardShortcut(searchShortcut, toggleCommands); - useKeyboardShortcut(commandPanelShortcut, toggleCommands); - useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL_SHIFT, toggleCommands); - useKeyboardShortcut(fileSearchShortcut, openFileSearch); + useCommandPanelShortcuts({ + open, + setOpen, + mode, + workspaceSearchAvailable, + setMode, + setSearch, + }); const { grouped, @@ -505,8 +574,10 @@ export function CommandPanel() { handleTaskSelect, handleFileSelect, handleKeyDown, + onScopeChange, goBack, } = useCommandPanelHandlers(state, setOpen, commands, kanbanSteps, repositories); + const handleContentSelect = useContentSearchResultOpener(setOpen, worktreePath); return ( ({ + onCreateEditor: null as ((view: unknown) => void) | null, +})); +const getMonacoInstance = vi.hoisted(() => vi.fn()); + +vi.mock("@/components/editors/monaco/monaco-init", () => ({ + getMonacoInstance, +})); + +vi.mock("@uiw/react-codemirror", () => ({ + default: ({ onCreateEditor }: { onCreateEditor: (view: unknown) => void }) => { + editorHarness.onCreateEditor = onCreateEditor; + return
; + }, +})); + +vi.mock("./use-codemirror-editor-state", () => ({ + useCodeMirrorEditorState: () => ({ + comments: [], + diffStats: null, + extensions: [], + floatingButtonPos: null, + textSelection: null, + commentView: null, + wrapEnabled: false, + setWrapEnabled: vi.fn(), + handleChange: vi.fn(), + }), +})); + +vi.mock("./use-codemirror-walkthrough-range", () => ({ + useCodeMirrorWalkthroughRange: () => null, +})); + +vi.mock("@/components/editors/external-vcs-file-link", () => ({ + ExternalVcsFileLink: () => null, + useExternalVcsFileStatus: () => null, +})); + +import { CodeMirrorCodeEditor } from "./codemirror-code-editor"; + +const FILE_PATH = "src/app.ts"; +const FRONTEND_REPO = "frontend"; +const BACKEND_REPO = "backend"; +const CONTENT = "alpha\nbravo\ncharlie"; + +const baseProps = { + path: FILE_PATH, + content: CONTENT, + originalContent: CONTENT, + isDirty: false, + isSaving: false, + repo: FRONTEND_REPO, + onChange: vi.fn(), + onSave: vi.fn(), +}; + +function createEditorView(content = CONTENT) { + return { + state: EditorState.create({ doc: content }), + dispatch: vi.fn(), + focus: vi.fn(), + } as unknown as EditorView; +} + +function renderEditor(props = baseProps) { + return render( + + + , + ); +} + +function mountEditorView(view: EditorView) { + act(() => editorHarness.onCreateEditor?.(view)); +} + +afterEach(() => { + cleanup(); + editorHarness.onCreateEditor = null; + getMonacoInstance.mockReset(); + consumePendingCursorPosition(FILE_PATH); + consumePendingCursorPosition(FILE_PATH, FRONTEND_REPO); + consumePendingCursorPosition(FILE_PATH, BACKEND_REPO); +}); + +describe("CodeMirrorCodeEditor pending cursor navigation", () => { + it("reveals the pending 1-based line and column for its repository", () => { + setPendingCursorPosition(FILE_PATH, 2, 3, FRONTEND_REPO); + const view = createEditorView(); + + renderEditor(); + mountEditorView(view); + + expect(view.dispatch).toHaveBeenCalledWith({ + selection: { anchor: 8 }, + effects: expect.anything(), + }); + expect(view.focus).toHaveBeenCalledTimes(1); + expect(consumePendingCursorPosition(FILE_PATH, FRONTEND_REPO)).toBeUndefined(); + }); + + it("leaves a pending position for the same path in another repository untouched", () => { + setPendingCursorPosition(FILE_PATH, 2, 3, FRONTEND_REPO); + setPendingCursorPosition(FILE_PATH, 3, 2, BACKEND_REPO); + const view = createEditorView(); + + renderEditor(); + mountEditorView(view); + + expect(view.dispatch).toHaveBeenCalledWith({ + selection: { anchor: 8 }, + effects: expect.anything(), + }); + expect(consumePendingCursorPosition(FILE_PATH, BACKEND_REPO)).toEqual({ + line: 3, + column: 2, + }); + }); + + it("clamps a pending position to the end of the document", () => { + setPendingCursorPosition(FILE_PATH, 99, 99, FRONTEND_REPO); + const view = createEditorView(); + + renderEditor(); + mountEditorView(view); + + expect(view.dispatch).toHaveBeenCalledWith({ + selection: { anchor: CONTENT.length }, + effects: expect.anything(), + }); + }); + + it("consumes a pending position when an already-mounted editor updates", () => { + const view = createEditorView(); + const rendered = renderEditor(); + mountEditorView(view); + setPendingCursorPosition(FILE_PATH, 2, 3, FRONTEND_REPO); + + rendered.rerender( + + + , + ); + + expect(view.dispatch).toHaveBeenCalledWith({ + selection: { anchor: 8 }, + effects: expect.anything(), + }); + expect(view.focus).toHaveBeenCalledTimes(1); + }); +}); + +describe("CodeMirrorCodeEditor mounted cursor broker", () => { + it("synchronously reveals a pending position in an already-mounted pinned editor", () => { + const view = createEditorView(); + const rendered = renderEditor(); + mountEditorView(view); + getMonacoInstance.mockReturnValue({ + editor: { + getEditors: () => [ + { + getModel: () => ({ uri: { path: "/worktree/src/other.ts" } }), + }, + ], + }, + }); + setPendingCursorPosition(FILE_PATH, 2, 3, FRONTEND_REPO); + + const revealed = scrollEditorIfMounted(FILE_PATH, null, 2, 3, FRONTEND_REPO); + + expect(revealed).toBe(true); + expect(view.dispatch).toHaveBeenCalledWith({ + selection: { anchor: 8 }, + effects: expect.anything(), + }); + expect(view.focus).toHaveBeenCalledTimes(1); + expect(consumePendingCursorPosition(FILE_PATH, FRONTEND_REPO)).toBeUndefined(); + + rendered.unmount(); + vi.mocked(view.dispatch).mockClear(); + setPendingCursorPosition(FILE_PATH, 3, 1, FRONTEND_REPO); + + expect(scrollEditorIfMounted(FILE_PATH, null, 3, 1, FRONTEND_REPO)).toBe(false); + expect(view.dispatch).not.toHaveBeenCalled(); + expect(consumePendingCursorPosition(FILE_PATH, FRONTEND_REPO)).toEqual({ + line: 3, + column: 1, + }); + }); + + it("does not reveal the same path from another repository", () => { + const view = createEditorView(); + renderEditor(); + mountEditorView(view); + setPendingCursorPosition(FILE_PATH, 3, 2, BACKEND_REPO); + + const revealed = scrollEditorIfMounted(FILE_PATH, null, 3, 2, BACKEND_REPO); + + expect(revealed).toBe(false); + expect(view.dispatch).not.toHaveBeenCalled(); + expect(view.focus).not.toHaveBeenCalled(); + expect(consumePendingCursorPosition(FILE_PATH, BACKEND_REPO)).toEqual({ + line: 3, + column: 2, + }); + }); +}); diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index 38f2b1ef92..008441c1d1 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; import type { EditorView } from "@codemirror/view"; import { Button } from "@kandev/ui/button"; @@ -26,8 +26,13 @@ import { ExternalVcsFileLink, useExternalVcsFileStatus, } from "@/components/editors/external-vcs-file-link"; +import { registerCodeMirrorCursorRevealer } from "@/hooks/file-editor-cursor"; import { useCodeMirrorEditorState } from "./use-codemirror-editor-state"; import { useCodeMirrorWalkthroughRange } from "./use-codemirror-walkthrough-range"; +import { + revealCodeMirrorCursor, + revealPendingCodeMirrorCursor, +} from "./codemirror-cursor-navigation"; const SAVE_SHORTCUT = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"; @@ -356,6 +361,15 @@ function useCodeMirrorCodeEditorSetup(props: FileEditorContentProps) { path, repo, }); + useEffect(() => { + if (editorView) revealPendingCodeMirrorCursor(editorView, path, repo); + }); + useEffect(() => { + if (!editorView) return; + return registerCodeMirrorCursorRevealer(path, repo, (line, column) => + revealCodeMirrorCursor(editorView, line, column), + ); + }, [editorView, path, repo]); const handleCreateEditor = useCallback((view: EditorView) => setEditorView(view), []); return { wrapperRef, editorAreaRef, editorRef, state, walkthroughRange, handleCreateEditor }; } diff --git a/apps/web/components/editors/codemirror/codemirror-cursor-navigation.test.ts b/apps/web/components/editors/codemirror/codemirror-cursor-navigation.test.ts new file mode 100644 index 0000000000..80719568eb --- /dev/null +++ b/apps/web/components/editors/codemirror/codemirror-cursor-navigation.test.ts @@ -0,0 +1,19 @@ +import { Text } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; +import { getCodeMirrorCursorOffset } from "./codemirror-cursor-navigation"; + +const DOCUMENT = Text.of(["alpha", "bravo", "charlie"]); + +describe("getCodeMirrorCursorOffset", () => { + it("translates a 1-based line and column into a document offset", () => { + expect(getCodeMirrorCursorOffset(DOCUMENT, 2, 3)).toBe(8); + }); + + it("clamps a position before the document to its start", () => { + expect(getCodeMirrorCursorOffset(DOCUMENT, -4, 0)).toBe(0); + }); + + it("clamps a position after the document to its end", () => { + expect(getCodeMirrorCursorOffset(DOCUMENT, 99, 99)).toBe(DOCUMENT.length); + }); +}); diff --git a/apps/web/components/editors/codemirror/codemirror-cursor-navigation.ts b/apps/web/components/editors/codemirror/codemirror-cursor-navigation.ts new file mode 100644 index 0000000000..670ef30c51 --- /dev/null +++ b/apps/web/components/editors/codemirror/codemirror-cursor-navigation.ts @@ -0,0 +1,34 @@ +import type { Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { consumePendingCursorPosition } from "@/hooks/file-editor-cursor"; + +function clampOneBased(value: number, maximum: number): number { + const integer = Number.isNaN(value) ? 1 : Math.trunc(value); + return Math.min(Math.max(integer, 1), maximum); +} + +export function getCodeMirrorCursorOffset(doc: Text, line: number, column: number): number { + const targetLine = doc.line(clampOneBased(line, doc.lines)); + const targetColumn = clampOneBased(column, targetLine.length + 1); + return targetLine.from + targetColumn - 1; +} + +export function revealCodeMirrorCursor(view: EditorView, line: number, column: number): boolean { + const anchor = getCodeMirrorCursorOffset(view.state.doc, line, column); + view.dispatch({ + selection: { anchor }, + effects: EditorView.scrollIntoView(anchor, { y: "center" }), + }); + view.focus(); + return true; +} + +export function revealPendingCodeMirrorCursor( + view: EditorView, + path: string, + repo?: string, +): boolean { + const pending = consumePendingCursorPosition(path, repo); + if (!pending) return false; + return revealCodeMirrorCursor(view, pending.line, pending.column); +} diff --git a/apps/web/components/workspace-content-search.test.tsx b/apps/web/components/workspace-content-search.test.tsx new file mode 100644 index 0000000000..239f902ef2 --- /dev/null +++ b/apps/web/components/workspace-content-search.test.tsx @@ -0,0 +1,220 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WorkspaceContentSearchResult } from "@/lib/types/backend"; + +vi.mock("@kandev/ui/command", () => ({ + CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, + CommandGroup: ({ + children, + heading, + forceMount: _forceMount, + ...props + }: { + children: ReactNode; + heading: ReactNode; + forceMount?: boolean; + }) => ( +
+

{heading}

+ {children} +
+ ), + CommandItem: ({ + children, + onSelect, + forceMount: _forceMount, + value: _value, + ...props + }: { + children: ReactNode; + onSelect: () => void; + forceMount?: boolean; + value?: string; + }) => ( + + ), +})); + +vi.mock("@/hooks/domains/session/use-repo-display-name", () => ({ + useRepoDisplayName: () => (repositoryName: string) => + repositoryName ? `Display ${repositoryName}` : undefined, +})); + +vi.mock("@/components/ui/file-icon", () => ({ + FileIcon: ({ fileName }: { fileName: string }) => , +})); + +import { + WorkspaceContentSearch, + getContentSearchResultValue, + splitPreviewByMatches, +} from "./workspace-content-search"; + +const REPO_A = "repo-a"; +const REPO_B = "repo-b"; +const SHARED_PATH = "src/shared.ts"; +const REPOSITORY_ATTRIBUTE = "data-repository"; + +const results: WorkspaceContentSearchResult[] = [ + { + repository_name: REPO_A, + path: SHARED_PATH, + line: 12, + column: 4, + preview: "const needle = true", + match_ranges: [{ start: 6, end: 12 }], + }, + { + repository_name: REPO_B, + path: SHARED_PATH, + line: 8, + column: 2, + preview: "return needle", + match_ranges: [{ start: 7, end: 13 }], + }, +]; + +afterEach(() => cleanup()); + +describe("content search result identity", () => { + it("keeps same-path results in different repositories distinct", () => { + expect(getContentSearchResultValue(results[0])).not.toBe( + getContentSearchResultValue(results[1]), + ); + }); +}); + +describe("splitPreviewByMatches", () => { + it("uses backend UTF-16 offsets without splitting an emoji surrogate pair", () => { + expect(splitPreviewByMatches("😀 needle", [{ start: 3, end: 9 }])).toEqual([ + { text: "😀 ", matched: false }, + { text: "needle", matched: true }, + ]); + }); + + it("clamps, sorts, and merges unsafe ranges", () => { + expect( + splitPreviewByMatches("abcdef", [ + { start: 4, end: 99 }, + { start: -2, end: 2 }, + { start: 1, end: 3 }, + { start: 3, end: 3 }, + ]), + ).toEqual([ + { text: "abc", matched: true }, + { text: "d", matched: false }, + { text: "ef", matched: true }, + ]); + }); +}); + +describe("WorkspaceContentSearch", () => { + it("groups results by raw repository while showing display labels", () => { + const onSelect = vi.fn(); + render( + , + ); + + const groups = screen.getAllByTestId("content-search-repo-group"); + expect(groups).toHaveLength(2); + expect(groups[0].getAttribute(REPOSITORY_ATTRIBUTE)).toBe(REPO_A); + expect(groups[1].getAttribute(REPOSITORY_ATTRIBUTE)).toBe(REPO_B); + expect(within(groups[0]).getByText(`Display ${REPO_A}`)).toBeTruthy(); + + const resultRows = screen.getAllByTestId("content-search-result"); + expect(resultRows[0].getAttribute("data-path")).toBe(SHARED_PATH); + expect(resultRows[0].getAttribute(REPOSITORY_ATTRIBUTE)).toBe(REPO_A); + expect(resultRows[0].getAttribute("data-line")).toBe("12"); + expect(within(resultRows[0]).getByText("12").classList.contains("tabular-nums")).toBe(true); + expect(within(resultRows[0]).getByTestId("content-search-match").textContent).toBe("needle"); + + fireEvent.click(resultRows[1]); + expect(onSelect).toHaveBeenCalledWith(results[1]); + }); + + it("uses a non-empty label for a single repository with an empty transport key", () => { + render( + , + ); + + expect(screen.getByTestId("content-search-repo-group").getAttribute(REPOSITORY_ATTRIBUTE)).toBe( + "", + ); + expect(screen.getByText("Results")).toBeTruthy(); + }); + + it("shows distinct empty, searching, and no-results states", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Type to search task contents…")).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByText("Searching task workspace…")).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByText("No content matches found.")).toBeTruthy(); + }); + + it.each([ + ["session-unavailable", "Content search needs an active task session."], + ["query-too-long", "Search queries are limited to 200 characters."], + ["transport-error", "Search failed. Edit the query or reopen search to retry."], + ] as const)("shows the %s state instead of no matches", (error, message) => { + render( + , + ); + + expect(screen.getByText(message)).toBeTruthy(); + expect(screen.queryByText("No content matches found.")).toBeNull(); + }); +}); diff --git a/apps/web/components/workspace-content-search.tsx b/apps/web/components/workspace-content-search.tsx new file mode 100644 index 0000000000..b3f293c94e --- /dev/null +++ b/apps/web/components/workspace-content-search.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { IconLoader2 } from "@tabler/icons-react"; +import { CommandEmpty, CommandGroup, CommandItem } from "@kandev/ui/command"; +import { FileIcon } from "@/components/ui/file-icon"; +import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; +import type { WorkspaceContentSearchError } from "@/hooks/domains/session/use-workspace-content-search"; +import { groupByRepositoryName, isSingleRepoGroup } from "@/lib/group-by-repo"; +import type { ContentSearchMatchRange, WorkspaceContentSearchResult } from "@/lib/types/backend"; + +type PreviewPart = { text: string; matched: boolean }; + +function normalizeMatchRanges( + previewLength: number, + ranges: ContentSearchMatchRange[], +): ContentSearchMatchRange[] { + const normalized = ranges + .filter(({ start, end }) => Number.isFinite(start) && Number.isFinite(end) && end > start) + .map(({ start, end }) => ({ + start: Math.max(0, Math.min(previewLength, Math.floor(start))), + end: Math.max(0, Math.min(previewLength, Math.floor(end))), + })) + .filter(({ start, end }) => end > start) + .sort((a, b) => a.start - b.start || a.end - b.end); + + const merged: ContentSearchMatchRange[] = []; + for (const range of normalized) { + const previous = merged.at(-1); + if (previous && range.start <= previous.end) { + previous.end = Math.max(previous.end, range.end); + } else { + merged.push({ ...range }); + } + } + return merged; +} + +export function splitPreviewByMatches( + preview: string, + ranges: ContentSearchMatchRange[], +): PreviewPart[] { + const parts: PreviewPart[] = []; + let cursor = 0; + for (const range of normalizeMatchRanges(preview.length, ranges)) { + if (range.start > cursor) { + parts.push({ text: preview.slice(cursor, range.start), matched: false }); + } + parts.push({ text: preview.slice(range.start, range.end), matched: true }); + cursor = range.end; + } + if (cursor < preview.length) parts.push({ text: preview.slice(cursor), matched: false }); + return parts; +} + +export function getContentSearchResultValue(result: WorkspaceContentSearchResult): string { + return `__content:${JSON.stringify([ + result.repository_name, + result.path, + result.line, + result.column, + ])}`; +} + +function getFileName(path: string): string { + return path.split("/").pop() || path; +} + +function HighlightedPreview({ result }: { result: WorkspaceContentSearchResult }) { + return ( + + {splitPreviewByMatches(result.preview, result.match_ranges).map((part, index) => + part.matched ? ( + + {part.text} + + ) : ( + part.text + ), + )} + + ); +} + +function SearchResultRow({ + result, + onSelect, +}: { + result: WorkspaceContentSearchResult; + onSelect: (result: WorkspaceContentSearchResult) => void; +}) { + return ( + onSelect(result)} + forceMount + data-testid="content-search-result" + data-path={result.path} + data-repository={result.repository_name} + data-line={result.line} + className="min-h-10 cursor-pointer items-start py-2" + > + + + + {result.path} + + {result.line} + + + + + + ); +} + +type WorkspaceContentSearchProps = { + results: WorkspaceContentSearchResult[]; + isSearching: boolean; + error: WorkspaceContentSearchError | null; + search: string; + sessionId: string | null; + onSelect: (result: WorkspaceContentSearchResult) => void; +}; + +export function WorkspaceContentSearch({ + results, + isSearching, + error, + search, + sessionId, + onSelect, +}: WorkspaceContentSearchProps) { + const getRepoDisplayName = useRepoDisplayName(sessionId); + if (error === "session-unavailable") { + return Content search needs an active task session.; + } + if (error === "query-too-long") { + return Search queries are limited to 200 characters.; + } + if (error === "transport-error") { + return Search failed. Edit the query or reopen search to retry.; + } + if (isSearching && results.length === 0) { + return ( + + + Searching task workspace… + + ); + } + if (!search.trim()) return Type to search task contents…; + if (results.length === 0) return No content matches found.; + + const groups = groupByRepositoryName(results, (result) => result.repository_name); + const singleRepo = isSingleRepoGroup(groups); + return groups.map((group) => ( + + {group.items.map((result) => ( + + ))} + + )); +} diff --git a/apps/web/e2e/tests/command-panel.spec.ts b/apps/web/e2e/tests/command-panel.spec.ts index bd89069170..85430185bd 100644 --- a/apps/web/e2e/tests/command-panel.spec.ts +++ b/apps/web/e2e/tests/command-panel.spec.ts @@ -156,16 +156,33 @@ test.describe("Command Panel", () => { await expect(testPage.getByRole("heading", { name: /Create Pull Request/ })).toBeVisible(); }); - test("Cmd+Shift+K opens file search mode with appropriate placeholder", async ({ testPage }) => { - const kanban = new KanbanPage(testPage); - await kanban.goto(); + test("Cmd+Shift+K opens file search inside a task workbench", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "File Search Shortcut E2E", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: seedData.worktreeExecutorProfileId, + }, + ); + await testPage.goto(`/t/${task.id}`); + await new SessionPage(testPage).waitForLoad(); await openFileSearch(testPage); const dialog = commandDialog(testPage); await expect(dialog).toBeVisible({ timeout: 5_000 }); - // Should show the "Files" back-button breadcrumb - await expect(dialog.getByRole("button", { name: /Files/ })).toBeVisible(); + await expect(dialog.getByRole("tab", { name: "Files" })).toHaveAttribute( + "aria-selected", + "true", + ); // Should show empty state for file search await expect(dialog.getByText("Type to search files...")).toBeVisible(); @@ -280,9 +297,24 @@ test.describe("Command Panel", () => { await expect(dialog).not.toBeVisible({ timeout: 3_000 }); }); - test("Backspace in file search mode returns to commands mode", async ({ testPage }) => { - const kanban = new KanbanPage(testPage); - await kanban.goto(); + test("Backspace keeps the empty file-search scope active", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "File Search Backspace E2E", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: seedData.worktreeExecutorProfileId, + }, + ); + await testPage.goto(`/t/${task.id}`); + await new SessionPage(testPage).waitForLoad(); // Open file search mode await openFileSearch(testPage); @@ -290,14 +322,18 @@ test.describe("Command Panel", () => { await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog.getByText("Type to search files...")).toBeVisible(); - // Focus the input and press backspace on empty input to go back to commands mode + // Files is a peer scope, not a nested command mode, so an empty Backspace + // should remain in place instead of acting as hidden "back" navigation. const input = dialog.getByRole("combobox"); await input.focus(); await input.press("Backspace"); - // Should now show the commands mode (navigation commands visible) - await expect(dialog.getByText("Navigation")).toBeVisible({ timeout: 5_000 }); - await expect(dialog.getByText("Go to Home")).toBeVisible({ timeout: 3_000 }); + await expect(dialog.getByRole("tab", { name: "Files" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(dialog.getByText("Type to search files...")).toBeVisible(); + await expect(dialog.getByText("Navigation")).toHaveCount(0); }); test("Cmd+K toggles the panel open and closed", async ({ testPage }) => { diff --git a/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts b/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts new file mode 100644 index 0000000000..3e757a3513 --- /dev/null +++ b/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts @@ -0,0 +1,185 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { expect, test } from "../../fixtures/test-base"; +import { GitHelper, makeGitEnv } from "../../helpers/git-helper"; +import { SessionPage } from "../../pages/session-page"; +import { MODIFIER } from "./shared"; + +const SEARCH_TERM = "TaskWorkspaceNeedle"; +const EXTRA_REPOSITORY_NAME = "content-search-extra"; +const TARGET_LINE = 9; + +function fileContent(marker: string, targetLine: number): string { + const lines = Array.from( + { length: targetLine - 1 }, + (_, index) => `export const filler${index + 1} = ${index + 1};`, + ); + lines.push(`export const ${marker} = "${SEARCH_TERM}";`); + return `${lines.join("\n")}\n`; +} + +function initializeRepository(directory: string, gitEnv: NodeJS.ProcessEnv): GitHelper { + fs.mkdirSync(directory, { recursive: true }); + execSync("git init -b main", { cwd: directory, env: gitEnv }); + return new GitHelper(directory, gitEnv); +} + +test("@search keeps workspace modes out of non-task routes", async ({ testPage }) => { + await testPage.goto("/settings/general"); + + const dialog = testPage.getByRole("dialog"); + await testPage.keyboard.press(`${MODIFIER}+Shift+f`); + await expect(dialog).not.toBeVisible(); + await testPage.keyboard.press(`${MODIFIER}+Shift+k`); + await expect(dialog).not.toBeVisible(); + + await testPage.keyboard.press(`${MODIFIER}+k`); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("tablist", { name: "Command palette mode" })).toHaveCount(0); + await expect(dialog).not.toContainText("Switch mode"); +}); + +test("@search searches all task repositories and opens the selected match", async ({ + testPage, + apiClient, + seedData, + backend, +}) => { + test.setTimeout(120_000); + + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const sharedPath = `src/content-search-${suffix}.ts`; + const gitEnv = makeGitEnv(backend.tmpDir); + + const primaryGit = new GitHelper(path.join(backend.tmpDir, "repos", "e2e-repo"), gitEnv); + primaryGit.createFile(sharedPath, fileContent("PRIMARY_REPOSITORY_MATCH", 3)); + primaryGit.stageAll(); + primaryGit.commit("seed primary workspace content search"); + + const extraRepoDir = path.join(backend.tmpDir, "repos", `${EXTRA_REPOSITORY_NAME}-${suffix}`); + const extraGit = initializeRepository(extraRepoDir, gitEnv); + extraGit.createFile(sharedPath, fileContent("EXTRA_REPOSITORY_MATCH", TARGET_LINE)); + extraGit.stageAll(); + extraGit.commit("seed extra workspace content search"); + const extraRepository = await apiClient.createRepository( + seedData.workspaceId, + extraRepoDir, + "main", + { name: EXTRA_REPOSITORY_NAME }, + ); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Multi-repository workspace content search", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId, extraRepository.id], + executor_profile_id: seedData.worktreeExecutorProfileId, + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + + // The global content-search shortcut must win even when an editable chat + // surface owns focus. + const composer = session.activeChat().getByTestId("chat-input-editor"); + await composer.click(); + await testPage.keyboard.press(`${MODIFIER}+Shift+f`); + + const dialog = testPage.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + const input = dialog.getByRole("combobox"); + await expect(input).toBeFocused(); + await input.fill(SEARCH_TERM); + + const groups = dialog.getByTestId("content-search-repo-group"); + await expect(groups).toHaveCount(2, { timeout: 15_000 }); + await expect( + dialog.locator( + `[data-testid="content-search-repo-group"][data-repository="${EXTRA_REPOSITORY_NAME}"]`, + ), + ).toHaveCount(1); + + const commandsTab = dialog.getByRole("tab", { name: "Commands" }); + const filesTab = dialog.getByRole("tab", { name: "Files" }); + const contentsTab = dialog.getByRole("tab", { name: "Contents" }); + await expect(contentsTab).toHaveAttribute("aria-selected", "true"); + + await commandsTab.click(); + await expect(input).toBeFocused(); + await expect(input).toHaveValue(SEARCH_TERM); + await expect(commandsTab).toHaveAttribute("aria-selected", "true"); + + await testPage.keyboard.press("Tab"); + await expect(filesTab).toHaveAttribute("aria-selected", "true"); + await expect(input).toHaveValue(SEARCH_TERM); + + const fileQuery = path.basename(sharedPath); + await input.fill(fileQuery); + const fileMatches = dialog.locator("[cmdk-item]").filter({ hasText: fileQuery }); + await expect(fileMatches).toHaveCount(2, { timeout: 10_000 }); + const fileGroups = dialog.getByTestId("file-search-repo-group"); + await expect(fileGroups).toHaveCount(2); + const extraFileGroup = fileGroups.filter({ hasText: EXTRA_REPOSITORY_NAME }); + await expect(extraFileGroup).toHaveCount(1); + await expect(extraFileGroup.locator("[cmdk-item]").filter({ hasText: fileQuery })).toHaveCount(1); + + await input.fill(SEARCH_TERM); + await testPage.keyboard.press("Tab"); + await expect(contentsTab).toHaveAttribute("aria-selected", "true"); + await expect(input).toBeFocused(); + await testPage.keyboard.press("Tab"); + await expect(commandsTab).toHaveAttribute("aria-selected", "true"); + await testPage.keyboard.press("Shift+Tab"); + await expect(contentsTab).toHaveAttribute("aria-selected", "true"); + await expect(input).toBeFocused(); + await testPage.keyboard.press("Shift+Tab"); + await expect(filesTab).toHaveAttribute("aria-selected", "true"); + await expect(dialog).toBeVisible(); + await expect(input).toBeFocused(); + + await testPage.keyboard.press("Tab"); + await expect(contentsTab).toHaveAttribute("aria-selected", "true"); + await expect(groups).toHaveCount(2, { timeout: 15_000 }); + + const extraResult = dialog + .locator(`[data-testid="content-search-result"][data-repository="${EXTRA_REPOSITORY_NAME}"]`) + .filter({ hasText: sharedPath }); + await expect(extraResult).toBeVisible(); + await expect(extraResult).toContainText(`${TARGET_LINE}`); + await extraResult.click(); + + await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + const selectedEditor = testPage + .locator(".monaco-editor:visible .view-lines") + .filter({ hasText: "EXTRA_REPOSITORY_MATCH" }); + await expect(selectedEditor).toBeVisible({ timeout: 15_000 }); + + await expect + .poll( + () => + testPage.evaluate((marker) => { + type Editor = { + getModel: () => { getValue: () => string } | null; + getPosition: () => { lineNumber: number } | null; + }; + type MonacoWindow = Window & { + monaco?: { editor: { getEditors: () => Editor[] } }; + }; + const editors = (window as MonacoWindow).monaco?.editor.getEditors() ?? []; + return ( + editors.find((editor) => editor.getModel()?.getValue().includes(marker))?.getPosition() + ?.lineNumber ?? null + ); + }, "EXTRA_REPOSITORY_MATCH"), + { timeout: 10_000 }, + ) + .toBe(TARGET_LINE); +}); diff --git a/apps/web/hooks/domains/session/use-workspace-content-search.test.ts b/apps/web/hooks/domains/session/use-workspace-content-search.test.ts new file mode 100644 index 0000000000..38faa47677 --- /dev/null +++ b/apps/web/hooks/domains/session/use-workspace-content-search.test.ts @@ -0,0 +1,194 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSearchWorkspaceContent = vi.fn(); +const mockClient = { request: vi.fn() }; + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => mockClient, +})); + +vi.mock("@/lib/ws/workspace-files", () => ({ + searchWorkspaceContent: (...args: unknown[]) => mockSearchWorkspaceContent(...args), +})); + +import { useWorkspaceContentSearch } from "./use-workspace-content-search"; + +beforeEach(() => { + vi.useFakeTimers(); + mockSearchWorkspaceContent.mockReset(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("useWorkspaceContentSearch request lifecycle", () => { + it("debounces a trimmed query for the active session", async () => { + const results = [ + { + repository_name: "web", + path: "src/app.tsx", + line: 4, + column: 2, + preview: "needle", + match_ranges: [{ start: 0, end: 6 }], + }, + ]; + mockSearchWorkspaceContent.mockResolvedValue({ results }); + const { result } = renderHook(() => + useWorkspaceContentSearch({ + enabled: true, + query: " needle ", + sessionId: "session-1", + }), + ); + + expect(result.current.isSearching).toBe(true); + await act(async () => vi.advanceTimersByTimeAsync(249)); + expect(mockSearchWorkspaceContent).not.toHaveBeenCalled(); + + await act(async () => vi.advanceTimersByTimeAsync(1)); + + expect(mockSearchWorkspaceContent).toHaveBeenCalledWith(mockClient, "session-1", "needle", 50); + expect(result.current.results).toEqual(results); + expect(result.current.isSearching).toBe(false); + }); + + it("clears the previous query results while the next search is pending", async () => { + const previousResults = [ + { + repository_name: "web", + path: "src/old.ts", + line: 1, + column: 1, + preview: "previous", + match_ranges: [], + }, + ]; + mockSearchWorkspaceContent + .mockResolvedValueOnce({ results: previousResults }) + .mockResolvedValueOnce({ results: [] }); + const { result, rerender } = renderHook( + ({ query }) => useWorkspaceContentSearch({ enabled: true, query, sessionId: "session-1" }), + { initialProps: { query: "previous" } }, + ); + await act(async () => vi.advanceTimersByTimeAsync(250)); + expect(result.current.results).toEqual(previousResults); + + rerender({ query: "next" }); + + expect(result.current.results).toEqual([]); + expect(result.current.isSearching).toBe(true); + }); + + it("clears results without sending when search is disabled", async () => { + mockSearchWorkspaceContent.mockResolvedValue({ results: [] }); + const { result, rerender } = renderHook( + ({ enabled, query }) => useWorkspaceContentSearch({ enabled, query, sessionId: "session-1" }), + { initialProps: { enabled: true, query: "needle" } }, + ); + + rerender({ enabled: false, query: "needle" }); + await act(async () => vi.runAllTimersAsync()); + + expect(mockSearchWorkspaceContent).not.toHaveBeenCalled(); + expect(result.current.results).toEqual([]); + expect(result.current.isSearching).toBe(false); + }); +}); + +describe("useWorkspaceContentSearch validation and failures", () => { + it("reports when the task has no active session", () => { + const { result } = renderHook(() => + useWorkspaceContentSearch({ + enabled: true, + query: "needle", + sessionId: null, + }), + ); + + expect(mockSearchWorkspaceContent).not.toHaveBeenCalled(); + expect(result.current.error).toBe("session-unavailable"); + }); + + it("rejects queries over 200 Unicode code points without sending", async () => { + const { result } = renderHook(() => + useWorkspaceContentSearch({ + enabled: true, + query: "😀".repeat(201), + sessionId: "session-1", + }), + ); + await act(async () => vi.runAllTimersAsync()); + + expect(mockSearchWorkspaceContent).not.toHaveBeenCalled(); + expect(result.current.error).toBe("query-too-long"); + expect(result.current.isSearching).toBe(false); + }); + + it("distinguishes a transport failure from an empty result", async () => { + mockSearchWorkspaceContent.mockRejectedValue(new Error("offline")); + const { result } = renderHook(() => + useWorkspaceContentSearch({ + enabled: true, + query: "needle", + sessionId: "session-1", + }), + ); + + await act(async () => vi.advanceTimersByTimeAsync(250)); + + expect(result.current.results).toEqual([]); + expect(result.current.error).toBe("transport-error"); + expect(result.current.isSearching).toBe(false); + }); +}); + +describe("useWorkspaceContentSearch stale responses", () => { + it("ignores a response from an outdated query", async () => { + const firstResults = [ + { + repository_name: "", + path: "old.ts", + line: 1, + column: 1, + preview: "first", + match_ranges: [], + }, + ]; + const secondResults = [ + { + repository_name: "", + path: "new.ts", + line: 1, + column: 1, + preview: "second", + match_ranges: [], + }, + ]; + let resolveFirst: ((value: { results: typeof firstResults }) => void) | undefined; + mockSearchWorkspaceContent + .mockImplementationOnce( + () => + new Promise<{ results: typeof firstResults }>((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce({ results: secondResults }); + const { result, rerender } = renderHook( + ({ query }) => useWorkspaceContentSearch({ enabled: true, query, sessionId: "session-1" }), + { initialProps: { query: "first" } }, + ); + await act(async () => vi.advanceTimersByTimeAsync(250)); + + rerender({ query: "second" }); + await act(async () => vi.advanceTimersByTimeAsync(250)); + await act(async () => resolveFirst?.({ results: firstResults })); + + expect(mockSearchWorkspaceContent).toHaveBeenCalledTimes(2); + expect(result.current.results).toEqual(secondResults); + expect(result.current.isSearching).toBe(false); + }); +}); diff --git a/apps/web/hooks/domains/session/use-workspace-content-search.ts b/apps/web/hooks/domains/session/use-workspace-content-search.ts new file mode 100644 index 0000000000..08007ea79d --- /dev/null +++ b/apps/web/hooks/domains/session/use-workspace-content-search.ts @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { WorkspaceContentSearchResult } from "@/lib/types/backend"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import { searchWorkspaceContent } from "@/lib/ws/workspace-files"; + +const CONTENT_SEARCH_DEBOUNCE_MS = 250; +const CONTENT_SEARCH_LIMIT_PER_REPO = 50; +const MAX_CONTENT_SEARCH_CODE_POINTS = 200; + +export type WorkspaceContentSearchError = + | "session-unavailable" + | "query-too-long" + | "transport-error"; + +type WorkspaceContentSearchOptions = { + enabled: boolean; + query: string; + sessionId: string | null; +}; + +export function useWorkspaceContentSearch({ + enabled, + query, + sessionId, +}: WorkspaceContentSearchOptions) { + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const normalizedQuery = query.trim(); + if (!enabled) { + setResults([]); + setIsSearching(false); + setError(null); + return; + } + if (!sessionId) { + setResults([]); + setIsSearching(false); + setError("session-unavailable"); + return; + } + if (!normalizedQuery) { + setResults([]); + setIsSearching(false); + setError(null); + return; + } + if (Array.from(normalizedQuery).length > MAX_CONTENT_SEARCH_CODE_POINTS) { + setResults([]); + setIsSearching(false); + setError("query-too-long"); + return; + } + + let cancelled = false; + setResults([]); + setIsSearching(true); + setError(null); + const timer = setTimeout(async () => { + const client = getWebSocketClient(); + if (!client) { + if (!cancelled) { + setResults([]); + setError("transport-error"); + setIsSearching(false); + } + return; + } + + try { + const response = await searchWorkspaceContent( + client, + sessionId, + normalizedQuery, + CONTENT_SEARCH_LIMIT_PER_REPO, + ); + if (!cancelled) { + setResults(response.results ?? []); + setError(null); + } + } catch { + if (!cancelled) { + setResults([]); + setError("transport-error"); + } + } finally { + if (!cancelled) setIsSearching(false); + } + }, CONTENT_SEARCH_DEBOUNCE_MS); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [enabled, query, sessionId]); + + return { results, isSearching, error }; +} diff --git a/apps/web/hooks/file-editor-cursor.ts b/apps/web/hooks/file-editor-cursor.ts index 2394204b19..c4c5ba44cf 100644 --- a/apps/web/hooks/file-editor-cursor.ts +++ b/apps/web/hooks/file-editor-cursor.ts @@ -3,6 +3,8 @@ import { walkthroughFileMatches } from "@/lib/diff/walkthrough-match"; import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions"; const pendingCursorPositions = new Map(); +type CodeMirrorCursorRevealer = (line: number, column: number) => boolean; +const codeMirrorCursorRevealers = new Map>(); function pendingCursorKey(path: string, repo?: string): string { return buildRepoScopedItemId(path, repo); @@ -27,6 +29,39 @@ export function consumePendingCursorPosition( return pos; } +export function registerCodeMirrorCursorRevealer( + path: string, + repo: string | undefined, + reveal: CodeMirrorCursorRevealer, +): () => void { + const key = pendingCursorKey(path, repo); + const revealers = codeMirrorCursorRevealers.get(key) ?? new Set(); + revealers.add(reveal); + codeMirrorCursorRevealers.set(key, revealers); + + return () => { + revealers.delete(reveal); + if (revealers.size === 0) codeMirrorCursorRevealers.delete(key); + }; +} + +function revealMountedCodeMirror( + path: string, + repo: string | undefined, + line: number, + column: number, +): boolean { + const revealers = codeMirrorCursorRevealers.get(pendingCursorKey(path, repo)); + if (!revealers) return false; + + for (const reveal of [...revealers].reverse()) { + if (!reveal(line, column)) continue; + consumePendingCursorPosition(path, repo); + return true; + } + return false; +} + function pathSegments(path: string): string[] { return path.trim().replaceAll("\\", "/").split("/").filter(Boolean); } @@ -47,6 +82,19 @@ function editorModelMatches(modelPath: string, monacoPath: string, path: string, return exactMatch || walkthroughFileMatches(modelPath, path); } +function mountedMonacoModelMatches( + modelPath: string, + monacoPath: string, + path: string, + worktreePath: string | null, + repo?: string, +): boolean { + if (worktreePath && (modelPath === `/${monacoPath}` || modelPath === monacoPath)) return true; + if (repo) return editorModelMatches(modelPath, monacoPath, path, repo); + if (worktreePath) return false; + return editorModelMatches(modelPath, monacoPath, path); +} + export function scrollEditorIfMounted( path: string, worktreePath: string | null, @@ -55,23 +103,21 @@ export function scrollEditorIfMounted( repo?: string, ): boolean { const monaco = getMonacoInstance(); - if (!monaco) return false; - - const monacoPath = worktreePath ? `${worktreePath}/${path}` : path; - for (const editor of monaco.editor.getEditors()) { - const model = editor.getModel(); - if (!model) continue; - const modelPath = model.uri.path; - const matches = worktreePath - ? modelPath === `/${monacoPath}` || modelPath === monacoPath - : editorModelMatches(modelPath, monacoPath, path, repo); - if (matches) { - consumePendingCursorPosition(path, repo); - editor.setPosition({ lineNumber: line, column }); - editor.revealLineInCenter(line); - editor.focus(); - return true; + if (monaco) { + const monacoPath = worktreePath ? `${worktreePath}/${path}` : path; + for (const editor of monaco.editor.getEditors()) { + const model = editor.getModel(); + if (!model) continue; + const modelPath = model.uri.path; + const matches = mountedMonacoModelMatches(modelPath, monacoPath, path, worktreePath, repo); + if (matches) { + consumePendingCursorPosition(path, repo); + editor.setPosition({ lineNumber: line, column }); + editor.revealLineInCenter(line); + editor.focus(); + return true; + } } } - return false; + return revealMountedCodeMirror(path, repo, line, column); } diff --git a/apps/web/hooks/use-app-shortcuts.test.ts b/apps/web/hooks/use-app-shortcuts.test.ts index 37b40e0687..e3725cbf8f 100644 --- a/apps/web/hooks/use-app-shortcuts.test.ts +++ b/apps/web/hooks/use-app-shortcuts.test.ts @@ -3,12 +3,27 @@ import { renderHook, cleanup } from "@testing-library/react"; import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; const mockToggleAppSidebar = vi.fn(); +const mockOpenMode = vi.fn(); let mockShortcuts: StoredShortcutOverrides = {}; +let mockPathname = "/t/task-1"; +let mockActiveTaskId: string | null = "task-1"; +let mockActiveSessionId: string | null = "session-1"; +let mockSessionTaskId: string | null = "task-1"; function buildState() { return { userSettings: { keyboardShortcuts: mockShortcuts }, toggleAppSidebar: mockToggleAppSidebar, + tasks: { + activeTaskId: mockActiveTaskId, + activeSessionId: mockActiveSessionId, + }, + taskSessions: { + items: + mockActiveSessionId && mockSessionTaskId + ? { [mockActiveSessionId]: { task_id: mockSessionTaskId } } + : {}, + }, }; } @@ -16,6 +31,14 @@ vi.mock("@/components/state-provider", () => ({ useAppStoreApi: () => ({ getState: () => buildState() }), })); +vi.mock("@/lib/commands/command-registry", () => ({ + useCommandPanelOpen: () => ({ openMode: mockOpenMode }), +})); + +vi.mock("@/lib/routing/client-router", () => ({ + usePathname: () => mockPathname, +})); + import { useAppShortcuts } from "./use-app-shortcuts"; /** Bind TOGGLE_SIDEBAR to Cmd/Ctrl+B (it is unbound by default). */ @@ -31,6 +54,22 @@ function pressB(modifier: "ctrlKey" | "metaKey", target: EventTarget = window): return event; } +function pressContentSearch( + modifier: "ctrlKey" | "metaKey", + target: EventTarget = window, +): KeyboardEvent { + const init: KeyboardEventInit = { + key: "f", + shiftKey: true, + bubbles: true, + cancelable: true, + }; + init[modifier] = true; + const event = new KeyboardEvent("keydown", init); + target.dispatchEvent(event); + return event; +} + // Cover both `ctrlOrCmd` branches: Ctrl on Windows/Linux, Cmd (metaKey) on macOS. const pressCtrlB = (target?: EventTarget) => pressB("ctrlKey", target); const pressMetaB = (target?: EventTarget) => pressB("metaKey", target); @@ -38,7 +77,12 @@ const pressMetaB = (target?: EventTarget) => pressB("metaKey", target); describe("useAppShortcuts", () => { beforeEach(() => { mockToggleAppSidebar.mockClear(); + mockOpenMode.mockClear(); mockShortcuts = {}; + mockPathname = "/t/task-1"; + mockActiveTaskId = "task-1"; + mockActiveSessionId = "session-1"; + mockSessionTaskId = "task-1"; }); // renderHook attaches a window listener; unmount it between tests so stale @@ -88,4 +132,72 @@ describe("useAppShortcuts", () => { pressCtrlB(); expect(mockToggleAppSidebar).not.toHaveBeenCalled(); }); + + it("opens task content search before an editable target handles Cmd/Ctrl+Shift+F", () => { + renderHook(() => useAppShortcuts()); + const input = document.createElement("input"); + document.body.appendChild(input); + + const event = pressContentSearch("ctrlKey", input); + + expect(mockOpenMode).toHaveBeenCalledWith("search-content"); + expect(event.defaultPrevented).toBe(true); + input.remove(); + }); + + it("opens task content search with Cmd on macOS", () => { + renderHook(() => useAppShortcuts()); + + const event = pressContentSearch("metaKey"); + + expect(mockOpenMode).toHaveBeenCalledWith("search-content"); + expect(event.defaultPrevented).toBe(true); + }); + + it("does not intercept content search outside an active task workbench", () => { + mockPathname = "/"; + renderHook(() => useAppShortcuts()); + + const event = pressContentSearch("ctrlKey"); + + expect(mockOpenMode).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it("uses a stored content-search override instead of its default", () => { + mockShortcuts = { + CONTENT_SEARCH: { key: "g", modifiers: { ctrlOrCmd: true, shift: true } }, + }; + renderHook(() => useAppShortcuts()); + + pressContentSearch("ctrlKey"); + expect(mockOpenMode).not.toHaveBeenCalled(); + + const event = new KeyboardEvent("keydown", { + key: "g", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(event); + + expect(mockOpenMode).toHaveBeenCalledWith("search-content"); + expect(event.defaultPrevented).toBe(true); + }); + + it("leaves panel-local Cmd/Ctrl+F untouched", () => { + renderHook(() => useAppShortcuts()); + const event = new KeyboardEvent("keydown", { + key: "f", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + window.dispatchEvent(event); + + expect(mockOpenMode).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); }); diff --git a/apps/web/hooks/use-app-shortcuts.ts b/apps/web/hooks/use-app-shortcuts.ts index 340df90302..f19d855b5c 100644 --- a/apps/web/hooks/use-app-shortcuts.ts +++ b/apps/web/hooks/use-app-shortcuts.ts @@ -4,6 +4,9 @@ import { useEffect } from "react"; import { useAppStoreApi } from "@/components/state-provider"; import { isEditableKeydownTarget, matchesShortcut } from "@/lib/keyboard/utils"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; +import { useCommandPanelOpen } from "@/lib/commands/command-registry"; +import { isTaskWorkspaceSearchAvailable } from "@/lib/commands/task-workspace-search"; +import { usePathname } from "@/lib/routing/client-router"; /** * App-root keyboard shortcuts that must fire on every route — not just inside @@ -33,13 +36,23 @@ import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; */ export function useAppShortcuts() { const appStore = useAppStoreApi(); + const { openMode } = useCommandPanelOpen(); + const pathname = usePathname(); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.defaultPrevented) return; - if (isEditableKeydownTarget(e)) return; const overrides = appStore.getState().userSettings.keyboardShortcuts; + if (matchesShortcut(e, getShortcut("CONTENT_SEARCH", overrides))) { + if (!isTaskWorkspaceSearchAvailable(appStore.getState(), pathname)) return; + e.preventDefault(); + e.stopPropagation(); + openMode("search-content"); + return; + } + if (isEditableKeydownTarget(e)) return; + if (matchesShortcut(e, getShortcut("TOGGLE_SIDEBAR", overrides))) { e.preventDefault(); e.stopPropagation(); @@ -51,5 +64,5 @@ export function useAppShortcuts() { // xterm.js) can swallow it — mirrors useEditorKeybinds. window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [appStore]); + }, [appStore, openMode, pathname]); } diff --git a/apps/web/hooks/use-command-panel-shortcuts.test.ts b/apps/web/hooks/use-command-panel-shortcuts.test.ts new file mode 100644 index 0000000000..8f587a585f --- /dev/null +++ b/apps/web/hooks/use-command-panel-shortcuts.test.ts @@ -0,0 +1,77 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { KeyboardShortcut } from "@/lib/keyboard/constants"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type ShortcutRegistration = { + shortcut: KeyboardShortcut; + callback: () => void; + enabled: boolean; +}; + +const registrations: ShortcutRegistration[] = []; + +vi.mock("@/hooks/use-keyboard-shortcut", () => ({ + useKeyboardShortcut: ( + shortcut: KeyboardShortcut, + callback: () => void, + options?: { enabled?: boolean }, + ) => { + registrations.push({ shortcut, callback, enabled: options?.enabled ?? true }); + }, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ userSettings: { keyboardShortcuts: {} } }), +})); + +import { useCommandPanelShortcuts } from "./use-command-panel-shortcuts"; + +function findShortcut(key: string, shift = false) { + return registrations.find( + ({ shortcut }) => shortcut.key === key && Boolean(shortcut.modifiers?.shift) === shift, + ); +} + +beforeEach(() => registrations.splice(0)); +afterEach(() => cleanup()); + +describe("useCommandPanelShortcuts", () => { + it("switches an open file palette to commands instead of closing it", () => { + const setMode = vi.fn(); + const setOpen = vi.fn(); + const setSearch = vi.fn(); + renderHook(() => + useCommandPanelShortcuts({ + open: true, + mode: "search-files", + workspaceSearchAvailable: true, + setMode, + setOpen, + setSearch, + }), + ); + + act(() => findShortcut("k")?.callback()); + + expect(setMode).toHaveBeenCalledWith("commands"); + expect(setSearch).toHaveBeenCalledWith(""); + expect(setOpen).toHaveBeenCalledWith(true); + }); + + it("does not register file search outside an active task workbench", () => { + const options = { + open: false, + mode: "commands" as const, + workspaceSearchAvailable: false, + setMode: vi.fn(), + setOpen: vi.fn(), + setSearch: vi.fn(), + }; + + renderHook(() => useCommandPanelShortcuts(options)); + + expect(findShortcut("k", true)?.enabled).toBe(false); + expect(findShortcut("k")?.enabled).toBe(true); + }); +}); diff --git a/apps/web/hooks/use-command-panel-shortcuts.ts b/apps/web/hooks/use-command-panel-shortcuts.ts new file mode 100644 index 0000000000..c9909ddbf2 --- /dev/null +++ b/apps/web/hooks/use-command-panel-shortcuts.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useAppStore } from "@/components/state-provider"; +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import type { CommandPanelMode } from "@/lib/commands/types"; +import { SHORTCUTS } from "@/lib/keyboard/constants"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; + +type CommandPanelShortcutOptions = { + open: boolean; + setOpen: (open: boolean) => void; + mode: CommandPanelMode; + workspaceSearchAvailable: boolean; + setMode: (mode: CommandPanelMode) => void; + setSearch: (search: string) => void; +}; + +export function useCommandPanelShortcuts({ + open, + setOpen, + mode, + workspaceSearchAvailable, + setMode, + setSearch, +}: CommandPanelShortcutOptions) { + const openRef = useRef(open); + useEffect(() => { + openRef.current = open; + }, [open]); + + const openCommands = useCallback(() => { + if (openRef.current && mode === "commands") { + setOpen(false); + return; + } + setMode("commands"); + setSearch(""); + setOpen(true); + }, [mode, setMode, setOpen, setSearch]); + + const openFileSearch = useCallback(() => { + if (openRef.current && mode === "search-files") { + setOpen(false); + return; + } + setMode("search-files"); + setSearch(""); + setOpen(true); + }, [mode, setMode, setOpen, setSearch]); + + const keyboardShortcuts = useAppStore((state) => state.userSettings.keyboardShortcuts); + useKeyboardShortcut(getShortcut("SEARCH", keyboardShortcuts), openCommands); + useKeyboardShortcut(getShortcut("COMMAND_PANEL", keyboardShortcuts), openCommands); + useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL_SHIFT, openCommands); + useKeyboardShortcut(getShortcut("FILE_SEARCH", keyboardShortcuts), openFileSearch, { + enabled: workspaceSearchAvailable, + }); +} diff --git a/apps/web/hooks/use-content-search-result-opener.test.ts b/apps/web/hooks/use-content-search-result-opener.test.ts new file mode 100644 index 0000000000..74e3a5c28c --- /dev/null +++ b/apps/web/hooks/use-content-search-result-opener.test.ts @@ -0,0 +1,31 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceContentSearchResult } from "@/lib/types/backend"; + +const mockOpenContentSearchResult = vi.fn(); + +vi.mock("@/lib/commands/content-search-selection", () => ({ + openContentSearchResult: (...args: unknown[]) => mockOpenContentSearchResult(...args), +})); + +import { useContentSearchResultOpener } from "./use-content-search-result-opener"; + +describe("useContentSearchResultOpener", () => { + it("closes the palette and opens the selected workspace result", () => { + const setOpen = vi.fn(); + const selected = { + repository_name: "web", + path: "src/app.tsx", + line: 3, + column: 2, + preview: "needle", + match_ranges: [], + } satisfies WorkspaceContentSearchResult; + const { result } = renderHook(() => useContentSearchResultOpener(setOpen, "/tasks/task-1")); + + act(() => result.current(selected)); + + expect(setOpen).toHaveBeenCalledWith(false); + expect(mockOpenContentSearchResult).toHaveBeenCalledWith(selected, "/tasks/task-1"); + }); +}); diff --git a/apps/web/hooks/use-content-search-result-opener.ts b/apps/web/hooks/use-content-search-result-opener.ts new file mode 100644 index 0000000000..34837bd79f --- /dev/null +++ b/apps/web/hooks/use-content-search-result-opener.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useCallback } from "react"; +import { openContentSearchResult } from "@/lib/commands/content-search-selection"; +import type { WorkspaceContentSearchResult } from "@/lib/types/backend"; + +export function useContentSearchResultOpener( + setOpen: (open: boolean) => void, + worktreePath: string | null, +) { + return useCallback( + (result: WorkspaceContentSearchResult) => { + setOpen(false); + openContentSearchResult(result, worktreePath); + }, + [setOpen, worktreePath], + ); +} diff --git a/apps/web/hooks/use-file-editors.test.tsx b/apps/web/hooks/use-file-editors.test.tsx index 8d3eb2fd44..387812df2b 100644 --- a/apps/web/hooks/use-file-editors.test.tsx +++ b/apps/web/hooks/use-file-editors.test.tsx @@ -77,6 +77,34 @@ describe("scrollEditorIfMounted", () => { expect(consumePendingCursorPosition(APP_PATH, REPO)).toBeUndefined(); }); + it("uses repo-scoped matching when a worktree path is also available", () => { + const wrongRepoEditor = createEditor("/worktree/backend/src/app.ts"); + const repoEditor = createEditor("/worktree/frontend/src/app.ts"); + getMonacoInstance.mockReturnValue({ + editor: { getEditors: () => [wrongRepoEditor, repoEditor] }, + }); + setPendingCursorPosition(APP_PATH, 12, 1, REPO); + + expect(scrollEditorIfMounted(APP_PATH, WORKTREE_PATH, 42, 3, REPO)).toBe(true); + + expect(wrongRepoEditor.setPosition).not.toHaveBeenCalled(); + expect(repoEditor.setPosition).toHaveBeenCalledWith({ lineNumber: 42, column: 3 }); + expect(consumePendingCursorPosition(APP_PATH, REPO)).toBeUndefined(); + }); + + it("scrolls a repo-scoped request in Monaco's worktree-relative model", () => { + const editor = createEditor(WORKTREE_APP_PATH); + getMonacoInstance.mockReturnValue({ editor: { getEditors: () => [editor] } }); + setPendingCursorPosition(APP_PATH, 12, 1, REPO); + + expect(scrollEditorIfMounted(APP_PATH, WORKTREE_PATH, 42, 3, REPO)).toBe(true); + + expect(editor.setPosition).toHaveBeenCalledWith({ lineNumber: 42, column: 3 }); + expect(editor.revealLineInCenter).toHaveBeenCalledWith(42); + expect(editor.focus).toHaveBeenCalledTimes(1); + expect(consumePendingCursorPosition(APP_PATH, REPO)).toBeUndefined(); + }); + it("does not suffix-scroll a repo-scoped request into an unscoped model path", () => { const editor = createEditor(WORKTREE_APP_PATH); getMonacoInstance.mockReturnValue({ editor: { getEditors: () => [editor] } }); diff --git a/apps/web/hooks/use-plugin-shortcuts.test.ts b/apps/web/hooks/use-plugin-shortcuts.test.ts index 9b651f3024..3d97431a98 100644 --- a/apps/web/hooks/use-plugin-shortcuts.test.ts +++ b/apps/web/hooks/use-plugin-shortcuts.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, cleanup } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; import type { PluginRecord } from "@/lib/types/plugins"; import { pluginRegistry } from "@/lib/plugins/registry"; +import { CommandRegistryProvider } from "@/lib/commands/command-registry"; let mockOverrides: StoredShortcutOverrides = {}; let mockItems: PluginRecord[] = []; @@ -61,6 +63,10 @@ const TOGGLE_KEYBINDING = { id: "toggle", default: "mod+shift+e", description: " /** A free combo (not used by any core shortcut) shared by two plugins bound to the same "open" id. */ const OPEN_COMBO = "mod+shift+o"; +function commandRegistryWrapper({ children }: { children: ReactNode }) { + return createElement(CommandRegistryProvider, null, children); +} + function withToggleKeybinding(overrides: Partial = {}): PluginRecord { return makePlugin({ ui: { keybindings: [TOGGLE_KEYBINDING] }, ...overrides }); } @@ -243,10 +249,13 @@ describe("core vs plugin shortcut precedence", () => { const pluginHandler = vi.fn(); pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", pluginHandler); - renderHook(() => { - useAppShortcuts(); - usePluginShortcuts(); - }); + renderHook( + () => { + useAppShortcuts(); + usePluginShortcuts(); + }, + { wrapper: commandRegistryWrapper }, + ); const event = pressKey("b", { ctrlKey: true }); expect(mockToggleAppSidebar).toHaveBeenCalledTimes(1); @@ -259,10 +268,13 @@ describe("core vs plugin shortcut precedence", () => { const handler = vi.fn(); pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler); - renderHook(() => { - useAppShortcuts(); - usePluginShortcuts(); - }); + renderHook( + () => { + useAppShortcuts(); + usePluginShortcuts(); + }, + { wrapper: commandRegistryWrapper }, + ); pressKey("e", { ctrlKey: true, shiftKey: true }); expect(handler).toHaveBeenCalledTimes(1); diff --git a/apps/web/lib/commands/command-registry.test.tsx b/apps/web/lib/commands/command-registry.test.tsx new file mode 100644 index 0000000000..c3edd135eb --- /dev/null +++ b/apps/web/lib/commands/command-registry.test.tsx @@ -0,0 +1,30 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { CommandRegistryProvider, useCommandPanelOpen } from "./command-registry"; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +afterEach(() => cleanup()); + +describe("command panel mode requests", () => { + it("opens the panel in a requested mode", () => { + const { result } = renderHook(() => useCommandPanelOpen(), { wrapper }); + + act(() => result.current.openMode("search-content")); + + expect(result.current.open).toBe(true); + expect(result.current.mode).toBe("search-content"); + }); + + it("keeps direct mode changes available to the mounted panel", () => { + const { result } = renderHook(() => useCommandPanelOpen(), { wrapper }); + + act(() => result.current.setMode("search-files")); + + expect(result.current.mode).toBe("search-files"); + expect(result.current.open).toBe(false); + }); +}); diff --git a/apps/web/lib/commands/command-registry.tsx b/apps/web/lib/commands/command-registry.tsx index 15cd17bc88..da4958fca5 100644 --- a/apps/web/lib/commands/command-registry.tsx +++ b/apps/web/lib/commands/command-registry.tsx @@ -10,6 +10,7 @@ import { type ReactNode, } from "react"; import type { CommandItem } from "./types"; +import type { CommandPanelMode } from "./types"; type CommandRegistryContextValue = { register: (sourceId: string, commands: CommandItem[]) => void; @@ -18,6 +19,10 @@ type CommandRegistryContextValue = { getSnapshot: () => CommandItem[]; open: boolean; setOpen: (open: boolean) => void; + mode: CommandPanelMode; + setMode: (mode: CommandPanelMode) => void; + openMode: (mode: CommandPanelMode) => void; + modeRequestVersion: number; }; const CommandRegistryContext = createContext(null); @@ -27,6 +32,14 @@ export function CommandRegistryProvider({ children }: { children: ReactNode }) { const listenersRef = useRef(new Set<() => void>()); const snapshotRef = useRef([]); const [open, setOpen] = useState(false); + const [mode, setMode] = useState("commands"); + const [modeRequestVersion, setModeRequestVersion] = useState(0); + + const openMode = useCallback((nextMode: CommandPanelMode) => { + setMode(nextMode); + setOpen(true); + setModeRequestVersion((version) => version + 1); + }, []); const notify = useCallback(() => { // Rebuild snapshot @@ -67,7 +80,18 @@ export function CommandRegistryProvider({ children }: { children: ReactNode }) { return ( {children} @@ -86,6 +110,6 @@ export function useCommands(): CommandItem[] { } export function useCommandPanelOpen() { - const { open, setOpen } = useCommandRegistry(); - return { open, setOpen }; + const { open, setOpen, mode, setMode, openMode, modeRequestVersion } = useCommandRegistry(); + return { open, setOpen, mode, setMode, openMode, modeRequestVersion }; } diff --git a/apps/web/lib/commands/content-search-selection.test.ts b/apps/web/lib/commands/content-search-selection.test.ts new file mode 100644 index 0000000000..7e146a5e22 --- /dev/null +++ b/apps/web/lib/commands/content-search-selection.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkspaceContentSearchResult } from "@/lib/types/backend"; + +const mockSetPendingCursorPosition = vi.fn(); +const mockScrollEditorIfMounted = vi.fn(); +const mockAddFileEditorPanel = vi.fn(); + +vi.mock("@/hooks/file-editor-cursor", () => ({ + setPendingCursorPosition: (...args: unknown[]) => mockSetPendingCursorPosition(...args), + scrollEditorIfMounted: (...args: unknown[]) => mockScrollEditorIfMounted(...args), +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: { + getState: () => ({ addFileEditorPanel: mockAddFileEditorPanel }), + }, +})); + +import { openContentSearchResult } from "./content-search-selection"; + +const FILE_PATH = "src/components/search.tsx"; +const FILE_NAME = "search.tsx"; + +const result: WorkspaceContentSearchResult = { + repository_name: "frontend", + path: FILE_PATH, + line: 42, + column: 7, + preview: "function search()", + match_ranges: [{ start: 9, end: 15 }], +}; + +describe("openContentSearchResult", () => { + beforeEach(() => { + mockSetPendingCursorPosition.mockReset(); + mockScrollEditorIfMounted.mockReset(); + mockAddFileEditorPanel.mockReset(); + }); + + it("opens the matching repository file at its exact cursor position", () => { + openContentSearchResult(result, "/tasks/task-1"); + + expect(mockSetPendingCursorPosition).toHaveBeenCalledWith(FILE_PATH, 42, 7, "frontend"); + expect(mockScrollEditorIfMounted).toHaveBeenCalledWith( + FILE_PATH, + "/tasks/task-1", + 42, + 7, + "frontend", + ); + expect(mockAddFileEditorPanel).toHaveBeenCalledWith(FILE_PATH, FILE_NAME, { + repo: "frontend", + }); + }); + + it("omits an empty repository key for a single-repo workspace", () => { + openContentSearchResult({ ...result, repository_name: "" }, null); + + expect(mockSetPendingCursorPosition).toHaveBeenCalledWith(FILE_PATH, 42, 7, undefined); + expect(mockAddFileEditorPanel).toHaveBeenCalledWith(FILE_PATH, FILE_NAME, { + repo: undefined, + }); + }); +}); diff --git a/apps/web/lib/commands/content-search-selection.ts b/apps/web/lib/commands/content-search-selection.ts new file mode 100644 index 0000000000..6915c7186e --- /dev/null +++ b/apps/web/lib/commands/content-search-selection.ts @@ -0,0 +1,14 @@ +import { scrollEditorIfMounted, setPendingCursorPosition } from "@/hooks/file-editor-cursor"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import type { WorkspaceContentSearchResult } from "@/lib/types/backend"; +import { getFileName } from "@/lib/utils/file-path"; + +export function openContentSearchResult( + result: WorkspaceContentSearchResult, + worktreePath: string | null, +): void { + const repo = result.repository_name || undefined; + setPendingCursorPosition(result.path, result.line, result.column, repo); + scrollEditorIfMounted(result.path, worktreePath, result.line, result.column, repo); + useDockviewStore.getState().addFileEditorPanel(result.path, getFileName(result.path), { repo }); +} diff --git a/apps/web/lib/commands/task-workspace-search.test.ts b/apps/web/lib/commands/task-workspace-search.test.ts new file mode 100644 index 0000000000..e159b6ef20 --- /dev/null +++ b/apps/web/lib/commands/task-workspace-search.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type { AppState } from "@/lib/state/store"; +import { isTaskWorkspaceSearchAvailable } from "./task-workspace-search"; + +const TASK_ID = "task-1"; +const SESSION_ID = "session-1"; + +function searchState({ + activeTaskId = TASK_ID, + activeSessionId = SESSION_ID, + sessionTaskId = TASK_ID, +}: { + activeTaskId?: string | null; + activeSessionId?: string | null; + sessionTaskId?: string; +} = {}): AppState { + return { + tasks: { + activeTaskId, + activeSessionId, + }, + taskSessions: { + items: activeSessionId + ? { + [activeSessionId]: { + id: activeSessionId, + task_id: sessionTaskId, + }, + } + : {}, + }, + } as unknown as AppState; +} + +describe("isTaskWorkspaceSearchAvailable", () => { + it("allows search for the active session inside its task detail route", () => { + expect(isTaskWorkspaceSearchAvailable(searchState(), `/t/${TASK_ID}`)).toBe(true); + }); + + it.each([ + ["missing active task", searchState({ activeTaskId: null }), `/t/${TASK_ID}`], + ["missing active session", searchState({ activeSessionId: null }), `/t/${TASK_ID}`], + ["non-task route", searchState(), "/tasks"], + ["different task route", searchState(), "/t/task-2"], + [ + "session belonging to another task", + searchState({ sessionTaskId: "task-2" }), + `/t/${TASK_ID}`, + ], + ])("rejects %s", (_label, state, pathname) => { + expect(isTaskWorkspaceSearchAvailable(state, pathname)).toBe(false); + }); +}); diff --git a/apps/web/lib/commands/task-workspace-search.ts b/apps/web/lib/commands/task-workspace-search.ts new file mode 100644 index 0000000000..883d84df86 --- /dev/null +++ b/apps/web/lib/commands/task-workspace-search.ts @@ -0,0 +1,8 @@ +import { isTaskDetailPath } from "@/lib/links"; +import type { AppState } from "@/lib/state/store"; + +export function isTaskWorkspaceSearchAvailable(state: AppState, pathname: string): boolean { + const { activeTaskId, activeSessionId } = state.tasks; + if (!activeTaskId || !activeSessionId || !isTaskDetailPath(pathname, activeTaskId)) return false; + return state.taskSessions.items[activeSessionId]?.task_id === activeTaskId; +} diff --git a/apps/web/lib/commands/types.ts b/apps/web/lib/commands/types.ts index 224b9ed41b..bf9fd5f164 100644 --- a/apps/web/lib/commands/types.ts +++ b/apps/web/lib/commands/types.ts @@ -1,7 +1,12 @@ import type { ReactNode } from "react"; import type { KeyboardShortcut } from "@/lib/keyboard/constants"; -export type CommandPanelMode = "commands" | "search-tasks" | "search-files" | "input"; +export type CommandPanelMode = + | "commands" + | "search-tasks" + | "search-files" + | "search-content" + | "input"; export type CommandItem = { id: string; diff --git a/apps/web/lib/keyboard/constants.ts b/apps/web/lib/keyboard/constants.ts index 5981db326c..27c7d61e2e 100644 --- a/apps/web/lib/keyboard/constants.ts +++ b/apps/web/lib/keyboard/constants.ts @@ -137,6 +137,10 @@ export const SHORTCUTS = { key: KEYS.K, modifiers: { ctrlOrCmd: true, shift: true }, }, + CONTENT_SEARCH: { + key: KEYS.F, + modifiers: { ctrlOrCmd: true, shift: true }, + }, TOGGLE_PLAN_MODE: { key: KEYS.TAB, modifiers: { shift: true }, diff --git a/apps/web/lib/keyboard/shortcut-overrides.test.ts b/apps/web/lib/keyboard/shortcut-overrides.test.ts index 2285aa9689..3f20054690 100644 --- a/apps/web/lib/keyboard/shortcut-overrides.test.ts +++ b/apps/web/lib/keyboard/shortcut-overrides.test.ts @@ -14,6 +14,7 @@ describe("CONFIGURABLE_SHORTCUTS", () => { const ids = Object.keys(CONFIGURABLE_SHORTCUTS); expect(ids).toContain("SEARCH"); expect(ids).toContain("FILE_SEARCH"); + expect(ids).toContain("CONTENT_SEARCH"); expect(ids).toContain("QUICK_CHAT"); expect(ids).toContain("BOTTOM_TERMINAL"); expect(ids).toContain("TOGGLE_SIDEBAR"); @@ -27,10 +28,12 @@ describe("CONFIGURABLE_SHORTCUTS", () => { expect(ids).toContain("VOICE_INPUT_TOGGLE"); expect(ids).toContain("REVERSE_SEARCH"); expect(ids).toContain("OPEN_TASK_PR"); - expect(ids).toHaveLength(15); + expect(ids).toHaveLength(16); }); it("each entry has a label and default matching SHORTCUTS", () => { + expect(CONFIGURABLE_SHORTCUTS.CONTENT_SEARCH.label).toBe("Search Task Contents"); + expect(CONFIGURABLE_SHORTCUTS.CONTENT_SEARCH.default).toBe(SHORTCUTS.CONTENT_SEARCH); expect(CONFIGURABLE_SHORTCUTS.BOTTOM_TERMINAL.label).toBe("Toggle Bottom Terminal"); expect(CONFIGURABLE_SHORTCUTS.BOTTOM_TERMINAL.default).toBe(SHORTCUTS.BOTTOM_TERMINAL); diff --git a/apps/web/lib/keyboard/shortcut-overrides.ts b/apps/web/lib/keyboard/shortcut-overrides.ts index 2fcc3be325..5094726357 100644 --- a/apps/web/lib/keyboard/shortcut-overrides.ts +++ b/apps/web/lib/keyboard/shortcut-overrides.ts @@ -3,6 +3,7 @@ import { SHORTCUTS, type KeyboardShortcut } from "./constants"; export type ConfigurableShortcutId = | "SEARCH" | "FILE_SEARCH" + | "CONTENT_SEARCH" | "QUICK_CHAT" | "BOTTOM_TERMINAL" | "TOGGLE_SIDEBAR" @@ -39,6 +40,7 @@ export const CONFIGURABLE_SHORTCUTS: Record< > = { SEARCH: { label: "Command Panel", default: SHORTCUTS.SEARCH }, FILE_SEARCH: { label: "File Search", default: SHORTCUTS.FILE_SEARCH }, + CONTENT_SEARCH: { label: "Search Task Contents", default: SHORTCUTS.CONTENT_SEARCH }, QUICK_CHAT: { label: "Quick Chat", default: SHORTCUTS.QUICK_CHAT }, BOTTOM_TERMINAL: { label: "Toggle Bottom Terminal", default: SHORTCUTS.BOTTOM_TERMINAL }, TOGGLE_SIDEBAR: { label: "Toggle Sidebar", default: UNBOUND_SHORTCUT }, diff --git a/apps/web/lib/types/workspace-files.ts b/apps/web/lib/types/workspace-files.ts index 32e5a48601..78fa0697a5 100644 --- a/apps/web/lib/types/workspace-files.ts +++ b/apps/web/lib/types/workspace-files.ts @@ -23,8 +23,37 @@ export type FileContentResponse = { error?: string; }; +export type FileSearchResult = { + repository_name?: string; + /** Task-root-relative path accepted by the workspace file APIs. */ + path: string; +}; + export type FileSearchResponse = { files: string[]; + /** Structured results for repository-aware consumers. */ + results?: FileSearchResult[]; + error?: string; +}; + +export type ContentSearchMatchRange = { + /** Inclusive UTF-16 offset into `preview`. */ + start: number; + /** Exclusive UTF-16 offset into `preview`. */ + end: number; +}; + +export type WorkspaceContentSearchResult = { + repository_name: string; + path: string; + line: number; + column: number; + preview: string; + match_ranges: ContentSearchMatchRange[]; +}; + +export type WorkspaceContentSearchResponse = { + results: WorkspaceContentSearchResult[]; error?: string; }; diff --git a/apps/web/lib/ws/workspace-content-search.test.ts b/apps/web/lib/ws/workspace-content-search.test.ts new file mode 100644 index 0000000000..b3d71bb80a --- /dev/null +++ b/apps/web/lib/ws/workspace-content-search.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WebSocketClient } from "./client"; +import { searchWorkspaceContent } from "./workspace-files"; + +describe("searchWorkspaceContent", () => { + it("sends a repository-scoped result request for the active session", async () => { + const response = { + results: [ + { + repository_name: "frontend", + path: "src/app.tsx", + line: 12, + column: 8, + preview: "const searchable = true", + match_ranges: [{ start: 6, end: 16 }], + }, + ], + }; + const request = vi.fn().mockResolvedValue(response); + const client = { request } as unknown as WebSocketClient; + + await expect(searchWorkspaceContent(client, "session-1", "searchable", 50)).resolves.toEqual( + response, + ); + expect(request).toHaveBeenCalledWith("workspace.content.search", { + session_id: "session-1", + query: "searchable", + limit_per_repo: 50, + }); + }); +}); diff --git a/apps/web/lib/ws/workspace-files.ts b/apps/web/lib/ws/workspace-files.ts index 0586435dea..5c0a0cd62c 100644 --- a/apps/web/lib/ws/workspace-files.ts +++ b/apps/web/lib/ws/workspace-files.ts @@ -3,6 +3,7 @@ import type { FileTreeResponse, FileContentResponse, FileSearchResponse, + WorkspaceContentSearchResponse, } from "@/lib/types/backend"; /** @@ -75,6 +76,20 @@ export async function searchWorkspaceFiles( }); } +/** Search text content across every repository in the active task workspace. */ +export async function searchWorkspaceContent( + client: WebSocketClient, + sessionId: string, + query: string, + limitPerRepo: number = 50, +): Promise { + return client.request("workspace.content.search", { + session_id: sessionId, + query, + limit_per_repo: limitPerRepo, + }); +} + /** * File update response from backend */ diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index 7610d888d6..6ce3a2166c 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -86,6 +86,20 @@ Messages show peer attribution, and Kandev gives the receiving agent hidden repl Desktop panel groups can host agent chat, files, terminals, Changes, the task plan, previews, and GitHub pull-request detail. Use **+** to add a panel. Mobile exposes sessions, files, terminal, and changes through task navigation and sheets. Its task switcher opens as an inset bottom card, and the current-session control shows the active agent's icon and name. +Press **Cmd+Shift+F** on macOS or **Ctrl+Shift+F** elsewhere to search the +contents of every file in the active task workspace. Results are grouped by +repository and show the repository-relative path, line number, and matching +line; selecting one opens that repository's file at the match. Content search +includes tracked files and untracked files that are not ignored. Use +**Cmd/Ctrl+Shift+K** when you want to search only file names and paths across +all repositories in the active task. The palette keeps **Commands**, **Files**, +and **Contents** visible as compact tabs beside the search field while an active +task workbench is open; elsewhere the palette remains command-only and leaves +the workspace-search shortcuts untouched. Click a mode or press **Tab** / +**Shift+Tab** to switch without clearing your query or moving focus from the +search field. File matches are grouped by repository. Hover a mode to see its +direct shortcut. + Open **Settings > General > Layouts** to configure reusable desktop workbench profiles. Select a tab in a built-in layout to reveal its nearby edit controls, arrange or remove tabs and splits, then use the floating **Save changes** control. Kandev keeps the built-in row visible, marks it **Customized**, and stores your override without requiring a duplicate. Choose **Reset** beside a customized built-in to restore its original definition. Removing Terminal from the Default layout also prevents Kandev from creating its initial user shell. Changing the default does not replace a layout already saved for a task; choose **Reset Layout** from the workbench layout menu when you want that task to adopt the latest default. All panels for a task point at the same task environment. In a multi-repository task, check the repository label before editing, committing, or reviewing. A preview also requires the application to listen on a reachable interface and expose a forwarded port. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 2779254784..a4bf6d80ac 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -136,6 +136,7 @@ Subscription quota tracking and per-agent cheap-model profile routing. | [agent-message-comments](ui/agent-message-comments.md) | shipped | | [external-vcs-file-links](ui/external-vcs-file-links.md) | shipped | | [task-listing-display-preferences](ui/task-listing-display-preferences.md) | shipped | +| [task-workspace-content-search](ui/task-workspace-content-search.md) | shipped | ## system-page/ — operational diagnostics & maintenance UI diff --git a/docs/specs/ui/task-workspace-content-search.md b/docs/specs/ui/task-workspace-content-search.md new file mode 100644 index 0000000000..37c78020fb --- /dev/null +++ b/docs/specs/ui/task-workspace-content-search.md @@ -0,0 +1,159 @@ +--- +status: shipped +created: 2026-07-27 +owner: kandev +--- + +# Task Workspace Content Search + +## Why + +People working inside a task need to find code by what it says, not only by its +file name. Opening a terminal and running a repository-specific search breaks +the workbench flow, and it is especially awkward when a task contains several +repositories. + +## What + +- On a task route, **Cmd+Shift+F** on macOS or **Ctrl+Shift+F** elsewhere opens + the command palette directly in workspace content-search mode. +- The shortcut works while a workbench editor or text input has focus and takes + precedence over that surface's local find shortcut. It prevents the browser's + default search action. +- The command palette visibly exposes **Commands**, **Files**, and **Contents** + as low-chrome text tabs beside the search field. The active mode uses a subtle + underline, and each mode's direct shortcut remains discoverable from its + tooltip without adding a separate selector row. +- **Files** and **Contents** are available only on the active task-detail route + when its selected session belongs to that task. Elsewhere the palette is + command-only, does not intercept either workspace-search shortcut, and + normalizes a previously open workspace-search palette back to **Commands**. +- Clicking a mode or pressing **Tab** / **Shift+Tab** switches among those modes + without discarding the current query. The existing **Cmd/Ctrl+Shift+K** + shortcut still opens file-name and path search directly. +- File-name and path search covers every repository materialized for the active + task. Multi-repository results use task-root-relative, repository-prefixed + paths so same-named files remain distinguishable and open in the correct + repository. The palette groups those matches by repository and shows paths + relative to each group. +- Search covers every repository materialized for the active task session. + Tracked files and untracked, non-ignored files are eligible; ignored files, + directories, and workspace metadata are not. +- Matching is case-insensitive and fuzzy within one line. A contiguous match + ranks ahead of a non-contiguous subsequence match. Adjacency, word boundaries, + and shorter gaps improve a fuzzy match's rank. +- Each result shows its repository, repository-relative path, one-based line + number, and a single-line content preview. Matched characters are highlighted + in the preview. +- Results are grouped by repository. A repository's display label may be + shortened for readability, but selecting a result retains the repository's + raw transport identity. +- Selecting a result opens or activates that repository's file and places the + cursor at the result's one-based line and column. Both Monaco and CodeMirror + reveal the location, including when the editor is mounted after selection. +- Empty-query, searching, no-match, unavailable-session, and failed-search + states are distinguishable without closing the palette. +- Search is bounded: a query contains at most 200 characters, each repository + returns at most 50 results, and files larger than the existing 10 MiB + workspace-file limit are skipped. +- Binary files, invalid UTF-8, unreadable files, paths that escape the + repository, and files removed during a search are skipped without failing + the entire query. +- Match ranges use UTF-16 offsets into the returned preview so browser + highlighting remains correct for non-BMP Unicode text. +- Search does not require an external executable such as ripgrep, so it behaves + consistently in local, container, remote, and Windows executors. + +## API surface + +The browser requests workspace content search over the session-scoped WebSocket +action: + +```text +workspace.content.search +``` + +The request contains `session_id`, `query`, and an optional +`limit_per_repo`. The response contains ordered result records: + +| Field | Meaning | +| ----------------- | ------------------------------------------------------------- | +| `repository_name` | Raw repository identity used by workspace file APIs | +| `path` | Repository-relative slash-separated path | +| `line` | One-based source line | +| `column` | One-based UTF-16 source column of the first matched character | +| `preview` | Searchable source line presented by the palette | +| `match_ranges` | Half-open UTF-16 ranges into `preview` | + +The backend-to-agentctl HTTP transport exposes the same result shape. Workspace +file-name search keeps its legacy `files` array and additionally returns +structured `results` entries containing `repository_name` and the +task-root-relative `path`, allowing grouping-aware consumers to separate +repositories without parsing path strings. + +## Permissions and isolation + +- A request is authorized through the session ID before its execution + workspace is accessed. +- Only repositories attached to that session's task execution are searched. +- Repository-relative paths are resolved through the existing workspace path + containment rules; symlinks or traversal cannot expand search scope. +- Result data contains no absolute host path. + +## Failure modes + +- Without an active task session, the palette explains that content search is + unavailable and sends no request. +- A blank query clears prior results and sends no request. +- A query over the maximum length is rejected consistently rather than scanning + with a silently different value. +- If one eligible file disappears, becomes unreadable, or is invalid while the + search runs, other files and repositories still return results. +- If the execution cannot be recovered or the transport request fails, the + palette presents a retryable failure state and keeps the entered query. +- Cancellation stops outstanding repository work and stale responses do not + replace results for a newer query. + +## Scenarios + +- **GIVEN** a task editor has focus, **WHEN** the user presses + **Cmd/Ctrl+Shift+F**, **THEN** task content search opens and the browser or + editor find UI does not. +- **GIVEN** the palette is open with a query, **WHEN** the user clicks another + top-level mode or presses **Tab**, **THEN** the mode changes, the query remains, + and the input keeps focus. +- **GIVEN** the user leaves the active task workbench, **WHEN** the palette is + opened or a workspace-search shortcut is pressed, **THEN** only + **Commands** is offered and the workspace-search shortcut keeps its native + behavior. +- **GIVEN** a line containing an exact occurrence and another containing only a + fuzzy subsequence, **WHEN** both match the query, **THEN** the exact occurrence + ranks first. +- **GIVEN** a task with two repositories containing the same search term, + **WHEN** the user searches, **THEN** both repositories have clearly separated + result groups with repository-relative paths. +- **GIVEN** a task with two repositories containing matching file names, + **WHEN** the user searches in **Files** mode, **THEN** matching paths from both + repositories appear with their repository prefixes. +- **GIVEN** two repositories with the same relative path, **WHEN** the user + selects the result from the second repository, **THEN** Kandev opens the + second repository's file at the returned line and column. +- **GIVEN** an untracked non-ignored text file and an ignored text file, + **WHEN** both contain the query, **THEN** only the untracked non-ignored file + appears. +- **GIVEN** a preview containing non-BMP Unicode before and inside a match, + **WHEN** results render, **THEN** the supplied match ranges highlight the + intended characters without splitting a surrogate pair. +- **GIVEN** a search is followed quickly by another query, **WHEN** the first + response arrives last, **THEN** the palette retains the second query's + results. + +## Out of scope + +- Replacing file-name and path search. +- Searching tasks other than the task currently open. +- Searching Git history, ignored files, generated artifacts, agent messages, + terminal output, or files outside attached repositories. +- Regular-expression, whole-word, case-sensitive, replace, or persistent search + options. +- A persistent full-text index or external search service.